claude-memory-admin 1.4.0 → 1.5.1
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 +26 -5
- package/package.json +1 -1
- package/public/app.mjs +479 -289
- package/public/dialog.mjs +84 -0
- package/public/dom.mjs +15 -0
- package/public/index.html +1 -1
- package/public/styles.css +1 -1
- package/public/ui.mjs +20 -6
- package/server.mjs +9 -0
- package/src/checks.mjs +58 -0
- package/src/model.mjs +40 -1
- package/src/projects.mjs +6 -2
- package/src/sessions.mjs +174 -0
package/public/app.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { renderGraph } from '/graph.mjs';
|
|
2
2
|
import { renderMarkdown } from '/markdown.mjs';
|
|
3
|
+
import { confirmDialog, isDialogOpen, openDialog } from '/dialog.mjs';
|
|
4
|
+
import { el, node } from '/dom.mjs';
|
|
3
5
|
import * as ui from '/ui.mjs';
|
|
4
6
|
|
|
5
7
|
const state = {
|
|
@@ -17,7 +19,7 @@ const state = {
|
|
|
17
19
|
pruneSort: 'oldest',
|
|
18
20
|
pruneSelection: new Set(),
|
|
19
21
|
indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
|
|
20
|
-
aux: { instructions: null, settings: null },
|
|
22
|
+
aux: { instructions: null, settings: null, sessions: null },
|
|
21
23
|
pathCheck: null,
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -25,8 +27,6 @@ const RANK = { ok: 0, warn: 1, bad: 2 };
|
|
|
25
27
|
const worst = (...severities) => severities.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'ok');
|
|
26
28
|
const worstSeverity = (items) => (!items.length ? 'ok' : items.some((item) => item.severity === 'bad') ? 'bad' : 'warn');
|
|
27
29
|
|
|
28
|
-
const el = (id) => document.getElementById(id);
|
|
29
|
-
|
|
30
30
|
async function api(path, options) {
|
|
31
31
|
const response = await fetch(path, {
|
|
32
32
|
headers: { 'content-type': 'application/json' },
|
|
@@ -38,32 +38,17 @@ async function api(path, options) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function toast(message, { error = false, action } = {}) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
node.append(document.createTextNode(message));
|
|
41
|
+
const element = node('div', { class: ui.toast(error) });
|
|
42
|
+
element.append(document.createTextNode(message));
|
|
44
43
|
if (action) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
element.append(node('button', {
|
|
45
|
+
class: ui.toastAction,
|
|
46
|
+
text: action.label,
|
|
47
|
+
onclick: () => { element.remove(); action.run(); },
|
|
48
|
+
}));
|
|
50
49
|
}
|
|
51
|
-
el('toast-root').append(
|
|
52
|
-
setTimeout(() =>
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function node(tag, props = {}, children = []) {
|
|
56
|
-
const element = document.createElement(tag);
|
|
57
|
-
for (const [key, value] of Object.entries(props)) {
|
|
58
|
-
if (key === 'class') element.className = value;
|
|
59
|
-
else if (key === 'text') element.textContent = value;
|
|
60
|
-
else if (key.startsWith('on')) element.addEventListener(key.slice(2).toLowerCase(), value);
|
|
61
|
-
else if (value !== null && value !== undefined) element.setAttribute(key, value);
|
|
62
|
-
}
|
|
63
|
-
for (const child of [].concat(children)) {
|
|
64
|
-
if (child) element.append(child);
|
|
65
|
-
}
|
|
66
|
-
return element;
|
|
50
|
+
el('toast-root').append(element);
|
|
51
|
+
setTimeout(() => element.remove(), action ? 12000 : 4500);
|
|
67
52
|
}
|
|
68
53
|
|
|
69
54
|
function memoryLookup(project) {
|
|
@@ -153,6 +138,7 @@ const TABS = [
|
|
|
153
138
|
{ id: 'graph', label: 'Graph' },
|
|
154
139
|
{ id: 'prune', label: 'Prune' },
|
|
155
140
|
{ id: 'context', label: 'Context' },
|
|
141
|
+
{ id: 'sessions', label: 'Sessions' },
|
|
156
142
|
{ id: 'health', label: 'Health' },
|
|
157
143
|
{ id: 'settings', label: 'Settings' },
|
|
158
144
|
{ id: 'trash', label: 'Trash' },
|
|
@@ -169,6 +155,7 @@ function renderTabs() {
|
|
|
169
155
|
for (const tab of TABS) {
|
|
170
156
|
if (global && tab.id !== 'context') continue;
|
|
171
157
|
if (!global && tab.id === 'context' && !hasProjectDir) continue;
|
|
158
|
+
if (tab.id === 'sessions' && !state.store.sessions?.count) continue;
|
|
172
159
|
let badge = null;
|
|
173
160
|
let tone = 'neutral';
|
|
174
161
|
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
@@ -181,6 +168,10 @@ function renderTabs() {
|
|
|
181
168
|
badge = '!';
|
|
182
169
|
tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
|
|
183
170
|
}
|
|
171
|
+
if (tab.id === 'sessions' && state.store.sessions) {
|
|
172
|
+
badge = String(state.store.sessions.count);
|
|
173
|
+
if (state.store.sessions.expiringCount) tone = 'warn';
|
|
174
|
+
}
|
|
184
175
|
if (tab.id === 'context' && state.aux.instructions?.problems.length) {
|
|
185
176
|
badge = String(state.aux.instructions.problems.length);
|
|
186
177
|
tone = worstSeverity(state.aux.instructions.problems);
|
|
@@ -278,13 +269,16 @@ function renderDetail() {
|
|
|
278
269
|
];
|
|
279
270
|
if (!memory.nameMatchesFile) rows.push(['name', `${memory.name} (differs from filename)`]);
|
|
280
271
|
for (const [key, value] of Object.entries(memory.metadata)) {
|
|
281
|
-
if (key !== 'type' && key !== 'modified') rows.push([key, value]);
|
|
272
|
+
if (key !== 'type' && key !== 'modified' && key !== 'originSessionId') rows.push([key, value]);
|
|
282
273
|
}
|
|
283
274
|
for (const [key, value] of rows) {
|
|
284
275
|
meta.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: String(value) }));
|
|
285
276
|
}
|
|
286
277
|
card.append(meta);
|
|
287
278
|
|
|
279
|
+
const origin = provenanceLine(memory);
|
|
280
|
+
if (origin) card.append(origin);
|
|
281
|
+
|
|
288
282
|
const body = node('div', { class: ui.prose });
|
|
289
283
|
card.append(body);
|
|
290
284
|
renderBody(body, memory, state.store);
|
|
@@ -658,7 +652,19 @@ function goToTab(id) {
|
|
|
658
652
|
}
|
|
659
653
|
|
|
660
654
|
async function removeIndexLine(lineIndex, expectedText, done) {
|
|
661
|
-
|
|
655
|
+
const go = await confirmDialog({
|
|
656
|
+
title: `Remove line ${lineIndex + 1} from MEMORY.md?`,
|
|
657
|
+
confirmLabel: 'Remove',
|
|
658
|
+
tone: 'danger',
|
|
659
|
+
body: [
|
|
660
|
+
node('div', { class: ui.willBlock }, [
|
|
661
|
+
node('div', { class: ui.willTitle, text: 'Will be removed' }),
|
|
662
|
+
node('div', { class: ui.willItem('remove'), text: expectedText }),
|
|
663
|
+
]),
|
|
664
|
+
node('p', { class: ui.noteTight, text: 'Only this line goes. The memory file it points at is left on disk.' }),
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
if (!go) return;
|
|
662
668
|
try {
|
|
663
669
|
await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
|
|
664
670
|
method: 'POST',
|
|
@@ -700,7 +706,21 @@ function renderIssue(item, memories) {
|
|
|
700
706
|
action: {
|
|
701
707
|
label: 'Remove link',
|
|
702
708
|
run: async () => {
|
|
703
|
-
|
|
709
|
+
const go = await confirmDialog({
|
|
710
|
+
title: `Remove [[${link.target}]] from ${link.from}?`,
|
|
711
|
+
confirmLabel: 'Remove link',
|
|
712
|
+
tone: 'danger',
|
|
713
|
+
body: [
|
|
714
|
+
node('p', { class: ui.noteTight, text: 'The link markup goes, the words stay.' }),
|
|
715
|
+
node('div', { class: ui.willBlock }, [
|
|
716
|
+
node('div', { class: ui.willTitle, text: 'Will change' }),
|
|
717
|
+
node('div', { class: ui.willItem('remove'), text: `see [[${link.target}]]` }),
|
|
718
|
+
node('div', { class: ui.willItem('keep'), text: `see ${link.target}` }),
|
|
719
|
+
]),
|
|
720
|
+
node('p', { class: ui.noteTight, text: 'A copy of the file is kept in the trash, so this can be undone.' }),
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
if (!go) return;
|
|
704
724
|
try {
|
|
705
725
|
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/wikilink/remove`, {
|
|
706
726
|
method: 'POST',
|
|
@@ -863,6 +883,32 @@ function renderIssue(item, memories) {
|
|
|
863
883
|
);
|
|
864
884
|
}
|
|
865
885
|
|
|
886
|
+
if (item.kind === 'provenance-expired') {
|
|
887
|
+
return issue(
|
|
888
|
+
`Provenance gone: ${item.name}`,
|
|
889
|
+
`the session it was written in (${item.sessionId}) has been swept, so why this memory exists can no longer be traced. Nothing recovers it - judge the memory on what it says.`,
|
|
890
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (item.kind === 'path-evidence-expiring') {
|
|
895
|
+
return issue(
|
|
896
|
+
item.days === 0
|
|
897
|
+
? 'The last transcript naming this project is due to be swept'
|
|
898
|
+
: `The last transcript naming this project is swept in ${item.days} days`,
|
|
899
|
+
`${item.path} is recovered from the session transcripts, and Claude Code drops those after ${item.retentionDays} days while never touching memory/. Once the last one goes this store shows its raw folder name instead. Remember path records it now.`,
|
|
900
|
+
{ bad, action: { label: 'Remember path\u2026', run: openRememberPathDialog } },
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (item.kind === 'no-memory-despite-sessions') {
|
|
905
|
+
return issue(
|
|
906
|
+
`${item.sessionCount} ${item.sessionCount === 1 ? 'session' : 'sessions'}, and no memory written`,
|
|
907
|
+
`auto memory is on for this project, but nothing has been saved in the ${item.retentionDays} days of transcripts kept here. Either nothing was worth remembering, or memory is not being written.`,
|
|
908
|
+
{ bad },
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
866
912
|
return issue(item.kind, JSON.stringify(item));
|
|
867
913
|
}
|
|
868
914
|
|
|
@@ -887,6 +933,157 @@ const SETTINGS_PROBLEMS = {
|
|
|
887
933
|
|
|
888
934
|
const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
889
935
|
|
|
936
|
+
const TITLE_SOURCE = {
|
|
937
|
+
'ai-title': 'title Claude Code generated for this session',
|
|
938
|
+
slug: 'session slug, no generated title found in the part read',
|
|
939
|
+
prompt: 'first prompt, no generated title found in the part read',
|
|
940
|
+
};
|
|
941
|
+
|
|
942
|
+
const sizeLabel = (bytes) => (bytes >= 1048576
|
|
943
|
+
? `${(bytes / 1048576).toFixed(1)} MB`
|
|
944
|
+
: `${(bytes / 1024).toFixed(1)} KB`);
|
|
945
|
+
|
|
946
|
+
function sessionDate(iso) {
|
|
947
|
+
return iso ? iso.replace('T', ' ').replace(/\..*$/, '') : 'unknown';
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function sessionRow(session, memoriesFrom) {
|
|
951
|
+
const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
|
|
952
|
+
const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
|
|
953
|
+
|
|
954
|
+
const tags = [];
|
|
955
|
+
if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
|
|
956
|
+
if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
|
|
957
|
+
if (session.expiresInDays !== null) {
|
|
958
|
+
tags.push(node('span', {
|
|
959
|
+
class: ui.badge(soon ? 'warn' : 'neutral'),
|
|
960
|
+
text: session.expiresInDays === 0 ? 'due to be swept' : `swept in ${session.expiresInDays}d`,
|
|
961
|
+
}));
|
|
962
|
+
}
|
|
963
|
+
for (const memory of memoriesFrom) {
|
|
964
|
+
tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const body = node('div', { class: ui.contextBody, hidden: true });
|
|
968
|
+
const facts = node('dl', { class: ui.metaList });
|
|
969
|
+
const rows = [
|
|
970
|
+
['session', session.id],
|
|
971
|
+
['last active', `${sessionDate(session.modified)} (file mtime)`],
|
|
972
|
+
['size', sizeLabel(session.bytes)],
|
|
973
|
+
];
|
|
974
|
+
if (session.cwd) rows.push(['cwd', session.cwd]);
|
|
975
|
+
if (session.version) rows.push(['version', session.version]);
|
|
976
|
+
if (session.titleFrom) rows.push(['title from', TITLE_SOURCE[session.titleFrom] || session.titleFrom]);
|
|
977
|
+
for (const [key, value] of rows) {
|
|
978
|
+
facts.append(node('dt', { class: ui.metaKey, text: key }), node('dd', { class: ui.metaValue, text: value }));
|
|
979
|
+
}
|
|
980
|
+
body.append(facts);
|
|
981
|
+
if (session.prompt) {
|
|
982
|
+
body.append(node('div', { class: ui.sectionLabel, text: 'Opened with' }));
|
|
983
|
+
body.append(node('p', { class: ui.note, text: session.prompt }));
|
|
984
|
+
}
|
|
985
|
+
if (memoriesFrom.length) {
|
|
986
|
+
body.append(node('div', { class: ui.sectionLabel, text: 'Memories written here' }));
|
|
987
|
+
const chips = node('div', { class: ui.linkRow });
|
|
988
|
+
for (const memory of memoriesFrom) {
|
|
989
|
+
chips.append(node('button', {
|
|
990
|
+
class: ui.chip(true),
|
|
991
|
+
text: memory.name,
|
|
992
|
+
onclick: () => selectMemory(memory.file),
|
|
993
|
+
}));
|
|
994
|
+
}
|
|
995
|
+
body.append(chips);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const button = node('button', {
|
|
999
|
+
class: ui.contextRowButton,
|
|
1000
|
+
'aria-expanded': 'false',
|
|
1001
|
+
onclick: (event) => {
|
|
1002
|
+
const open = body.hidden;
|
|
1003
|
+
body.hidden = !open;
|
|
1004
|
+
caret.textContent = open ? '\u25be' : '\u25b8';
|
|
1005
|
+
event.currentTarget.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
1006
|
+
},
|
|
1007
|
+
}, [
|
|
1008
|
+
node('div', { class: ui.contextMain }, [
|
|
1009
|
+
node('div', { class: ui.contextTags }, tags),
|
|
1010
|
+
node('div', { class: ui.contextFile }, [caret, document.createTextNode(session.title || session.id)]),
|
|
1011
|
+
]),
|
|
1012
|
+
node('div', { class: ui.contextSize, text: sessionDate(session.modified).slice(0, 10) }),
|
|
1013
|
+
]);
|
|
1014
|
+
|
|
1015
|
+
return node('div', {}, [button, body]);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function retentionTrack(sessions, days) {
|
|
1019
|
+
const track = node('div', { class: ui.retentionTrack }, [node('span', { class: ui.retentionSweep })]);
|
|
1020
|
+
for (const session of sessions) {
|
|
1021
|
+
if (session.expiresInDays === null) continue;
|
|
1022
|
+
const share = days > 0 ? Math.min(1, session.expiresInDays / days) : 1;
|
|
1023
|
+
track.append(node('span', {
|
|
1024
|
+
class: ui.retentionTick(session.expiresInDays <= 7),
|
|
1025
|
+
style: `left: ${(share * 100).toFixed(2)}%`,
|
|
1026
|
+
title: `${session.title || session.id}\nswept in ${session.expiresInDays} days`,
|
|
1027
|
+
}));
|
|
1028
|
+
}
|
|
1029
|
+
return node('div', {}, [
|
|
1030
|
+
track,
|
|
1031
|
+
node('div', { class: ui.retentionScale }, [
|
|
1032
|
+
node('span', { text: 'swept' }),
|
|
1033
|
+
node('span', { text: `today · kept ${days} days` }),
|
|
1034
|
+
]),
|
|
1035
|
+
]);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
async function renderSessions(container) {
|
|
1039
|
+
let data = state.aux.sessions;
|
|
1040
|
+
if (!data) {
|
|
1041
|
+
container.append(node('p', { class: ui.note, text: 'Reading session transcripts\u2026' }));
|
|
1042
|
+
try {
|
|
1043
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/sessions`);
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
container.textContent = '';
|
|
1046
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
1047
|
+
}
|
|
1048
|
+
if (state.tab !== 'sessions') return;
|
|
1049
|
+
state.aux.sessions = data;
|
|
1050
|
+
}
|
|
1051
|
+
container.textContent = '';
|
|
1052
|
+
|
|
1053
|
+
const byOrigin = new Map();
|
|
1054
|
+
for (const memory of state.store.memories) {
|
|
1055
|
+
if (!memory.origin?.present) continue;
|
|
1056
|
+
if (!byOrigin.has(memory.origin.sessionId)) byOrigin.set(memory.origin.sessionId, []);
|
|
1057
|
+
byOrigin.get(memory.origin.sessionId).push(memory);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
|
|
1061
|
+
|
|
1062
|
+
container.append(node('div', { class: ui.meter }, [
|
|
1063
|
+
node('div', { class: ui.meterTop }, [
|
|
1064
|
+
node('span', { class: ui.meterValue, text: String(data.count) }),
|
|
1065
|
+
node('span', { class: ui.meterUnit, text: data.count === 1 ? 'session kept' : 'sessions kept' }),
|
|
1066
|
+
]),
|
|
1067
|
+
retentionTrack(data.sessions, data.days),
|
|
1068
|
+
node('div', { class: ui.meterFacts }, [
|
|
1069
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: sizeLabel(data.bytes) }), document.createTextNode(' on disk')]),
|
|
1070
|
+
node('span', {}, [node('b', { class: ui.meterFactValue, text: `${data.days} days` }), document.createTextNode(' retention (cleanupPeriodDays)')]),
|
|
1071
|
+
node('span', {}, [
|
|
1072
|
+
node('b', { class: ui.meterFactValue, text: data.evidenceExpiresInDays === null ? '-' : `${data.evidenceExpiresInDays} days` }),
|
|
1073
|
+
document.createTextNode(' until the last proof of this path goes'),
|
|
1074
|
+
]),
|
|
1075
|
+
]),
|
|
1076
|
+
node('p', { class: ui.meterNote, text: 'Sweep dates come from each file\u2019s mtime, which any copy or restore resets, so they are an estimate of when Claude Code will drop them rather than something the transcript recorded about itself.' }),
|
|
1077
|
+
]));
|
|
1078
|
+
|
|
1079
|
+
container.append(node('div', { class: ui.sectionLabel, text: `Sessions \u00b7 ${data.count}` }));
|
|
1080
|
+
const card = node('div', { class: ui.card });
|
|
1081
|
+
for (const session of data.sessions) {
|
|
1082
|
+
card.append(sessionRow(session, byOrigin.get(session.id) || []));
|
|
1083
|
+
}
|
|
1084
|
+
container.append(card);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
890
1087
|
async function renderSettings(container) {
|
|
891
1088
|
let data = state.aux.settings;
|
|
892
1089
|
if (!data) {
|
|
@@ -1060,7 +1257,7 @@ function openHookEditor(row, lineIndex) {
|
|
|
1060
1257
|
field.select();
|
|
1061
1258
|
}
|
|
1062
1259
|
|
|
1063
|
-
function openMoveDialog(entry) {
|
|
1260
|
+
async function openMoveDialog(entry) {
|
|
1064
1261
|
const current = indexEntryAt(entry.index);
|
|
1065
1262
|
if (!current) return toast('MEMORY.md has changed - reload and try again', { error: true });
|
|
1066
1263
|
|
|
@@ -1070,40 +1267,31 @@ function openMoveDialog(entry) {
|
|
|
1070
1267
|
picker.append(node('option', { value: '', text: 'Top of MEMORY.md' }));
|
|
1071
1268
|
for (const section of sections) picker.append(node('option', { value: section, text: `Start of "${section}"` }));
|
|
1072
1269
|
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
node('
|
|
1077
|
-
node('div', { class: ui.
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
() => 'Entry moved',
|
|
1099
|
-
);
|
|
1100
|
-
},
|
|
1101
|
-
}),
|
|
1102
|
-
]),
|
|
1103
|
-
]);
|
|
1104
|
-
|
|
1105
|
-
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1106
|
-
el('modal-root').append(backdrop);
|
|
1270
|
+
const go = await openDialog({
|
|
1271
|
+
title: 'Move this entry above the cutoff',
|
|
1272
|
+
body: [
|
|
1273
|
+
node('p', { class: ui.noteTight, text: `"${current.title}" sits at line ${current.index + 1}, past the cutoff at line ${cutoff ? cutoff.rawLine + 1 : '-'}, so Claude never loads it. Moving it up puts it back inside the loaded part of the index - which pushes whatever is now last past the cutoff instead.` }),
|
|
1274
|
+
node('div', { class: ui.willBlock }, [
|
|
1275
|
+
node('div', { class: ui.willTitle, text: 'Will move' }),
|
|
1276
|
+
node('div', { class: ui.willItem('keep'), text: current.text }),
|
|
1277
|
+
]),
|
|
1278
|
+
node('label', { class: ui.willTitle, text: 'Move to' }),
|
|
1279
|
+
picker,
|
|
1280
|
+
],
|
|
1281
|
+
actions: [
|
|
1282
|
+
{ label: 'Cancel' },
|
|
1283
|
+
{ label: 'Move', tone: 'primary', value: true },
|
|
1284
|
+
],
|
|
1285
|
+
});
|
|
1286
|
+
if (!go) return;
|
|
1287
|
+
|
|
1288
|
+
await runIndexEdit(
|
|
1289
|
+
'index/move',
|
|
1290
|
+
picker.value
|
|
1291
|
+
? { lineIndex: current.index, expectedText: current.text, section: picker.value }
|
|
1292
|
+
: { lineIndex: current.index, expectedText: current.text, top: true },
|
|
1293
|
+
() => 'Entry moved',
|
|
1294
|
+
);
|
|
1107
1295
|
}
|
|
1108
1296
|
|
|
1109
1297
|
async function openAddEntryDialog(file) {
|
|
@@ -1117,8 +1305,8 @@ async function openAddEntryDialog(file) {
|
|
|
1117
1305
|
return toast(err.message, { error: true });
|
|
1118
1306
|
}
|
|
1119
1307
|
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1308
|
+
const titleInput = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
1309
|
+
titleInput.value = preview.name;
|
|
1122
1310
|
const hook = node('textarea', { class: ui.textArea, spellcheck: 'false' });
|
|
1123
1311
|
hook.value = preview.description;
|
|
1124
1312
|
|
|
@@ -1129,54 +1317,44 @@ async function openAddEntryDialog(file) {
|
|
|
1129
1317
|
|
|
1130
1318
|
const line = node('div', { class: ui.willItem('keep') });
|
|
1131
1319
|
const update = () => {
|
|
1132
|
-
const label =
|
|
1320
|
+
const label = titleInput.value.trim().replace(/[[\]]/g, '') || preview.name;
|
|
1133
1321
|
const text = hook.value.trim();
|
|
1134
1322
|
line.textContent = text ? `- [${label}](${file}) — ${text}` : `- [${label}](${file})`;
|
|
1135
1323
|
};
|
|
1136
|
-
|
|
1324
|
+
titleInput.addEventListener('input', update);
|
|
1137
1325
|
hook.addEventListener('input', update);
|
|
1138
1326
|
update();
|
|
1139
1327
|
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
onclick: async () => {
|
|
1165
|
-
closeModal();
|
|
1166
|
-
await runIndexEdit(
|
|
1167
|
-
'index/add',
|
|
1168
|
-
{ file, section: picker.value || null, title: title.value, hook: hook.value },
|
|
1169
|
-
() => 'Added to MEMORY.md',
|
|
1170
|
-
);
|
|
1171
|
-
},
|
|
1172
|
-
}),
|
|
1173
|
-
]),
|
|
1174
|
-
]);
|
|
1328
|
+
const go = await openDialog({
|
|
1329
|
+
title: `Add ${file} to MEMORY.md`,
|
|
1330
|
+
body: [
|
|
1331
|
+
node('p', { class: ui.noteTight, text: preview.hasIndex
|
|
1332
|
+
? 'MEMORY.md is loaded at the start of every session, so a memory with no bullet here is one Claude never sees. This adds one line and nothing else.'
|
|
1333
|
+
: 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
|
|
1334
|
+
node('label', { class: ui.willTitle, text: 'Title' }),
|
|
1335
|
+
titleInput,
|
|
1336
|
+
node('label', { class: ui.willTitle, text: 'Hook' }),
|
|
1337
|
+
hook,
|
|
1338
|
+
node('label', { class: ui.willTitle, text: 'Section' }),
|
|
1339
|
+
picker,
|
|
1340
|
+
node('div', { class: ui.willBlock }, [
|
|
1341
|
+
node('div', { class: ui.willTitle, text: 'Will be added' }),
|
|
1342
|
+
line,
|
|
1343
|
+
]),
|
|
1344
|
+
],
|
|
1345
|
+
actions: [
|
|
1346
|
+
{ label: 'Cancel' },
|
|
1347
|
+
{ label: 'Add entry', tone: 'primary', value: true },
|
|
1348
|
+
],
|
|
1349
|
+
focus: titleInput,
|
|
1350
|
+
});
|
|
1351
|
+
if (!go) return;
|
|
1175
1352
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1353
|
+
await runIndexEdit(
|
|
1354
|
+
'index/add',
|
|
1355
|
+
{ file, section: picker.value || null, title: titleInput.value, hook: hook.value },
|
|
1356
|
+
() => 'Added to MEMORY.md',
|
|
1357
|
+
);
|
|
1180
1358
|
}
|
|
1181
1359
|
|
|
1182
1360
|
async function openMergeDialog(into, from) {
|
|
@@ -1193,8 +1371,8 @@ async function openMergeDialog(into, from) {
|
|
|
1193
1371
|
const heading = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
|
|
1194
1372
|
heading.value = preview.heading;
|
|
1195
1373
|
|
|
1196
|
-
const body =
|
|
1197
|
-
body.
|
|
1374
|
+
const body = [];
|
|
1375
|
+
body.push(node('p', { class: ui.noteTight, text: `Everything in "${preview.fromName}" moves into "${preview.intoName}" under a new heading, and "${preview.fromName}" goes to the trash. This rewrites prose, which nothing else in this app does - the whole operation is one undo.` }));
|
|
1198
1376
|
|
|
1199
1377
|
const changes = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will change' })]);
|
|
1200
1378
|
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} + ${preview.bodyLines} line(s) from ${preview.from}` }));
|
|
@@ -1204,7 +1382,7 @@ async function openMergeDialog(into, from) {
|
|
|
1204
1382
|
for (const link of preview.selfLinks) {
|
|
1205
1383
|
changes.append(node('div', { class: ui.willItem('keep'), text: `${preview.into} [[${link}]] → plain text, to avoid a self-link` }));
|
|
1206
1384
|
}
|
|
1207
|
-
body.
|
|
1385
|
+
body.push(changes);
|
|
1208
1386
|
|
|
1209
1387
|
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
1210
1388
|
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.from} → memory/.trash/` }));
|
|
@@ -1214,7 +1392,7 @@ async function openMergeDialog(into, from) {
|
|
|
1214
1392
|
if (!preview.indexLines.length) {
|
|
1215
1393
|
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for the source - nothing to remove there.' }));
|
|
1216
1394
|
}
|
|
1217
|
-
body.
|
|
1395
|
+
body.push(removals);
|
|
1218
1396
|
|
|
1219
1397
|
if (preview.inlineRefs.length) {
|
|
1220
1398
|
const kept = node('div', { class: ui.willBlock }, [
|
|
@@ -1223,45 +1401,35 @@ async function openMergeDialog(into, from) {
|
|
|
1223
1401
|
for (const ref of preview.inlineRefs) {
|
|
1224
1402
|
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
1225
1403
|
}
|
|
1226
|
-
body.
|
|
1404
|
+
body.push(kept);
|
|
1227
1405
|
}
|
|
1228
1406
|
|
|
1229
|
-
body.
|
|
1407
|
+
body.push(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
|
|
1230
1408
|
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
node('h3', { class: ui.modalTitle, text: `Merge "${preview.fromName}" into "${preview.intoName}"?` }),
|
|
1234
|
-
]),
|
|
1409
|
+
const go = await openDialog({
|
|
1410
|
+
title: `Merge "${preview.fromName}" into "${preview.intoName}"?`,
|
|
1235
1411
|
body,
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
try {
|
|
1244
|
-
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
|
|
1245
|
-
method: 'POST',
|
|
1246
|
-
body: JSON.stringify({ into, from, heading: heading.value }),
|
|
1247
|
-
});
|
|
1248
|
-
if (state.selected === from) state.selected = into;
|
|
1249
|
-
await openStore(state.storeId, { keepTab: true });
|
|
1250
|
-
toast(`Merged into ${preview.intoName}`, {
|
|
1251
|
-
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1252
|
-
});
|
|
1253
|
-
} catch (err) {
|
|
1254
|
-
toast(err.message, { error: true });
|
|
1255
|
-
}
|
|
1256
|
-
},
|
|
1257
|
-
}),
|
|
1258
|
-
]),
|
|
1259
|
-
]);
|
|
1412
|
+
actions: [
|
|
1413
|
+
{ label: 'Cancel' },
|
|
1414
|
+
{ label: 'Merge', tone: 'primary', value: true },
|
|
1415
|
+
],
|
|
1416
|
+
focus: heading,
|
|
1417
|
+
});
|
|
1418
|
+
if (!go) return;
|
|
1260
1419
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1420
|
+
try {
|
|
1421
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/merge`, {
|
|
1422
|
+
method: 'POST',
|
|
1423
|
+
body: JSON.stringify({ into, from, heading: heading.value }),
|
|
1424
|
+
});
|
|
1425
|
+
if (state.selected === from) state.selected = into;
|
|
1426
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1427
|
+
toast(`Merged into ${preview.intoName}`, {
|
|
1428
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1429
|
+
});
|
|
1430
|
+
} catch (err) {
|
|
1431
|
+
toast(err.message, { error: true });
|
|
1432
|
+
}
|
|
1265
1433
|
}
|
|
1266
1434
|
|
|
1267
1435
|
async function restoreFromTrash(id) {
|
|
@@ -1312,10 +1480,6 @@ function renderTrash(container) {
|
|
|
1312
1480
|
}
|
|
1313
1481
|
}
|
|
1314
1482
|
|
|
1315
|
-
function closeModal() {
|
|
1316
|
-
el('modal-root').textContent = '';
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
1483
|
async function openDeleteDialog(file) {
|
|
1320
1484
|
let preview;
|
|
1321
1485
|
try {
|
|
@@ -1327,7 +1491,7 @@ async function openDeleteDialog(file) {
|
|
|
1327
1491
|
return toast(err.message, { error: true });
|
|
1328
1492
|
}
|
|
1329
1493
|
|
|
1330
|
-
const body =
|
|
1494
|
+
const body = [];
|
|
1331
1495
|
|
|
1332
1496
|
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
1333
1497
|
removals.append(node('div', { class: ui.willItem('remove'), text: `memory/${preview.file} → memory/.trash/` }));
|
|
@@ -1337,14 +1501,14 @@ async function openDeleteDialog(file) {
|
|
|
1337
1501
|
if (!preview.indexLines.length && preview.hasIndex) {
|
|
1338
1502
|
removals.append(node('div', { class: ui.willItem(), text: 'MEMORY.md has no index bullet for this file - nothing to unlink there.' }));
|
|
1339
1503
|
}
|
|
1340
|
-
body.
|
|
1504
|
+
body.push(removals);
|
|
1341
1505
|
|
|
1342
1506
|
if (preview.inlineRefs.length) {
|
|
1343
1507
|
const kept = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Left untouched - mentioned inside prose, so you may want to fix these by hand' })]);
|
|
1344
1508
|
for (const ref of preview.inlineRefs) {
|
|
1345
1509
|
kept.append(node('div', { class: ui.willItem('keep'), text: `MEMORY.md line ${ref.index + 1}: ${ref.text}` }));
|
|
1346
1510
|
}
|
|
1347
|
-
body.
|
|
1511
|
+
body.push(kept);
|
|
1348
1512
|
}
|
|
1349
1513
|
|
|
1350
1514
|
const cascade = new Set();
|
|
@@ -1380,7 +1544,7 @@ async function openDeleteDialog(file) {
|
|
|
1380
1544
|
}
|
|
1381
1545
|
block.append(list);
|
|
1382
1546
|
block.append(node('p', { class: ui.noteTight, text: 'Tick any you also want deleted - they go to the trash together and restore as one step.' }));
|
|
1383
|
-
body.
|
|
1547
|
+
body.push(block);
|
|
1384
1548
|
|
|
1385
1549
|
selectAll.onclick = (event) => {
|
|
1386
1550
|
event.preventDefault();
|
|
@@ -1392,52 +1556,39 @@ async function openDeleteDialog(file) {
|
|
|
1392
1556
|
};
|
|
1393
1557
|
}
|
|
1394
1558
|
|
|
1395
|
-
body.
|
|
1396
|
-
|
|
1397
|
-
const confirmButton = node('button', {
|
|
1398
|
-
class: ui.button({ tone: 'danger' }),
|
|
1399
|
-
text: 'Delete',
|
|
1400
|
-
onclick: async () => {
|
|
1401
|
-
closeModal();
|
|
1402
|
-
try {
|
|
1403
|
-
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
1404
|
-
method: 'POST',
|
|
1405
|
-
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
1406
|
-
});
|
|
1407
|
-
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
1408
|
-
await openStore(state.storeId, { keepTab: true });
|
|
1409
|
-
const count = result.record.files.length;
|
|
1410
|
-
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
1411
|
-
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1412
|
-
});
|
|
1413
|
-
} catch (err) {
|
|
1414
|
-
toast(err.message, { error: true });
|
|
1415
|
-
}
|
|
1416
|
-
},
|
|
1417
|
-
});
|
|
1559
|
+
body.push(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
|
|
1418
1560
|
|
|
1419
|
-
const
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
]),
|
|
1561
|
+
const remove = { label: 'Delete', tone: 'danger', value: true };
|
|
1562
|
+
const answer = openDialog({
|
|
1563
|
+
title: `Delete "${preview.name || preview.file}"?`,
|
|
1564
|
+
subtitle: preview.description || '',
|
|
1424
1565
|
body,
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
confirmButton,
|
|
1428
|
-
]),
|
|
1429
|
-
]);
|
|
1430
|
-
|
|
1431
|
-
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1432
|
-
el('modal-root').append(backdrop);
|
|
1566
|
+
actions: [{ label: 'Cancel' }, remove],
|
|
1567
|
+
});
|
|
1433
1568
|
updateButton();
|
|
1569
|
+
if (!await answer) return;
|
|
1570
|
+
|
|
1571
|
+
try {
|
|
1572
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete`, {
|
|
1573
|
+
method: 'POST',
|
|
1574
|
+
body: JSON.stringify({ file, alsoDelete: [...cascade] }),
|
|
1575
|
+
});
|
|
1576
|
+
if (state.selected === file || cascade.has(state.selected)) state.selected = null;
|
|
1577
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1578
|
+
const count = result.record.files.length;
|
|
1579
|
+
toast(count > 1 ? `Deleted ${count} memories` : `Deleted "${result.record.label || file}"`, {
|
|
1580
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1581
|
+
});
|
|
1582
|
+
} catch (err) {
|
|
1583
|
+
toast(err.message, { error: true });
|
|
1584
|
+
}
|
|
1434
1585
|
|
|
1435
1586
|
function updateButton() {
|
|
1436
|
-
|
|
1587
|
+
remove.el.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
|
|
1437
1588
|
}
|
|
1438
1589
|
}
|
|
1439
1590
|
|
|
1440
|
-
function openRememberPathDialog() {
|
|
1591
|
+
async function openRememberPathDialog() {
|
|
1441
1592
|
const store = state.store;
|
|
1442
1593
|
const input = node('input', {
|
|
1443
1594
|
type: 'text',
|
|
@@ -1447,44 +1598,42 @@ function openRememberPathDialog() {
|
|
|
1447
1598
|
spellcheck: 'false',
|
|
1448
1599
|
});
|
|
1449
1600
|
|
|
1450
|
-
const
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
onclick: async () => {
|
|
1601
|
+
const remembered = await openDialog({
|
|
1602
|
+
title: 'Remember this project\u2019s path',
|
|
1603
|
+
body: [
|
|
1604
|
+
node('p', { class: ui.note, text: `The folder on disk is "${store.slug}", and its name is a lossy encoding of a path. No session transcript remains to recover the real one from, so tell it once and it will be used from now on.` }),
|
|
1605
|
+
input,
|
|
1606
|
+
node('p', { class: ui.noteTight, text: 'Saved to ~/.claude-memory-admin/paths.json. That file records slugs and folder paths only, never memory content, and is the one thing this app writes outside a memory/ directory.' }),
|
|
1607
|
+
],
|
|
1608
|
+
actions: [
|
|
1609
|
+
{ label: 'Cancel' },
|
|
1610
|
+
{
|
|
1611
|
+
label: 'Remember',
|
|
1612
|
+
tone: 'primary',
|
|
1613
|
+
value: true,
|
|
1614
|
+
guard: async () => {
|
|
1465
1615
|
const value = input.value.trim();
|
|
1466
|
-
if (!value) return;
|
|
1616
|
+
if (!value) return false;
|
|
1467
1617
|
try {
|
|
1468
1618
|
await api(`/api/stores/${encodeURIComponent(state.storeId)}/path/remember`, {
|
|
1469
1619
|
method: 'POST',
|
|
1470
1620
|
body: JSON.stringify({ path: value }),
|
|
1471
1621
|
});
|
|
1472
1622
|
} catch (err) {
|
|
1473
|
-
|
|
1623
|
+
toast(err.message, { error: true });
|
|
1624
|
+
return false;
|
|
1474
1625
|
}
|
|
1475
|
-
|
|
1476
|
-
await reloadStores();
|
|
1477
|
-
await openStore(state.storeId, { keepTab: true });
|
|
1478
|
-
toast(`Remembered ${value}`);
|
|
1626
|
+
return true;
|
|
1479
1627
|
},
|
|
1480
|
-
}
|
|
1481
|
-
]
|
|
1482
|
-
|
|
1628
|
+
},
|
|
1629
|
+
],
|
|
1630
|
+
focus: input,
|
|
1631
|
+
});
|
|
1632
|
+
if (!remembered) return;
|
|
1483
1633
|
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
input.
|
|
1487
|
-
input.select();
|
|
1634
|
+
await reloadStores();
|
|
1635
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1636
|
+
toast(`Remembered ${input.value.trim()}`);
|
|
1488
1637
|
}
|
|
1489
1638
|
|
|
1490
1639
|
async function forgetProjectPath() {
|
|
@@ -1510,7 +1659,7 @@ async function openStoreDeleteDialog() {
|
|
|
1510
1659
|
return toast('This store has no memory to delete');
|
|
1511
1660
|
}
|
|
1512
1661
|
|
|
1513
|
-
const body =
|
|
1662
|
+
const body = [];
|
|
1514
1663
|
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: 'Will be removed' })]);
|
|
1515
1664
|
if (preview.hasIndex) {
|
|
1516
1665
|
removals.append(node('div', { class: ui.willItem('remove'), text: `MEMORY.md (${preview.indexLines} lines) → memory/.trash/` }));
|
|
@@ -1518,51 +1667,42 @@ async function openStoreDeleteDialog() {
|
|
|
1518
1667
|
for (const entry of preview.files) {
|
|
1519
1668
|
removals.append(node('div', { class: ui.willItem('remove'), text: `${entry.file} → memory/.trash/` }));
|
|
1520
1669
|
}
|
|
1521
|
-
body.
|
|
1670
|
+
body.push(removals);
|
|
1522
1671
|
|
|
1523
|
-
body.
|
|
1672
|
+
body.push(node('div', { class: ui.willBlock }, [
|
|
1524
1673
|
node('div', { class: ui.willTitle, text: 'Will be kept' }),
|
|
1525
1674
|
node('div', { class: ui.willItem('keep'), text: state.store.kind === 'auto'
|
|
1526
1675
|
? 'Session transcripts (*.jsonl) and the project folder itself'
|
|
1527
1676
|
: 'The agent-memory folder itself, and every other agent\u2019s memory' }),
|
|
1528
1677
|
]));
|
|
1529
1678
|
|
|
1530
|
-
body.
|
|
1679
|
+
body.push(node('p', { class: ui.noteTight, text: 'Everything moves to .trash/ inside the store as one restore point, so it can be put back in a single step from the Trash tab.' }));
|
|
1531
1680
|
|
|
1532
1681
|
if (state.store.kind === 'agent-project') {
|
|
1533
|
-
body.
|
|
1682
|
+
body.push(node('p', { class: ui.subWarn, text: 'This store is checked into the repository. Deleting from it changes tracked files, and will show up in git status.' }));
|
|
1534
1683
|
}
|
|
1535
1684
|
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
node('p', { class: ui.note, text: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}` }),
|
|
1540
|
-
]),
|
|
1685
|
+
const go = await openDialog({
|
|
1686
|
+
title: `Delete all memory for ${state.store.label}?`,
|
|
1687
|
+
subtitle: `${preview.files.length} memories${preview.hasIndex ? ' + MEMORY.md' : ''}`,
|
|
1541
1688
|
body,
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
closeModal();
|
|
1549
|
-
try {
|
|
1550
|
-
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
|
|
1551
|
-
state.selected = null;
|
|
1552
|
-
await openStore(state.storeId, { keepTab: true });
|
|
1553
|
-
toast(`Cleared ${state.store.label}`, {
|
|
1554
|
-
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1555
|
-
});
|
|
1556
|
-
} catch (err) {
|
|
1557
|
-
toast(err.message, { error: true });
|
|
1558
|
-
}
|
|
1559
|
-
},
|
|
1560
|
-
}),
|
|
1561
|
-
]),
|
|
1562
|
-
]);
|
|
1689
|
+
actions: [
|
|
1690
|
+
{ label: 'Cancel' },
|
|
1691
|
+
{ label: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`, tone: 'danger', value: true },
|
|
1692
|
+
],
|
|
1693
|
+
});
|
|
1694
|
+
if (!go) return;
|
|
1563
1695
|
|
|
1564
|
-
|
|
1565
|
-
|
|
1696
|
+
try {
|
|
1697
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/project/delete`, { method: 'POST' });
|
|
1698
|
+
state.selected = null;
|
|
1699
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1700
|
+
toast(`Cleared ${state.store.label}`, {
|
|
1701
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1702
|
+
});
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
toast(err.message, { error: true });
|
|
1705
|
+
}
|
|
1566
1706
|
}
|
|
1567
1707
|
|
|
1568
1708
|
function highlight(text, terms) {
|
|
@@ -1772,12 +1912,12 @@ async function openBulkDeleteDialog(files) {
|
|
|
1772
1912
|
if (!files.length) return;
|
|
1773
1913
|
const memories = files.map((file) => state.store.memories.find((m) => m.file === file)).filter(Boolean);
|
|
1774
1914
|
|
|
1775
|
-
const body =
|
|
1915
|
+
const body = [];
|
|
1776
1916
|
const removals = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${memories.length} memories will be trashed` })]);
|
|
1777
1917
|
for (const memory of memories) {
|
|
1778
1918
|
removals.append(node('div', { class: ui.willItem('remove'), text: `${memory.file}${memory.entry ? ` · MEMORY.md line ${memory.entry.index + 1}` : ' · not in the index'}` }));
|
|
1779
1919
|
}
|
|
1780
|
-
body.
|
|
1920
|
+
body.push(removals);
|
|
1781
1921
|
|
|
1782
1922
|
const doomed = new Set(files);
|
|
1783
1923
|
const breaking = [];
|
|
@@ -1789,41 +1929,35 @@ async function openBulkDeleteDialog(files) {
|
|
|
1789
1929
|
if (breaking.length) {
|
|
1790
1930
|
const block = node('div', { class: ui.willBlock }, [node('div', { class: ui.willTitle, text: `${breaking.length} link(s) from memories you are keeping will break` })]);
|
|
1791
1931
|
for (const link of breaking) block.append(node('div', { class: ui.willItem('keep'), text: `${link.from} → [[${link.target}]]` }));
|
|
1792
|
-
body.
|
|
1932
|
+
body.push(block);
|
|
1793
1933
|
}
|
|
1794
1934
|
|
|
1795
|
-
body.
|
|
1935
|
+
body.push(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
|
|
1796
1936
|
|
|
1797
|
-
const
|
|
1798
|
-
|
|
1937
|
+
const go = await openDialog({
|
|
1938
|
+
title: `Delete ${memories.length} memories?`,
|
|
1799
1939
|
body,
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
},
|
|
1822
|
-
}),
|
|
1823
|
-
]),
|
|
1824
|
-
]);
|
|
1825
|
-
const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
|
|
1826
|
-
el('modal-root').append(backdrop);
|
|
1940
|
+
actions: [
|
|
1941
|
+
{ label: 'Cancel' },
|
|
1942
|
+
{ label: `Delete ${memories.length}`, tone: 'danger', value: true },
|
|
1943
|
+
],
|
|
1944
|
+
});
|
|
1945
|
+
if (!go) return;
|
|
1946
|
+
|
|
1947
|
+
try {
|
|
1948
|
+
const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
|
|
1949
|
+
method: 'POST',
|
|
1950
|
+
body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
|
|
1951
|
+
});
|
|
1952
|
+
state.pruneSelection.clear();
|
|
1953
|
+
if (doomed.has(state.selected)) state.selected = null;
|
|
1954
|
+
await openStore(state.storeId, { keepTab: true });
|
|
1955
|
+
toast(`Deleted ${memories.length} memories`, {
|
|
1956
|
+
action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
|
|
1957
|
+
});
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
toast(err.message, { error: true });
|
|
1960
|
+
}
|
|
1827
1961
|
}
|
|
1828
1962
|
|
|
1829
1963
|
let searchTimer = null;
|
|
@@ -1935,6 +2069,7 @@ function renderTab() {
|
|
|
1935
2069
|
if (state.tab === 'memories') renderMemories(container);
|
|
1936
2070
|
else if (state.tab === 'prune') renderPrune(container);
|
|
1937
2071
|
else if (state.tab === 'context') renderContext(container);
|
|
2072
|
+
else if (state.tab === 'sessions') renderSessions(container);
|
|
1938
2073
|
else if (state.tab === 'index') renderIndex(container);
|
|
1939
2074
|
else if (state.tab === 'health') renderHealth(container);
|
|
1940
2075
|
else if (state.tab === 'settings') renderSettings(container);
|
|
@@ -1987,6 +2122,49 @@ async function sweepIssues() {
|
|
|
1987
2122
|
renderStores();
|
|
1988
2123
|
}
|
|
1989
2124
|
|
|
2125
|
+
function provenanceLine(memory) {
|
|
2126
|
+
const origin = memory.origin;
|
|
2127
|
+
if (!origin) return null;
|
|
2128
|
+
|
|
2129
|
+
const known = state.aux.sessions?.sessions.find((session) => session.id === origin.sessionId);
|
|
2130
|
+
const label = origin.present ? (known?.title || origin.sessionId) : origin.sessionId;
|
|
2131
|
+
|
|
2132
|
+
const row = node('div', { class: ui.provenanceRow }, [node('span', { text: 'written in' })]);
|
|
2133
|
+
|
|
2134
|
+
if (origin.present) {
|
|
2135
|
+
row.append(node('button', {
|
|
2136
|
+
class: ui.provenanceLink(true),
|
|
2137
|
+
text: label,
|
|
2138
|
+
title: 'Open the Sessions tab',
|
|
2139
|
+
onclick: () => goToTab('sessions'),
|
|
2140
|
+
}));
|
|
2141
|
+
if (known?.gitBranch) row.append(node('span', { text: `on ${known.gitBranch}` }));
|
|
2142
|
+
if (origin.modified) row.append(node('span', { text: sessionDate(origin.modified).slice(0, 10) }));
|
|
2143
|
+
if (!known) loadSessionsForProvenance();
|
|
2144
|
+
} else {
|
|
2145
|
+
row.append(node('span', {
|
|
2146
|
+
class: ui.provenanceLink(false),
|
|
2147
|
+
text: label,
|
|
2148
|
+
title: 'This transcript is no longer on disk',
|
|
2149
|
+
}));
|
|
2150
|
+
row.append(node('span', { text: '- transcript swept, so why this memory exists can no longer be traced' }));
|
|
2151
|
+
}
|
|
2152
|
+
return row;
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
async function loadSessionsForProvenance() {
|
|
2156
|
+
if (state.aux.sessions || !state.store?.sessions?.count) return;
|
|
2157
|
+
const id = state.storeId;
|
|
2158
|
+
try {
|
|
2159
|
+
const data = await api(`/api/stores/${encodeURIComponent(id)}/sessions`);
|
|
2160
|
+
if (state.storeId !== id) return;
|
|
2161
|
+
state.aux.sessions = data;
|
|
2162
|
+
renderTab();
|
|
2163
|
+
} catch {
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
1990
2168
|
function renderStoreHeader() {
|
|
1991
2169
|
const store = state.store;
|
|
1992
2170
|
el('project-title').textContent = store.label;
|
|
@@ -2029,6 +2207,18 @@ function renderStoreHeader() {
|
|
|
2029
2207
|
]));
|
|
2030
2208
|
}
|
|
2031
2209
|
|
|
2210
|
+
const evidence = store.sessions?.evidenceExpiresInDays;
|
|
2211
|
+
if (!store.sessions?.remembered
|
|
2212
|
+
&& (store.resolvedBy === 'transcript' || store.resolvedBy === 'repo-root')
|
|
2213
|
+
&& typeof evidence === 'number' && evidence <= 14) {
|
|
2214
|
+
sub.append(node('span', { class: ui.subWarn }, [
|
|
2215
|
+
document.createTextNode(evidence === 0
|
|
2216
|
+
? 'the last transcript proving this path is due to be swept - the store keeps its memories and loses its name '
|
|
2217
|
+
: `the last transcript proving this path is swept in ${evidence} days, and nothing else records it `),
|
|
2218
|
+
node('button', { class: ui.linkButton, text: 'Remember path\u2026', onclick: openRememberPathDialog }),
|
|
2219
|
+
]));
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2032
2222
|
const auto = store.autoMemory;
|
|
2033
2223
|
if (auto && auto.known && !auto.enabled) {
|
|
2034
2224
|
sub.append(node('span', { class: ui.subWarn, text: auto.scope === 'env'
|
|
@@ -2040,7 +2230,7 @@ function renderStoreHeader() {
|
|
|
2040
2230
|
async function openStore(id, { keepTab = false } = {}) {
|
|
2041
2231
|
if (id !== state.storeId) state.pruneSelection.clear();
|
|
2042
2232
|
state.storeId = id;
|
|
2043
|
-
state.aux = { instructions: null, settings: null };
|
|
2233
|
+
state.aux = { instructions: null, settings: null, sessions: null };
|
|
2044
2234
|
state.pathCheck = null;
|
|
2045
2235
|
if (!keepTab) {
|
|
2046
2236
|
const opening = state.stores.find((store) => store.id === id);
|
|
@@ -2151,6 +2341,7 @@ async function init() {
|
|
|
2151
2341
|
paintTheme();
|
|
2152
2342
|
});
|
|
2153
2343
|
document.addEventListener('keydown', (event) => {
|
|
2344
|
+
if (isDialogOpen()) return;
|
|
2154
2345
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(event.target.tagName);
|
|
2155
2346
|
if (event.key === '/' && !typing) {
|
|
2156
2347
|
event.preventDefault();
|
|
@@ -2165,8 +2356,7 @@ async function init() {
|
|
|
2165
2356
|
return;
|
|
2166
2357
|
}
|
|
2167
2358
|
if (event.key === 'Escape') {
|
|
2168
|
-
if (
|
|
2169
|
-
else if (state.search !== null) clearSearch();
|
|
2359
|
+
if (state.search !== null) clearSearch();
|
|
2170
2360
|
else if (!state.collapsed) setCollapsed(true);
|
|
2171
2361
|
}
|
|
2172
2362
|
});
|