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.
- package/README.md +96 -129
- 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 +31 -4
- package/public/store.mjs +13 -7
- package/public/styles.css +1 -1
- package/public/ui.mjs +9 -17
- 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 +14 -0
- package/public/views/header.mjs +105 -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/rtk.mjs +148 -0
- 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/tools.mjs +76 -0
- package/public/views/worklist.mjs +33 -0
- package/server.mjs +9 -0
- package/src/rtk.mjs +179 -0
- package/public/views/health.mjs +0 -21
- package/public/views/prune.mjs +0 -191
- package/public/views/trash.mjs +0 -37
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const COST_KINDS = new Set(['long-hook', 'hook-repeats-description', 'empty-section', 'index-continuation']);
|
|
2
|
+
|
|
3
|
+
function rankOf(kind, severity) {
|
|
4
|
+
if (kind === 'past-cutoff') return 0;
|
|
5
|
+
if (severity === 'bad') return 1;
|
|
6
|
+
if (COST_KINDS.has(kind)) return 2;
|
|
7
|
+
if (kind === 'overlap') return 4;
|
|
8
|
+
return 3;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildWorklist(store) {
|
|
12
|
+
if (!store || !store.stats) return [];
|
|
13
|
+
const items = [];
|
|
14
|
+
const { index } = store.stats;
|
|
15
|
+
|
|
16
|
+
for (const entry of index.cutoff?.droppedEntries ?? []) {
|
|
17
|
+
items.push({ kind: 'past-cutoff', severity: 'bad', entry });
|
|
18
|
+
}
|
|
19
|
+
for (const hook of index.longHooks) {
|
|
20
|
+
items.push({ kind: 'long-hook', severity: 'warn', hook });
|
|
21
|
+
}
|
|
22
|
+
for (const item of store.health.issues) {
|
|
23
|
+
if (item.kind === 'long-hooks') continue;
|
|
24
|
+
items.push(item);
|
|
25
|
+
}
|
|
26
|
+
for (const pair of store.duplicates) {
|
|
27
|
+
items.push({ kind: 'overlap', severity: 'warn', pair });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return items
|
|
31
|
+
.map((item) => ({ ...item, rank: rankOf(item.kind, item.severity) }))
|
|
32
|
+
.sort((a, b) => a.rank - b.rank);
|
|
33
|
+
}
|
package/server.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import { listStores } from './src/stores.mjs';
|
|
|
16
16
|
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
17
17
|
import { searchAll } from './src/search.mjs';
|
|
18
18
|
import { verifyPaths } from './src/pathcheck.mjs';
|
|
19
|
+
import { findRtk, rtkReport } from './src/rtk.mjs';
|
|
19
20
|
import { sessionsWithSummaries, transcriptDir } from './src/sessions.mjs';
|
|
20
21
|
import {
|
|
21
22
|
addIndexEntry,
|
|
@@ -217,6 +218,7 @@ async function handleApi(req, res, url) {
|
|
|
217
218
|
rootSource: origin.path === ROOT ? origin.source : 'flag',
|
|
218
219
|
rootFile: origin.path === ROOT ? origin.file : null,
|
|
219
220
|
rootWarning: origin.invalid,
|
|
221
|
+
tools: [findRtk()],
|
|
220
222
|
stores: listStores(ROOT),
|
|
221
223
|
});
|
|
222
224
|
}
|
|
@@ -276,6 +278,13 @@ async function handleApi(req, res, url) {
|
|
|
276
278
|
return sendJson(res, 200, sessionsWithSummaries(slugDir, { projectDir: storeProjectDir(store) }));
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
if (action === 'tools/rtk' && req.method === 'GET') {
|
|
282
|
+
const rtk = findRtk();
|
|
283
|
+
if (!rtk.found) throw new Error('rtk is not installed, or not on the PATH this server was started with.');
|
|
284
|
+
const report = await rtkReport(storeProjectDir(store));
|
|
285
|
+
return sendJson(res, 200, { ...report, path: rtk.path });
|
|
286
|
+
}
|
|
287
|
+
|
|
279
288
|
if (action === 'settings' && req.method === 'GET') {
|
|
280
289
|
return sendJson(res, 200, settingsReport({ projectDir: storeProjectDir(store) }));
|
|
281
290
|
}
|
package/src/rtk.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const BINARY = 'rtk';
|
|
7
|
+
const TIMEOUT_MS = 15000;
|
|
8
|
+
const MAX_BUFFER = 4 * 1024 * 1024;
|
|
9
|
+
const DISCOVER_DAYS = 30;
|
|
10
|
+
const DISCOVER_LIMIT = 10;
|
|
11
|
+
const MISSED_LIMIT = 12;
|
|
12
|
+
const TREND_DAYS = 30;
|
|
13
|
+
|
|
14
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
|
|
15
|
+
const text = (value) => (typeof value === 'string' ? value : '');
|
|
16
|
+
const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
|
|
17
|
+
|
|
18
|
+
function candidateNames(env) {
|
|
19
|
+
if (process.platform !== 'win32') return [BINARY];
|
|
20
|
+
const exts = text(env.PATHEXT || '.EXE').split(';').filter(Boolean);
|
|
21
|
+
return exts.map((ext) => BINARY + ext.toLowerCase());
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findRtk(env = process.env) {
|
|
25
|
+
const dirs = text(env.PATH).split(path.delimiter).filter(Boolean);
|
|
26
|
+
const names = candidateNames(env);
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
for (const name of names) {
|
|
29
|
+
const candidate = path.join(dir, name);
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.statSync(candidate).isFile()) continue;
|
|
32
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
33
|
+
return { id: BINARY, found: true, path: candidate };
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { id: BINARY, found: false, path: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runRtk(args, { cwd, timeout = TIMEOUT_MS } = {}) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const options = { cwd, timeout, maxBuffer: MAX_BUFFER, windowsHide: true, encoding: 'utf8' };
|
|
45
|
+
execFile(BINARY, args, options, (err, stdout, stderr) => {
|
|
46
|
+
if (!err) return resolve(stdout);
|
|
47
|
+
if (err.code === 'ENOENT') return reject(new Error('rtk is not on this process PATH'));
|
|
48
|
+
if (err.killed) return reject(new Error(`rtk ${args[0]} was still running after ${timeout}ms and was stopped`));
|
|
49
|
+
const detail = text(stderr || stdout).trim().split('\n')[0];
|
|
50
|
+
return reject(new Error(detail ? `rtk ${args[0]} failed: ${detail}` : `rtk ${args[0]} exited ${err.code}`));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseJson(raw, label) {
|
|
56
|
+
try {
|
|
57
|
+
const value = JSON.parse(raw);
|
|
58
|
+
if (!value || typeof value !== 'object') throw new Error('not an object');
|
|
59
|
+
return value;
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`rtk ${label} did not answer with JSON`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summaryOf(raw) {
|
|
66
|
+
const summary = raw && typeof raw.summary === 'object' && raw.summary ? raw.summary : {};
|
|
67
|
+
return {
|
|
68
|
+
commands: num(summary.total_commands),
|
|
69
|
+
input: num(summary.total_input),
|
|
70
|
+
output: num(summary.total_output),
|
|
71
|
+
saved: num(summary.total_saved),
|
|
72
|
+
savedPct: num(summary.avg_savings_pct),
|
|
73
|
+
totalMs: num(summary.total_time_ms),
|
|
74
|
+
avgMs: num(summary.avg_time_ms),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function daysOf(raw) {
|
|
79
|
+
const list = raw && Array.isArray(raw.daily) ? raw.daily : [];
|
|
80
|
+
return list
|
|
81
|
+
.filter((day) => day && typeof day === 'object')
|
|
82
|
+
.map((day) => ({
|
|
83
|
+
date: text(day.date),
|
|
84
|
+
commands: num(day.commands),
|
|
85
|
+
input: num(day.input_tokens),
|
|
86
|
+
saved: num(day.saved_tokens),
|
|
87
|
+
savedPct: num(day.savings_pct),
|
|
88
|
+
}))
|
|
89
|
+
.filter((day) => day.date);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function windowOf(days, count) {
|
|
93
|
+
const recent = days.slice(-count);
|
|
94
|
+
const totals = recent.reduce((sum, day) => ({
|
|
95
|
+
commands: sum.commands + day.commands,
|
|
96
|
+
input: sum.input + day.input,
|
|
97
|
+
saved: sum.saved + day.saved,
|
|
98
|
+
}), { commands: 0, input: 0, saved: 0 });
|
|
99
|
+
return {
|
|
100
|
+
days: count,
|
|
101
|
+
active: recent.length,
|
|
102
|
+
commands: totals.commands,
|
|
103
|
+
saved: totals.saved,
|
|
104
|
+
savedPct: pct(totals.saved, totals.input),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function discoverOf(raw) {
|
|
109
|
+
const commands = num(raw.total_commands);
|
|
110
|
+
const alreadyRtk = num(raw.already_rtk);
|
|
111
|
+
const supported = Array.isArray(raw.supported) ? raw.supported : [];
|
|
112
|
+
const missed = supported
|
|
113
|
+
.filter((item) => item && typeof item === 'object')
|
|
114
|
+
.map((item) => ({
|
|
115
|
+
command: text(item.command),
|
|
116
|
+
count: num(item.count),
|
|
117
|
+
equivalent: text(item.rtk_equivalent),
|
|
118
|
+
category: text(item.category),
|
|
119
|
+
savedTokens: num(item.estimated_savings_tokens),
|
|
120
|
+
savedPct: num(item.estimated_savings_pct),
|
|
121
|
+
}))
|
|
122
|
+
.filter((item) => item.command)
|
|
123
|
+
.sort((a, b) => b.savedTokens - a.savedTokens);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
sessionsScanned: num(raw.sessions_scanned),
|
|
127
|
+
commands,
|
|
128
|
+
alreadyRtk,
|
|
129
|
+
adoptionPct: pct(alreadyRtk, commands),
|
|
130
|
+
sinceDays: num(raw.since_days) || DISCOVER_DAYS,
|
|
131
|
+
missedTotal: missed.length,
|
|
132
|
+
missed: missed.slice(0, MISSED_LIMIT),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function usableDir(dir) {
|
|
137
|
+
if (!dir) return null;
|
|
138
|
+
try {
|
|
139
|
+
return fs.statSync(dir).isDirectory() ? dir : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function rtkReport(projectDir, { run = runRtk, home = os.homedir() } = {}) {
|
|
146
|
+
const errors = [];
|
|
147
|
+
const step = async (label, work) => {
|
|
148
|
+
try {
|
|
149
|
+
return await work();
|
|
150
|
+
} catch (err) {
|
|
151
|
+
errors.push({ step: label, message: err.message });
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const dir = usableDir(projectDir);
|
|
157
|
+
|
|
158
|
+
const [version, machine, project, discover] = await Promise.all([
|
|
159
|
+
step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^rtk\s+/, '')),
|
|
160
|
+
step('gain', async () => parseJson(await run(['gain', '-a', '--format', 'json'], { cwd: home }), 'gain')),
|
|
161
|
+
dir ? step('gain --project', async () => parseJson(await run(['gain', '-p', '--format', 'json'], { cwd: dir }), 'gain --project')) : null,
|
|
162
|
+
dir ? step('discover', async () => parseJson(
|
|
163
|
+
await run(['discover', '--format', 'json', '-s', String(DISCOVER_DAYS), '-l', String(DISCOVER_LIMIT)], { cwd: dir }),
|
|
164
|
+
'discover',
|
|
165
|
+
)) : null,
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const days = daysOf(machine);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
tool: BINARY,
|
|
172
|
+
version: version || null,
|
|
173
|
+
projectDir: dir,
|
|
174
|
+
machine: machine ? { ...summaryOf(machine), days, window: windowOf(days, TREND_DAYS) } : null,
|
|
175
|
+
project: project ? summaryOf(project) : null,
|
|
176
|
+
discover: discover ? discoverOf(discover) : null,
|
|
177
|
+
errors,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/public/views/health.mjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import * as ui from '/ui.mjs';
|
|
2
|
-
import { node } from '/dom.mjs';
|
|
3
|
-
import { state } from '/state.mjs';
|
|
4
|
-
import { renderIssue } from '/views/issue.mjs';
|
|
5
|
-
import { renderPathCheck } from '/views/path-check.mjs';
|
|
6
|
-
|
|
7
|
-
export function renderHealth(container) {
|
|
8
|
-
const { health, memories } = state.store;
|
|
9
|
-
|
|
10
|
-
if (!health.issues.length) {
|
|
11
|
-
container.append(node('div', { class: ui.card }, [
|
|
12
|
-
node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
|
|
13
|
-
node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
14
|
-
]));
|
|
15
|
-
} else {
|
|
16
|
-
for (const item of health.issues) container.append(renderIssue(item, memories));
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
|
|
20
|
-
renderPathCheck(container);
|
|
21
|
-
}
|
package/public/views/prune.mjs
DELETED
|
@@ -1,191 +0,0 @@
|
|
|
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
|
-
}
|
package/public/views/trash.mjs
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import * as ui from '/ui.mjs';
|
|
2
|
-
import { node } from '/dom.mjs';
|
|
3
|
-
import { state } from '/state.mjs';
|
|
4
|
-
import { restoreFromTrash } from '/store.mjs';
|
|
5
|
-
|
|
6
|
-
const INDEX_EDIT_LABELS = {
|
|
7
|
-
hook: 'MEMORY.md hook shortened',
|
|
8
|
-
move: 'MEMORY.md entry moved',
|
|
9
|
-
add: 'MEMORY.md entry added',
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export function renderTrash(container) {
|
|
13
|
-
const trash = state.store.trash;
|
|
14
|
-
if (!trash.length) {
|
|
15
|
-
container.append(node('p', { class: ui.note, text: 'Nothing in the trash. Deleted memories land here and can be restored.' }));
|
|
16
|
-
return;
|
|
17
|
-
}
|
|
18
|
-
for (const record of trash) {
|
|
19
|
-
const when = String(record.deletedAt).replace('T', ' ').replace(/\..*$/, '');
|
|
20
|
-
const detail = record.kind === 'wikilink'
|
|
21
|
-
? `unlinked in ${record.sourceFile} · ${when}`
|
|
22
|
-
: record.kind === 'index-edit'
|
|
23
|
-
? `${INDEX_EDIT_LABELS[record.op] || 'MEMORY.md edited'} · ${when}`
|
|
24
|
-
: record.kind === 'merge'
|
|
25
|
-
? `merged into ${record.into} · ${when} · ${record.backups?.length || 0} file(s) rewritten`
|
|
26
|
-
: `${record.files.length} file(s)${record.indexTrashedFile ? ' + MEMORY.md' : ''} · ${when} · ${record.removedLines?.length || 0} index line(s) removed`;
|
|
27
|
-
container.append(node('div', { class: ui.issue(!record.present) }, [
|
|
28
|
-
node('div', { class: ui.issueBody }, [
|
|
29
|
-
node('div', { class: ui.issueTitle, text: record.label || record.id }),
|
|
30
|
-
node('div', { class: ui.issueDetail, text: record.present ? detail : `${detail}, backup missing, cannot restore` }),
|
|
31
|
-
]),
|
|
32
|
-
record.present
|
|
33
|
-
? node('button', { class: ui.buttonPrimarySmall, text: 'Restore', onclick: () => restoreFromTrash(record.id) })
|
|
34
|
-
: null,
|
|
35
|
-
]));
|
|
36
|
-
}
|
|
37
|
-
}
|