memorix 1.6.3 → 1.7.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.
File without changes
@@ -49,6 +49,38 @@ const i18n = {
49
49
  deleteObs: 'Delete',
50
50
  deleteConfirm: 'Delete observation #%id%?',
51
51
  batchCleanup: 'Cleanup',
52
+ selectRecords: 'Select',
53
+ memoryMaintenance: 'Memory Maintenance',
54
+ previewRequired: 'Preview required',
55
+ cleanupAction: 'Cleanup',
56
+ consolidateAction: 'Consolidate',
57
+ deduplicateAction: 'Deduplicate',
58
+ archiveAction: 'Archive',
59
+ previewAction: 'Preview',
60
+ executeAction: 'Execute',
61
+ closeAction: 'Close',
62
+ cleanupNoise: 'Include test and demo noise',
63
+ maintenancePreview: 'Maintenance preview',
64
+ maintenanceEmpty: 'No candidates found',
65
+ maintenanceUnavailable: 'Action unavailable',
66
+ maintenanceChanged: 'Memory changed. Preview again before executing.',
67
+ maintenanceSuccess: 'Maintenance completed',
68
+ maintenanceFailed: 'Maintenance failed',
69
+ willDelete: 'Delete',
70
+ willArchive: 'Archive',
71
+ willResolve: 'Resolve',
72
+ willMerge: 'Merge',
73
+ canonicalRecord: 'Keep',
74
+ affectedRecords: 'Affected records',
75
+ cleanupLowQuality: 'Low quality',
76
+ cleanupDuplicates: 'Exact duplicates',
77
+ cleanupNoiseCount: 'Noise',
78
+ scannedRecords: 'Scanned',
79
+ comparisonFailures: 'Comparison failures',
80
+ confirmCleanup: 'Execute cleanup',
81
+ confirmConsolidate: 'Merge candidates',
82
+ confirmDeduplicate: 'Resolve duplicates',
83
+ confirmArchive: 'Archive candidates',
52
84
  selected: 'selected',
53
85
  cancel: 'Cancel',
54
86
  deleteSelected: 'Delete Selected',
@@ -481,6 +513,38 @@ const i18n = {
481
513
  deleteObs: '删除',
482
514
  deleteConfirm: '确认删除观察 #%id%?',
483
515
  batchCleanup: '清理',
516
+ selectRecords: '选择',
517
+ memoryMaintenance: '记忆维护',
518
+ previewRequired: '执行前需预览',
519
+ cleanupAction: '清理',
520
+ consolidateAction: '合并',
521
+ deduplicateAction: '智能去重',
522
+ archiveAction: '归档',
523
+ previewAction: '预览',
524
+ executeAction: '执行',
525
+ closeAction: '关闭',
526
+ cleanupNoise: '包含测试和演示噪声',
527
+ maintenancePreview: '维护预览',
528
+ maintenanceEmpty: '没有发现候选项',
529
+ maintenanceUnavailable: '当前操作不可用',
530
+ maintenanceChanged: '记忆已发生变化,请重新预览后再执行。',
531
+ maintenanceSuccess: '维护完成',
532
+ maintenanceFailed: '维护失败',
533
+ willDelete: '删除',
534
+ willArchive: '归档',
535
+ willResolve: '设为已解决',
536
+ willMerge: '合并',
537
+ canonicalRecord: '保留',
538
+ affectedRecords: '受影响记录',
539
+ cleanupLowQuality: '低质量记录',
540
+ cleanupDuplicates: '完全重复',
541
+ cleanupNoiseCount: '噪声',
542
+ scannedRecords: '已扫描',
543
+ comparisonFailures: '比较失败',
544
+ confirmCleanup: '执行清理',
545
+ confirmConsolidate: '合并候选项',
546
+ confirmDeduplicate: '解决重复项',
547
+ confirmArchive: '归档候选项',
484
548
  selected: '已选中',
485
549
  cancel: '取消',
486
550
  deleteSelected: '删除所选',
@@ -1071,6 +1135,209 @@ async function api(endpoint) {
1071
1135
  }
1072
1136
  }
1073
1137
 
1138
+ // ============================================================
1139
+ // Maintenance actions
1140
+ // ============================================================
1141
+
1142
+ let activeMaintenancePreview = null;
1143
+
1144
+ const maintenanceMeta = {
1145
+ cleanup: { title: 'cleanupAction', confirm: 'confirmCleanup', icon: 'lucide:eraser' },
1146
+ consolidate: { title: 'consolidateAction', confirm: 'confirmConsolidate', icon: 'lucide:combine' },
1147
+ deduplicate: { title: 'deduplicateAction', confirm: 'confirmDeduplicate', icon: 'lucide:copy-minus' },
1148
+ retention: { title: 'archiveAction', confirm: 'confirmArchive', icon: 'lucide:archive' },
1149
+ };
1150
+
1151
+ async function maintenancePost(action, phase, body = {}) {
1152
+ const suffix = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : '';
1153
+ const response = await fetch(`/api/maintenance/${action}/${phase}${suffix}`, {
1154
+ method: 'POST',
1155
+ headers: { 'content-type': 'application/json' },
1156
+ body: JSON.stringify(body),
1157
+ });
1158
+ const data = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
1159
+ if (!response.ok) {
1160
+ const error = new Error(data.error || `HTTP ${response.status}`);
1161
+ error.status = response.status;
1162
+ throw error;
1163
+ }
1164
+ return data;
1165
+ }
1166
+
1167
+ function showDashboardToast(message, tone = 'success') {
1168
+ const toast = document.getElementById('dashboard-toast');
1169
+ if (!toast) return;
1170
+ toast.textContent = message;
1171
+ toast.dataset.tone = tone;
1172
+ toast.classList.add('visible');
1173
+ clearTimeout(showDashboardToast.timer);
1174
+ showDashboardToast.timer = setTimeout(() => toast.classList.remove('visible'), 3200);
1175
+ }
1176
+
1177
+ function maintenanceSummaryEntries(action, summary = {}) {
1178
+ const mappings = {
1179
+ cleanup: [
1180
+ ['delete', 'willDelete'], ['archive', 'willArchive'],
1181
+ ['lowQuality', 'cleanupLowQuality'], ['duplicates', 'cleanupDuplicates'], ['noise', 'cleanupNoiseCount'],
1182
+ ],
1183
+ consolidate: [['clusters', 'willMerge'], ['observations', 'affectedRecords']],
1184
+ deduplicate: [['resolve', 'willResolve'], ['scanned', 'scannedRecords'], ['failedComparisons', 'comparisonFailures']],
1185
+ retention: [['archive', 'willArchive']],
1186
+ };
1187
+ return (mappings[action] || [])
1188
+ .filter(([key]) => summary[key] !== undefined)
1189
+ .map(([key, label]) => ({ label: t(label), value: summary[key] }));
1190
+ }
1191
+
1192
+ function maintenanceRecord(record, prefix = '') {
1193
+ return `
1194
+ <div class="maintenance-record">
1195
+ <span class="maintenance-record-id">#${record.id}</span>
1196
+ <span class="maintenance-record-title">${prefix}${escapeHtml(record.title || t('untitled'))}</span>
1197
+ <span class="maintenance-record-meta">${escapeHtml(record.entityName || '')}</span>
1198
+ </div>`;
1199
+ }
1200
+
1201
+ function maintenanceDetails(action, preview) {
1202
+ if (action === 'cleanup') {
1203
+ const lowQuality = (preview.lowQuality || []).map((item) => maintenanceRecord(item)).join('');
1204
+ const duplicateGroups = (preview.duplicateGroups || []).map((group) => `
1205
+ <div class="maintenance-group">
1206
+ ${maintenanceRecord(group.canonical, `${t('canonicalRecord')}: `)}
1207
+ ${(group.duplicates || []).map((item) => maintenanceRecord(item, `${t('willDelete')}: `)).join('')}
1208
+ </div>`).join('');
1209
+ const noise = (preview.noise || []).map((item) => maintenanceRecord(item, `${t('willArchive')}: `)).join('');
1210
+ const records = `${lowQuality}${duplicateGroups}${noise}`;
1211
+ return `
1212
+ <label class="maintenance-toggle">
1213
+ <input id="maintenance-noise-toggle" type="checkbox" ${preview.includeNoise ? 'checked' : ''}>
1214
+ <span>${t('cleanupNoise')}</span>
1215
+ </label>
1216
+ ${records || `<div class="maintenance-empty">${t('maintenanceEmpty')}</div>`}`;
1217
+ }
1218
+ if (action === 'consolidate') {
1219
+ return (preview.clusters || []).map((cluster, index) => `
1220
+ <div class="maintenance-group">
1221
+ <div class="maintenance-group-title">${t('willMerge')} ${index + 1} · ${escapeHtml(cluster.entityName)} · ${Math.round(cluster.similarity * 100)}%</div>
1222
+ ${(cluster.ids || []).map((id, itemIndex) => maintenanceRecord({
1223
+ id,
1224
+ title: (cluster.titles || [])[itemIndex] || '',
1225
+ entityName: cluster.type || '',
1226
+ })).join('')}
1227
+ </div>`).join('');
1228
+ }
1229
+ if (action === 'deduplicate') {
1230
+ return (preview.actions || []).map((item) => `
1231
+ <div class="maintenance-group">
1232
+ ${maintenanceRecord({ id: item.keepId, title: item.keepTitle, entityName: item.entityName }, `${t('canonicalRecord')}: `)}
1233
+ ${maintenanceRecord({ id: item.resolveId, title: item.resolveTitle, entityName: item.reason }, `${t('willResolve')}: `)}
1234
+ </div>`).join('');
1235
+ }
1236
+ return (preview.candidates || []).map((item) => maintenanceRecord({
1237
+ ...item,
1238
+ entityName: `${item.score} · ${item.ageHours}h`,
1239
+ })).join('');
1240
+ }
1241
+
1242
+ function renderMaintenanceDialog(action, preview) {
1243
+ const dialog = document.getElementById('maintenance-dialog');
1244
+ const body = document.getElementById('maintenance-dialog-body');
1245
+ const footer = document.getElementById('maintenance-dialog-footer');
1246
+ const meta = maintenanceMeta[action];
1247
+ document.getElementById('maintenance-dialog-kicker').textContent = t('maintenancePreview');
1248
+ document.getElementById('maintenance-dialog-title').textContent = t(meta.title);
1249
+
1250
+ if (preview.available === false) {
1251
+ body.innerHTML = `<div class="maintenance-empty"><strong>${t('maintenanceUnavailable')}</strong><span>${escapeHtml(preview.reason || '')}</span></div>`;
1252
+ footer.innerHTML = `<button class="maintenance-secondary" type="button" data-maintenance-close>${t('closeAction')}</button>`;
1253
+ } else {
1254
+ const summary = maintenanceSummaryEntries(action, preview.summary);
1255
+ const hasChanges = action === 'cleanup'
1256
+ ? (preview.summary.delete + preview.summary.archive) > 0
1257
+ : action === 'consolidate'
1258
+ ? preview.summary.clusters > 0
1259
+ : action === 'deduplicate'
1260
+ ? preview.summary.resolve > 0
1261
+ : preview.summary.archive > 0;
1262
+ body.innerHTML = `
1263
+ <div class="maintenance-summary">
1264
+ ${summary.map((item) => `<div class="maintenance-metric"><span>${escapeHtml(item.label)}</span><strong>${item.value}</strong></div>`).join('')}
1265
+ </div>
1266
+ <div class="maintenance-records">
1267
+ ${action === 'cleanup' || hasChanges ? maintenanceDetails(action, preview) : `<div class="maintenance-empty">${t('maintenanceEmpty')}</div>`}
1268
+ </div>`;
1269
+ footer.innerHTML = `
1270
+ <button class="maintenance-secondary" type="button" data-maintenance-close>${t('cancel')}</button>
1271
+ <button class="maintenance-primary" id="maintenance-execute" type="button" ${hasChanges ? '' : 'disabled'}>
1272
+ <span class="iconify" data-icon="${meta.icon}"></span>${t(meta.confirm)}
1273
+ </button>`;
1274
+ document.getElementById('maintenance-execute')?.addEventListener('click', executeMaintenancePreview);
1275
+ document.getElementById('maintenance-noise-toggle')?.addEventListener('change', (event) => {
1276
+ openMaintenancePreview('cleanup', { includeNoise: event.target.checked });
1277
+ });
1278
+ }
1279
+ footer.querySelector('[data-maintenance-close]')?.addEventListener('click', closeMaintenanceDialog);
1280
+ if (!dialog.open) dialog.showModal();
1281
+ }
1282
+
1283
+ async function openMaintenancePreview(action, options = {}) {
1284
+ const dialog = document.getElementById('maintenance-dialog');
1285
+ const meta = maintenanceMeta[action];
1286
+ document.getElementById('maintenance-dialog-kicker').textContent = t('maintenancePreview');
1287
+ document.getElementById('maintenance-dialog-title').textContent = t(meta.title);
1288
+ document.getElementById('maintenance-dialog-body').innerHTML = '<div class="loading"><div class="spinner"></div></div>';
1289
+ document.getElementById('maintenance-dialog-footer').innerHTML = '';
1290
+ if (!dialog.open) dialog.showModal();
1291
+
1292
+ try {
1293
+ const preview = await maintenancePost(action, 'preview', action === 'cleanup' ? { includeNoise: options.includeNoise === true } : {});
1294
+ activeMaintenancePreview = { action, preview };
1295
+ renderMaintenanceDialog(action, preview);
1296
+ } catch (error) {
1297
+ activeMaintenancePreview = null;
1298
+ document.getElementById('maintenance-dialog-body').innerHTML = `
1299
+ <div class="maintenance-empty"><strong>${t('maintenanceFailed')}</strong><span>${escapeHtml(error.message)}</span></div>`;
1300
+ document.getElementById('maintenance-dialog-footer').innerHTML = `
1301
+ <button class="maintenance-secondary" type="button" data-maintenance-close>${t('closeAction')}</button>`;
1302
+ document.querySelector('[data-maintenance-close]')?.addEventListener('click', closeMaintenanceDialog);
1303
+ }
1304
+ }
1305
+
1306
+ async function executeMaintenancePreview() {
1307
+ if (!activeMaintenancePreview) return;
1308
+ const { action, preview } = activeMaintenancePreview;
1309
+ const button = document.getElementById('maintenance-execute');
1310
+ button.disabled = true;
1311
+ button.classList.add('busy');
1312
+ try {
1313
+ await maintenancePost(action, 'execute', { payload: preview.payload, token: preview.token });
1314
+ closeMaintenanceDialog();
1315
+ showDashboardToast(t('maintenanceSuccess'));
1316
+ if (currentPage === 'retention') await loadRetention();
1317
+ else await loadObservations();
1318
+ } catch (error) {
1319
+ button.disabled = false;
1320
+ button.classList.remove('busy');
1321
+ const message = error.status === 409 ? t('maintenanceChanged') : `${t('maintenanceFailed')}: ${error.message}`;
1322
+ showDashboardToast(message, 'error');
1323
+ }
1324
+ }
1325
+
1326
+ function closeMaintenanceDialog() {
1327
+ activeMaintenancePreview = null;
1328
+ document.getElementById('maintenance-dialog')?.close();
1329
+ }
1330
+
1331
+ onDomReady(() => {
1332
+ const dialog = document.getElementById('maintenance-dialog');
1333
+ dialog?.querySelector('.maintenance-dialog-close')?.addEventListener('click', closeMaintenanceDialog);
1334
+ dialog?.addEventListener('click', (event) => {
1335
+ if (event.target === dialog) closeMaintenanceDialog();
1336
+ });
1337
+ });
1338
+
1339
+ window.openMaintenancePreview = openMaintenancePreview;
1340
+
1074
1341
  // ============================================================
1075
1342
  // Project Switcher — Custom Dropdown
1076
1343
  // ============================================================
@@ -2110,20 +2377,21 @@ function renderSemanticGraph(kg) {
2110
2377
  backgroundColor: 'transparent',
2111
2378
  tooltip: {
2112
2379
  trigger: 'item',
2380
+ confine: true,
2113
2381
  backgroundColor: tooltipBg,
2114
2382
  borderColor: tooltipBorder,
2115
2383
  borderWidth: 1,
2116
2384
  textStyle: { color: labelColor, fontSize: 12, fontFamily: 'Inter, system-ui, sans-serif' },
2117
- extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.18); border-radius: 8px; padding: 10px 12px; max-width: 320px;',
2385
+ extraCssText: 'box-shadow: 0 4px 16px rgba(0,0,0,0.18); border-radius: 8px; padding: 10px 12px; width: min(320px, calc(100vw - 32px)); max-width: calc(100vw - 32px); box-sizing: border-box; white-space: normal; overflow-wrap: anywhere; word-break: break-word;',
2118
2386
  formatter: (params) => {
2119
2387
  if (params.dataType === 'node') {
2120
2388
  const d = params.data;
2121
2389
  const summary = d.summary ? String(d.summary).slice(0, 160) : '';
2122
2390
  return (
2123
- `<div style="font-weight:600;font-size:13px;margin-bottom:4px;line-height:1.35;">${wrap(d.fullLabel || d.name)}</div>` +
2391
+ `<div style="font-weight:600;font-size:13px;margin-bottom:4px;line-height:1.35;white-space:normal;overflow-wrap:anywhere;word-break:break-word;">${wrap(d.fullLabel || d.name)}</div>` +
2124
2392
  `<div style="font-size:11px;color:${labelMutedColor};margin-bottom:6px;">${wrap(d.nodeType || '')}${d.entityName ? ' \u00b7 ' + wrap(d.entityName) : ''}</div>` +
2125
- `<div style="font-size:11px;color:${labelMutedColor};">${d.evidenceCount || 0} ${wrap(t('kgInspectorEvidence'))}</div>` +
2126
- (summary ? `<div style="font-size:11.5px;line-height:1.5;margin-top:8px;color:${labelColor};opacity:0.85;">${wrap(summary)}\u2026</div>` : '')
2393
+ `<div style="font-size:11px;color:${labelMutedColor};white-space:normal;overflow-wrap:anywhere;word-break:break-word;">${d.evidenceCount || 0} ${wrap(t('kgInspectorEvidence'))}</div>` +
2394
+ (summary ? `<div style="font-size:11.5px;line-height:1.5;margin-top:8px;color:${labelColor};opacity:0.85;white-space:normal;overflow-wrap:anywhere;word-break:break-word;">${wrap(summary)}\u2026</div>` : '')
2127
2395
  );
2128
2396
  }
2129
2397
  if (params.dataType === 'edge') {
@@ -3214,20 +3482,6 @@ let obsTypeFilter = '';
3214
3482
  let batchMode = false;
3215
3483
  let selectedIds = new Set();
3216
3484
 
3217
- // Low quality detection (same patterns as CLI cleanup)
3218
- const LOW_QUALITY_OBS_PATTERNS = [
3219
- /^Session activity/i,
3220
- /^Updated \S+\.\w+$/i,
3221
- /^Created \S+\.\w+$/i,
3222
- /^Deleted \S+\.\w+$/i,
3223
- /^Modified \S+\.\w+$/i,
3224
- /^Ran command:/i,
3225
- /^Read file:/i,
3226
- ];
3227
- function isLowQualityObs(title) {
3228
- return LOW_QUALITY_OBS_PATTERNS.some(p => p.test(title.trim()));
3229
- }
3230
-
3231
3485
  function renderBatchToolbar() {
3232
3486
  const slot = document.getElementById('batch-toolbar-slot');
3233
3487
  if (!slot) return;
@@ -3314,8 +3568,8 @@ async function loadObservations() {
3314
3568
  <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
3315
3569
  </div>
3316
3570
  <div style="display:flex;gap:8px;">
3317
- <button class="export-btn" id="btn-batch-cleanup" title="${t('batchCleanup')}">
3318
- [CLEANUP] ${t('batchCleanup')}
3571
+ <button class="export-btn" id="btn-batch-select" title="${t('selectRecords')}">
3572
+ <span class="iconify" data-icon="lucide:list-checks"></span> ${t('selectRecords')}
3319
3573
  </button>
3320
3574
  <button class="export-btn" id="btn-export" title="${t('exportData')}">
3321
3575
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M4 7l4 4 4-4M2 12v2h12v-2"/></svg>
@@ -3324,6 +3578,18 @@ async function loadObservations() {
3324
3578
  </div>
3325
3579
  </div>
3326
3580
 
3581
+ <section class="maintenance-strip" aria-labelledby="memory-maintenance-title">
3582
+ <div class="maintenance-strip-heading">
3583
+ <span id="memory-maintenance-title">${t('memoryMaintenance')}</span>
3584
+ <small>${t('previewRequired')}</small>
3585
+ </div>
3586
+ <div class="maintenance-actions">
3587
+ <button type="button" onclick="openMaintenancePreview('cleanup')"><span class="iconify" data-icon="lucide:eraser"></span>${t('cleanupAction')}</button>
3588
+ <button type="button" onclick="openMaintenancePreview('consolidate')"><span class="iconify" data-icon="lucide:combine"></span>${t('consolidateAction')}</button>
3589
+ <button type="button" onclick="openMaintenancePreview('deduplicate')"><span class="iconify" data-icon="lucide:copy-minus"></span>${t('deduplicateAction')}</button>
3590
+ </div>
3591
+ </section>
3592
+
3327
3593
  <div id="batch-toolbar-slot"></div>
3328
3594
 
3329
3595
  <div class="search-bar">
@@ -3341,18 +3607,10 @@ async function loadObservations() {
3341
3607
  window.open(`/api/export${sep}`, '_blank');
3342
3608
  });
3343
3609
 
3344
- // Batch cleanup: enter batch mode, auto-select low-quality observations
3345
- document.getElementById('btn-batch-cleanup').addEventListener('click', () => {
3610
+ // Manual selection remains separate from the shared cleanup pipeline.
3611
+ document.getElementById('btn-batch-select').addEventListener('click', () => {
3346
3612
  batchMode = !batchMode;
3347
- if (batchMode) {
3348
- // Auto-select low quality ones
3349
- selectedIds.clear();
3350
- allObservations.forEach(obs => {
3351
- if (isLowQualityObs(obs.title || '')) selectedIds.add(obs.id);
3352
- });
3353
- } else {
3354
- selectedIds.clear();
3355
- }
3613
+ selectedIds.clear();
3356
3614
  renderObsList();
3357
3615
  renderBatchToolbar();
3358
3616
  });
@@ -3405,18 +3663,16 @@ function renderObsList() {
3405
3663
  }
3406
3664
 
3407
3665
  list.innerHTML = filtered.map(obs => {
3408
- const isLow = isLowQualityObs(obs.title || '');
3409
3666
  const isSelected = selectedIds.has(obs.id);
3410
3667
  const hl = (text) => obsFilter ? escapeHtml(text).replace(new RegExp(`(${escapeHtml(obsFilter).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>') : escapeHtml(text);
3411
3668
  return `
3412
- <div class="obs-card${isLow ? ' low-quality' : ''}" data-obs-id="${obs.id}" onclick="toggleObsDetail(${obs.id})" style="cursor:pointer;">
3669
+ <div class="obs-card" data-obs-id="${obs.id}" onclick="toggleObsDetail(${obs.id})" style="cursor:pointer;">
3413
3670
  <div class="obs-card-header">
3414
3671
  ${batchMode ? `<input type="checkbox" class="obs-checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleObsSelect(${obs.id});" />` : ''}
3415
3672
  <span class="obs-card-id">#${obs.id}</span>
3416
3673
  <span class="type-badge" data-type="${obs.type || 'unknown'}">
3417
3674
  ${typeIcons[obs.type] || '[UNKNOWN]'} ${obs.type || t('unknown')}
3418
3675
  </span>
3419
- ${isLow ? '<span class="low-quality-badge">' + t('lowQuality') + '</span>' : ''}
3420
3676
  <span class="obs-card-title">${hl(obs.title || t('untitled'))}</span>
3421
3677
  <span class="obs-expand-icon">▼</span>
3422
3678
  </div>
@@ -3461,9 +3717,14 @@ async function loadRetention() {
3461
3717
  const { summary, items } = data;
3462
3718
 
3463
3719
  container.innerHTML = `
3464
- <div class="page-header">
3465
- <h1 class="page-title">${t('memoryRetention')}</h1>
3466
- <p class="page-subtitle">${t('retentionSubtitle')}</p>
3720
+ <div class="page-header page-header-actions">
3721
+ <div>
3722
+ <h1 class="page-title">${t('memoryRetention')}</h1>
3723
+ <p class="page-subtitle">${t('retentionSubtitle')}</p>
3724
+ </div>
3725
+ <button class="export-btn" type="button" onclick="openMaintenancePreview('retention')" ${summary.archive > 0 ? '' : 'disabled'}>
3726
+ <span class="iconify" data-icon="lucide:archive"></span>${t('archiveAction')}
3727
+ </button>
3467
3728
  </div>
3468
3729
 
3469
3730
  <div class="retention-summary">
@@ -182,6 +182,23 @@
182
182
  <div id="page-team" class="page"></div>
183
183
  </main>
184
184
 
185
+ <dialog id="maintenance-dialog" class="maintenance-dialog" aria-labelledby="maintenance-dialog-title">
186
+ <div class="maintenance-dialog-shell">
187
+ <header class="maintenance-dialog-header">
188
+ <div>
189
+ <div class="maintenance-dialog-kicker" id="maintenance-dialog-kicker"></div>
190
+ <h2 id="maintenance-dialog-title"></h2>
191
+ </div>
192
+ <button class="icon-btn maintenance-dialog-close" type="button" aria-label="Close" title="Close">
193
+ <span class="iconify" data-icon="lucide:x"></span>
194
+ </button>
195
+ </header>
196
+ <div id="maintenance-dialog-body" class="maintenance-dialog-body"></div>
197
+ <footer id="maintenance-dialog-footer" class="maintenance-dialog-footer"></footer>
198
+ </div>
199
+ </dialog>
200
+ <div id="dashboard-toast" class="dashboard-toast" role="status" aria-live="polite"></div>
201
+
185
202
  <!-- Apache ECharts 5 — Knowledge Graph renderer -->
186
203
  <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
187
204
  <!-- Cytoscape.js + Dagre — legacy entity-graph fallback -->