memorix 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/index.js +2042 -1430
  3. package/dist/cli/memcode.js +0 -0
  4. package/dist/dashboard/static/app.js +293 -33
  5. package/dist/dashboard/static/index.html +17 -0
  6. package/dist/dashboard/static/style.css +310 -0
  7. package/dist/index.js +1669 -970
  8. package/dist/maintenance-runner.js +1061 -1017
  9. package/dist/media-audio-runner.js +1052 -1009
  10. package/dist/media-video-runner.js +1052 -1009
  11. package/dist/memcode-runtime/CHANGELOG.md +34 -0
  12. package/dist/sdk.js +2293 -1684
  13. package/dist/vector-backfill-runner.js +1050 -1007
  14. package/docs/ARCHITECTURE.md +3 -0
  15. package/docs/DASHBOARD.md +54 -0
  16. package/docs/README.md +2 -0
  17. package/package.json +1 -1
  18. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  19. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  20. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  21. package/plugins/copilot/memorix/plugin.json +1 -1
  22. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  23. package/plugins/omp/memorix/package.json +1 -1
  24. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  25. package/plugins/pi/memorix/package.json +1 -1
  26. package/server.json +2 -2
  27. package/src/cli/commands/background.ts +17 -3
  28. package/src/cli/commands/cleanup.ts +13 -114
  29. package/src/cli/commands/memory.ts +12 -43
  30. package/src/cli/commands/operator-shared.ts +12 -8
  31. package/src/cli/commands/serve-http.ts +25 -7
  32. package/src/cli/commands/serve-shared.ts +49 -6
  33. package/src/cli/commands/serve.ts +4 -0
  34. package/src/dashboard/maintenance.ts +303 -0
  35. package/src/dashboard/server.ts +100 -2
  36. package/src/dashboard/static/index.html +17 -0
  37. package/src/dashboard/static/style.css +310 -0
  38. package/src/memory/cleanup.ts +143 -0
  39. package/src/memory/deduplication.ts +178 -0
  40. package/src/memory/session.ts +13 -3
  41. package/src/project/launch-root.ts +115 -0
  42. package/src/runtime/isolated-maintenance.ts +12 -1
  43. package/src/server.ts +50 -51
  44. package/src/store/obs-store.ts +2 -2
  45. package/src/store/sqlite-db.ts +2 -0
  46. package/src/store/sqlite-store.ts +19 -4
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
  // ============================================================
@@ -3214,20 +3481,6 @@ let obsTypeFilter = '';
3214
3481
  let batchMode = false;
3215
3482
  let selectedIds = new Set();
