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/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 node = document.createElement('div');
42
- node.className = ui.toast(error);
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
- const button = document.createElement('button');
46
- button.className = ui.toastAction;
47
- button.textContent = action.label;
48
- button.onclick = () => { node.remove(); action.run(); };
49
- node.append(button);
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(node);
52
- setTimeout(() => node.remove(), action ? 12000 : 4500);
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
- if (!confirm(`Remove line ${lineIndex + 1} from MEMORY.md?\n\n${expectedText}`)) return;
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
- if (!confirm(`Remove [[${link.target}]] from ${link.from}?\n\nThe link markup goes, the words stay:\n "see [[${link.target}]]" -> "see ${link.target}"\n\nA copy of the file is kept in the trash, so this can be undone.`)) return;
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 body = node('div', { class: ui.modalBody }, [
1074
- 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.` }),
1075
- node('div', { class: ui.willBlock }, [
1076
- node('div', { class: ui.willTitle, text: 'Will move' }),
1077
- node('div', { class: ui.willItem('keep'), text: current.text }),
1078
- ]),
1079
- node('label', { class: ui.willTitle, text: 'Move to' }),
1080
- picker,
1081
- ]);
1082
-
1083
- const modal = node('div', { class: ui.modal }, [
1084
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Move this entry above the cutoff' })]),
1085
- body,
1086
- node('footer', { class: ui.modalFoot }, [
1087
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1088
- node('button', {
1089
- class: ui.button({ tone: 'primary' }),
1090
- text: 'Move',
1091
- onclick: async () => {
1092
- closeModal();
1093
- await runIndexEdit(
1094
- 'index/move',
1095
- picker.value
1096
- ? { lineIndex: current.index, expectedText: current.text, section: picker.value }
1097
- : { lineIndex: current.index, expectedText: current.text, top: true },
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 title = node('input', { type: 'text', class: ui.pathInput, spellcheck: 'false' });
1121
- title.value = preview.name;
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 = title.value.trim().replace(/[[\]]/g, '') || preview.name;
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
- title.addEventListener('input', update);
1324
+ titleInput.addEventListener('input', update);
1137
1325
  hook.addEventListener('input', update);
1138
1326
  update();
1139
1327
 
1140
- const body = node('div', { class: ui.modalBody }, [
1141
- node('p', { class: ui.noteTight, text: preview.hasIndex
1142
- ? '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.'
1143
- : 'This project has no MEMORY.md yet. Adding this entry creates one.' }),
1144
- node('label', { class: ui.willTitle, text: 'Title' }),
1145
- title,
1146
- node('label', { class: ui.willTitle, text: 'Hook' }),
1147
- hook,
1148
- node('label', { class: ui.willTitle, text: 'Section' }),
1149
- picker,
1150
- node('div', { class: ui.willBlock }, [
1151
- node('div', { class: ui.willTitle, text: 'Will be added' }),
1152
- line,
1153
- ]),
1154
- ]);
1155
-
1156
- const modal = node('div', { class: ui.modal }, [
1157
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Add ${file} to MEMORY.md` })]),
1158
- body,
1159
- node('footer', { class: ui.modalFoot }, [
1160
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1161
- node('button', {
1162
- class: ui.button({ tone: 'primary' }),
1163
- text: 'Add entry',
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
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1177
- el('modal-root').append(backdrop);
1178
- title.focus();
1179
- title.select();
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 = node('div', { class: ui.modalBody });
1197
- body.append(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.` }));
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.append(changes);
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.append(removals);
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.append(kept);
1404
+ body.push(kept);
1227
1405
  }
1228
1406
 
1229
- body.append(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
1407
+ body.push(node('label', { class: ui.willTitle, text: 'Heading for the merged section' }), heading);
1230
1408
 
1231
- const modal = node('div', { class: ui.modal }, [
1232
- node('header', { class: ui.modalHead }, [
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
- node('footer', { class: ui.modalFoot }, [
1237
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1238
- node('button', {
1239
- class: ui.button({ tone: 'primary' }),
1240
- text: 'Merge',
1241
- onclick: async () => {
1242
- closeModal();
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
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1262
- el('modal-root').append(backdrop);
1263
- heading.focus();
1264
- heading.select();
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 = node('div', { class: ui.modalBody });
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.append(removals);
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.append(kept);
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.append(block);
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.append(node('p', { class: ui.noteTight, text: 'The file moves to memory/.trash/ with a restore record, so this can be undone.' }));
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 modal = node('div', { class: ui.modal }, [
1420
- node('header', { class: ui.modalHead }, [
1421
- node('h3', { class: ui.modalTitle, text: `Delete "${preview.name || preview.file}"?` }),
1422
- node('p', { class: ui.note, text: preview.description || '' }),
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
- node('footer', { class: ui.modalFoot }, [
1426
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
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
- confirmButton.textContent = cascade.size ? `Delete ${cascade.size + 1} memories` : 'Delete';
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 body = node('div', { class: ui.modalBody }, [
1451
- 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.` }),
1452
- input,
1453
- 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.' }),
1454
- ]);
1455
-
1456
- const modal = node('div', { class: ui.modal }, [
1457
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: 'Remember this project\u2019s path' })]),
1458
- body,
1459
- node('footer', { class: ui.modalFoot }, [
1460
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1461
- node('button', {
1462
- class: ui.button({ tone: 'primary' }),
1463
- text: 'Remember',
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
- return toast(err.message, { error: true });
1623
+ toast(err.message, { error: true });
1624
+ return false;
1474
1625
  }
1475
- closeModal();
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
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1485
- el('modal-root').append(backdrop);
1486
- input.focus();
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 = node('div', { class: ui.modalBody });
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.append(removals);
1670
+ body.push(removals);
1522
1671
 
1523
- body.append(node('div', { class: ui.willBlock }, [
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.append(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.' }));
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.append(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.' }));
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 modal = node('div', { class: ui.modal }, [
1537
- node('header', { class: ui.modalHead }, [
1538
- node('h3', { class: ui.modalTitle, text: `Delete all memory for ${state.store.label}?` }),
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
- node('footer', { class: ui.modalFoot }, [
1543
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1544
- node('button', {
1545
- class: ui.button({ tone: 'danger' }),
1546
- text: `Delete ${preview.files.length + (preview.hasIndex ? 1 : 0)} files`,
1547
- onclick: async () => {
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
- const backdrop = node('div', { class: ui.modalBackdrop, onclick: (event) => { if (event.target === backdrop) closeModal(); } }, [modal]);
1565
- el('modal-root').append(backdrop);
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 = node('div', { class: ui.modalBody });
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.append(removals);
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.append(block);
1932
+ body.push(block);
1793
1933
  }
1794
1934
 
1795
- body.append(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
1935
+ body.push(node('p', { class: ui.noteTight, text: 'All of them go to memory/.trash/ as one restore point.' }));
1796
1936
 
1797
- const modal = node('div', { class: ui.modal }, [
1798
- node('header', { class: ui.modalHead }, [node('h3', { class: ui.modalTitle, text: `Delete ${memories.length} memories?` })]),
1937
+ const go = await openDialog({
1938
+ title: `Delete ${memories.length} memories?`,
1799
1939
  body,
1800
- node('footer', { class: ui.modalFoot }, [
1801
- node('button', { class: ui.button(), text: 'Cancel', onclick: closeModal }),
1802
- node('button', {
1803
- class: ui.button({ tone: 'danger' }),
1804
- text: `Delete ${memories.length}`,
1805
- onclick: async () => {
1806
- closeModal();
1807
- try {
1808
- const result = await api(`/api/stores/${encodeURIComponent(state.storeId)}/delete-many`, {
1809
- method: 'POST',
1810
- body: JSON.stringify({ files, label: `${memories.length} pruned memories` }),
1811
- });
1812
- state.pruneSelection.clear();
1813
- if (doomed.has(state.selected)) state.selected = null;
1814
- await openStore(state.storeId, { keepTab: true });
1815
- toast(`Deleted ${memories.length} memories`, {
1816
- action: { label: 'Undo', run: () => restoreFromTrash(result.record.id) },
1817
- });
1818
- } catch (err) {
1819
- toast(err.message, { error: true });
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 (el('modal-root').firstChild) closeModal();
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
  });