3216
3483
 
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
3484
  function renderBatchToolbar() {
3232
3485
  const slot = document.getElementById('batch-toolbar-slot');
3233
3486
  if (!slot) return;
@@ -3314,8 +3567,8 @@ async function loadObservations() {
3314
3567
  <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
3315
3568
  </div>
3316
3569
  <div style="display:flex;gap:8px;">
3317
- <button class="export-btn" id="btn-batch-cleanup" title="${t('batchCleanup')}">
3318
- [CLEANUP] ${t('batchCleanup')}
3570
+ <button class="export-btn" id="btn-batch-select" title="${t('selectRecords')}">
3571
+ <span class="iconify" data-icon="lucide:list-checks"></span> ${t('selectRecords')}
3319
3572
  </button>
3320
3573
  <button class="export-btn" id="btn-export" title="${t('exportData')}">
3321
3574
  <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 +3577,18 @@ async function loadObservations() {
3324
3577
  </div>
3325
3578
  </div>
3326
3579
 
3580
+ <section class="maintenance-strip" aria-labelledby="memory-maintenance-title">
3581
+ <div class="maintenance-strip-heading">
3582
+ <span id="memory-maintenance-title">${t('memoryMaintenance')}</span>
3583
+ <small>${t('previewRequired')}</small>
3584
+ </div>
3585
+ <div class="maintenance-actions">
3586
+ <button type="button" onclick="openMaintenancePreview('cleanup')"><span class="iconify" data-icon="lucide:eraser"></span>${t('cleanupAction')}</button>
3587
+ <button type="button" onclick="openMaintenancePreview('consolidate')"><span class="iconify" data-icon="lucide:combine"></span>${t('consolidateAction')}</button>
3588
+ <button type="button" onclick="openMaintenancePreview('deduplicate')"><span class="iconify" data-icon="lucide:copy-minus"></span>${t('deduplicateAction')}</button>
3589
+ </div>
3590
+ </section>
3591
+
3327
3592
  <div id="batch-toolbar-slot"></div>
3328
3593
 
3329
3594
  <div class="search-bar">
@@ -3341,18 +3606,10 @@ async function loadObservations() {
3341
3606
  window.open(`/api/export${sep}`, '_blank');
3342
3607
  });
3343
3608
 
3344
- // Batch cleanup: enter batch mode, auto-select low-quality observations
3345
- document.getElementById('btn-batch-cleanup').addEventListener('click', () => {
3609
+ // Manual selection remains separate from the shared cleanup pipeline.
3610
+ document.getElementById('btn-batch-select').addEventListener('click', () => {
3346
3611
  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
- }
3612
+ selectedIds.clear();
3356
3613
  renderObsList();
3357
3614
  renderBatchToolbar();
3358
3615
  });
@@ -3405,18 +3662,16 @@ function renderObsList() {
3405
3662
  }
3406
3663
 
3407
3664
  list.innerHTML = filtered.map(obs => {
3408
- const isLow = isLowQualityObs(obs.title || '');
3409
3665
  const isSelected = selectedIds.has(obs.id);
3410
3666
  const hl = (text) => obsFilter ? escapeHtml(text).replace(new RegExp(`(${escapeHtml(obsFilter).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'), '<mark>$1</mark>') : escapeHtml(text);
3411
3667
  return `
3412
- <div class="obs-card${isLow ? ' low-quality' : ''}" data-obs-id="${obs.id}" onclick="toggleObsDetail(${obs.id})" style="cursor:pointer;">
3668
+ <div class="obs-card" data-obs-id="${obs.id}" onclick="toggleObsDetail(${obs.id})" style="cursor:pointer;">
3413
3669
  <div class="obs-card-header">
3414
3670
  ${batchMode ? `<input type="checkbox" class="obs-checkbox" ${isSelected ? 'checked' : ''} onclick="event.stopPropagation(); toggleObsSelect(${obs.id});" />` : ''}
3415
3671
  <span class="obs-card-id">#${obs.id}</span>
3416
3672
  <span class="type-badge" data-type="${obs.type || 'unknown'}">
3417
3673
  ${typeIcons[obs.type] || '[UNKNOWN]'} ${obs.type || t('unknown')}
3418
3674
  </span>
3419
- ${isLow ? '<span class="low-quality-badge">' + t('lowQuality') + '</span>' : ''}
3420
3675
  <span class="obs-card-title">${hl(obs.title || t('untitled'))}</span>
3421
3676
  <span class="obs-expand-icon">▼</span>
3422
3677
  </div>
@@ -3461,9 +3716,14 @@ async function loadRetention() {
3461
3716
  const { summary, items } = data;
3462
3717
 
3463
3718
  container.innerHTML = `
3464
- <div class="page-header">
3465
- <h1 class="page-title">${t('memoryRetention')}</h1>
3466
- <p class="page-subtitle">${t('retentionSubtitle')}</p>
3719
+ <div class="page-header page-header-actions">
3720
+ <div>
3721
+ <h1 class="page-title">${t('memoryRetention')}</h1>
3722
+ <p class="page-subtitle">${t('retentionSubtitle')}</p>
3723
+ </div>
3724
+ <button class="export-btn" type="button" onclick="openMaintenancePreview('retention')" ${summary.archive > 0 ? '' : 'disabled'}>
3725
+ <span class="iconify" data-icon="lucide:archive"></span>${t('archiveAction')}
3726
+ </button>
3467
3727
  </div>
3468
3728
 
3469
3729
  <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 -->