memorix 1.2.8 → 1.2.10

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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -2
  3. package/dist/cli/index.js +19505 -19136
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/cli/memcode.js +0 -0
  6. package/dist/index.js +7849 -7628
  7. package/dist/index.js.map +1 -1
  8. package/dist/maintenance-jobs-o1rYSFcM.d.ts +36 -0
  9. package/dist/maintenance-runner.d.ts +1 -34
  10. package/dist/maintenance-runner.js +5892 -5692
  11. package/dist/maintenance-runner.js.map +1 -1
  12. package/dist/memcode-runtime/CHANGELOG.md +16 -0
  13. package/dist/sdk.js +7782 -7561
  14. package/dist/sdk.js.map +1 -1
  15. package/dist/vector-backfill-runner.d.ts +31 -0
  16. package/dist/vector-backfill-runner.js +12670 -0
  17. package/dist/vector-backfill-runner.js.map +1 -0
  18. package/docs/AGENT_OPERATOR_PLAYBOOK.md +2 -2
  19. package/docs/DEVELOPMENT.md +7 -1
  20. package/docs/INTEGRATIONS.md +1 -1
  21. package/docs/SETUP.md +6 -4
  22. package/docs/hooks-architecture.md +1 -1
  23. package/package.json +6 -2
  24. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  25. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  26. package/plugins/copilot/memorix/plugin.json +1 -1
  27. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  28. package/plugins/omp/memorix/package.json +1 -1
  29. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  30. package/plugins/pi/memorix/package.json +1 -1
  31. package/server.json +38 -0
  32. package/src/cli/commands/agent-integrations.ts +12 -6
  33. package/src/cli/commands/operator-shared.ts +4 -1
  34. package/src/cli/commands/serve-http.ts +44 -47
  35. package/src/cli/commands/setup.ts +200 -23
  36. package/src/dashboard/server.ts +43 -63
  37. package/src/memory/observations.ts +108 -79
  38. package/src/memory/retention.ts +106 -28
  39. package/src/memory/session.ts +71 -12
  40. package/src/runtime/vector-backfill-runner.ts +144 -0
  41. package/src/search/intent-detector.ts +39 -1
  42. package/src/server.ts +5 -5
@@ -162,7 +162,9 @@ const OPENCLAW_BUNDLE_AGENTS = new Set<AgentName>(['openclaw']);
162
162
  const ANTIGRAVITY_PLUGIN_AGENTS = new Set<AgentName>(['antigravity']);
163
163
  const HERMES_PLUGIN_AGENTS = new Set<AgentName>(['hermes']);
164
164
  const OMP_PACKAGE_AGENTS = new Set<AgentName>(['omp']);
165
- const PACKAGE_OWNED_INTEGRATION_AGENTS = new Set<AgentName>(['antigravity', 'openclaw', 'hermes', 'omp']);
165
+ // These hosts have a native package/plugin surface. Their default setup must not
166
+ // create project-local fallback files beside the user's own agent configuration.
167
+ const PACKAGE_OWNED_INTEGRATION_AGENTS = new Set<AgentName>(['codex', 'antigravity', 'openclaw', 'hermes', 'omp']);
166
168
  const SUPPORTED_SETUP_AGENTS: AgentName[] = [
167
169
  'claude',
168
170
  'codex',
@@ -484,6 +486,32 @@ function mergeTomlMcpConfig(existingContent: string | null, generatedContent: st
484
486
  return `${parts.join('\n\n')}\n`;
485
487
  }
486
488
 
489
+ type LegacyArtifactStatus = 'removed' | 'preserved' | 'missing';
490
+
491
+ export interface CodexLegacyMigrationResult {
492
+ mcp: LegacyArtifactStatus;
493
+ projectHooks: LegacyArtifactStatus;
494
+ pluginFolder: LegacyArtifactStatus;
495
+ }
496
+
497
+ function getTomlSection(content: string, section: string): string | null {
498
+ const lines = content.split(/\r?\n/);
499
+ const header = `[${section}]`;
500
+ const start = lines.findIndex((line) => line.trim() === header);
501
+ if (start < 0) return null;
502
+
503
+ let end = lines.length;
504
+ for (let index = start + 1; index < lines.length; index += 1) {
505
+ const trimmed = lines[index].trim();
506
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
507
+ end = index;
508
+ break;
509
+ }
510
+ }
511
+
512
+ return lines.slice(start + 1, end).join('\n');
513
+ }
514
+
487
515
  function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
488
516
  if (server.name !== 'memorix' || server.command.trim().toLowerCase() !== 'node') return false;
489
517
  const startsMemorixStdio = server.args.some((arg) => arg.trim().toLowerCase() === 'serve');
@@ -494,6 +522,22 @@ function isLegacyCodexMemorixNodeServer(server: MCPServerEntry): boolean {
494
522
  return startsMemorixStdio && sourceEntry;
495
523
  }
496
524
 
525
+ function isOwnedLegacyCodexMcp(content: string): boolean {
526
+ const section = getTomlSection(content, 'mcp_servers.memorix');
527
+ if (section === null || getTomlSection(content, 'mcp_servers.memorix.env') !== null) return false;
528
+
529
+ const allowedKeys = new Set(['command', 'args', 'type', 'startup_timeout_sec', 'tool_timeout_sec', 'enabled']);
530
+ for (const rawLine of section.split(/\r?\n/)) {
531
+ const line = rawLine.trim();
532
+ if (!line || line.startsWith('#')) continue;
533
+ const key = line.match(/^([A-Za-z0-9_]+)\s*=/)?.[1];
534
+ if (!key || !allowedKeys.has(key)) return false;
535
+ }
536
+
537
+ const server = new CodexMCPAdapter().parse(content).find((entry) => entry.name === 'memorix');
538
+ return Boolean(server && isLegacyCodexMemorixNodeServer(server));
539
+ }
540
+
497
541
  /**
498
542
  * Codex plugins now own Memorix stdio MCP registration. Remove only the old
499
543
  * source-path form emitted by pre-plugin setup, never a user's custom server.
@@ -508,9 +552,7 @@ export async function removeLegacyCodexMemorixMcpConfig(
508
552
  return { configPath, removed: false };
509
553
  }
510
554
 
511
- const adapter = new CodexMCPAdapter();
512
- const legacyServer = adapter.parse(existingContent).find(isLegacyCodexMemorixNodeServer);
513
- if (!legacyServer) return { configPath, removed: false };
555
+ if (!isOwnedLegacyCodexMcp(existingContent)) return { configPath, removed: false };
514
556
 
515
557
  let nextContent = removeTomlSection(existingContent, 'mcp_servers.memorix.env');
516
558
  nextContent = removeTomlSection(nextContent, 'mcp_servers.memorix');
@@ -518,6 +560,88 @@ export async function removeLegacyCodexMemorixMcpConfig(
518
560
  return { configPath, removed: true };
519
561
  }
520
562
 
563
+ function collectHookCommands(value: unknown, commands: string[]): void {
564
+ if (Array.isArray(value)) {
565
+ for (const item of value) collectHookCommands(item, commands);
566
+ return;
567
+ }
568
+ if (!value || typeof value !== 'object') return;
569
+
570
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
571
+ if ((key === 'command' || key === 'commandWindows') && typeof child === 'string') {
572
+ commands.push(child);
573
+ } else {
574
+ collectHookCommands(child, commands);
575
+ }
576
+ }
577
+ }
578
+
579
+ function isOwnedLegacyCodexHooks(content: string): boolean {
580
+ try {
581
+ const parsed = JSON.parse(content) as { hooks?: unknown };
582
+ if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
583
+ const commands: string[] = [];
584
+ collectHookCommands(parsed.hooks, commands);
585
+ return commands.length > 0
586
+ && commands.every((command) => /^memorix(?:\.cmd)?\s+hook(?:\s|$)/i.test(command.trim()));
587
+ } catch {
588
+ return false;
589
+ }
590
+ }
591
+
592
+ async function removeOwnedLegacyCodexPluginFolder(homeDir: string): Promise<LegacyArtifactStatus> {
593
+ const canonicalPath = path.join(homeDir, 'plugins', 'memorix');
594
+ const legacyPath = path.join(homeDir, '.codex', 'plugins', 'memorix');
595
+ if (!existsSync(legacyPath)) return 'missing';
596
+ if (!existsSync(canonicalPath)) return 'preserved';
597
+
598
+ try {
599
+ const manifest = JSON.parse(await readFile(path.join(legacyPath, '.codex-plugin', 'plugin.json'), 'utf-8')) as Record<string, unknown>;
600
+ const repository = String(manifest.repository ?? '');
601
+ if (manifest.name !== 'memorix' || !repository.includes('github.com/AVIDS2/memorix')) return 'preserved';
602
+ await rm(legacyPath, { recursive: true, force: true });
603
+ return 'removed';
604
+ } catch {
605
+ return 'preserved';
606
+ }
607
+ }
608
+
609
+ export async function migrateLegacyCodexIntegration(options: {
610
+ homeDir?: string;
611
+ projectRoot?: string;
612
+ } = {}): Promise<CodexLegacyMigrationResult> {
613
+ const homeDir = options.homeDir ?? homedir();
614
+ const projectRoot = options.projectRoot ?? process.cwd();
615
+ const configPath = path.join(homeDir, '.codex', 'config.toml');
616
+ let mcp: LegacyArtifactStatus = 'missing';
617
+
618
+ try {
619
+ const content = await readFile(configPath, 'utf-8');
620
+ if (getTomlSection(content, 'mcp_servers.memorix') !== null) {
621
+ mcp = (await removeLegacyCodexMemorixMcpConfig(configPath)).removed ? 'removed' : 'preserved';
622
+ }
623
+ } catch {
624
+ mcp = 'missing';
625
+ }
626
+
627
+ const hooksPath = path.join(projectRoot, '.codex', 'hooks.json');
628
+ let projectHooks: LegacyArtifactStatus = 'missing';
629
+ try {
630
+ const content = await readFile(hooksPath, 'utf-8');
631
+ if (isOwnedLegacyCodexHooks(content)) {
632
+ await rm(hooksPath, { force: true });
633
+ projectHooks = 'removed';
634
+ } else {
635
+ projectHooks = 'preserved';
636
+ }
637
+ } catch {
638
+ projectHooks = 'missing';
639
+ }
640
+
641
+ const pluginFolder = await removeOwnedLegacyCodexPluginFolder(homeDir);
642
+ return { mcp, projectHooks, pluginFolder };
643
+ }
644
+
521
645
  function mergeYamlMcpConfig(existingContent: string | null, generatedContent: string): string {
522
646
  let existing: Record<string, unknown> = {};
523
647
  try {
@@ -678,7 +802,9 @@ export async function installPluginPackage(options: PluginInstallOptions): Promi
678
802
  }
679
803
 
680
804
  if (options.agent === 'codex') {
681
- const pluginPath = path.join(home, '.codex', 'plugins', 'memorix');
805
+ // Codex discovers personal plugins from the user marketplace. Keep the
806
+ // package outside ~/.codex so setup does not look like project/config state.
807
+ const pluginPath = path.join(home, 'plugins', 'memorix');
682
808
  const marketplacePath = path.join(home, '.agents', 'plugins', 'marketplace.json');
683
809
  await copyDir(source, pluginPath);
684
810
  await stampCodexPluginVersion(pluginPath);
@@ -908,6 +1034,39 @@ function tryInstallClaudePlugin(marketplaceRoot: string): { ok: boolean; message
908
1034
  };
909
1035
  }
910
1036
 
1037
+ export type CodexPluginState = 'ready' | 'disabled' | 'missing' | 'unknown';
1038
+
1039
+ export function parseCodexPluginState(content: string): CodexPluginState {
1040
+ try {
1041
+ // Some Windows builds emit a one-line diagnostic before the requested JSON.
1042
+ const start = content.indexOf('{');
1043
+ const end = content.lastIndexOf('}');
1044
+ if (start < 0 || end < start) return 'unknown';
1045
+ const parsed = JSON.parse(content.slice(start, end + 1)) as { installed?: unknown };
1046
+ if (!Array.isArray(parsed.installed)) return 'unknown';
1047
+
1048
+ const plugin = parsed.installed.find((entry): entry is Record<string, unknown> => (
1049
+ Boolean(entry)
1050
+ && typeof entry === 'object'
1051
+ && (entry as Record<string, unknown>).pluginId === 'memorix@personal'
1052
+ ));
1053
+ if (!plugin || plugin.installed !== true) return 'missing';
1054
+ return plugin.enabled === true ? 'ready' : 'disabled';
1055
+ } catch {
1056
+ return 'unknown';
1057
+ }
1058
+ }
1059
+
1060
+ function getCodexPluginState(): CodexPluginState {
1061
+ const result = spawnSync('codex', ['plugin', 'list', '--json'], {
1062
+ encoding: 'utf-8',
1063
+ stdio: 'pipe',
1064
+ shell: process.platform === 'win32',
1065
+ });
1066
+ if (result.status !== 0) return 'unknown';
1067
+ return parseCodexPluginState(result.stdout || '');
1068
+ }
1069
+
911
1070
  export function tryInstallCodexPlugin(): { ok: boolean; message: string } {
912
1071
  const result = spawnSync('codex', ['plugin', 'add', 'memorix@personal', '--json'], {
913
1072
  encoding: 'utf-8',
@@ -915,13 +1074,28 @@ export function tryInstallCodexPlugin(): { ok: boolean; message: string } {
915
1074
  shell: process.platform === 'win32',
916
1075
  });
917
1076
 
918
- if (result.status === 0) {
919
- return { ok: true, message: 'codex: plugin installed from Personal marketplace' };
920
- }
921
-
922
1077
  const output = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase();
923
- if (output.includes('already')) {
924
- return { ok: true, message: 'codex: plugin already installed from Personal marketplace' };
1078
+ const addFinished = result.status === 0 || output.includes('already');
1079
+ if (addFinished) {
1080
+ const pluginState = getCodexPluginState();
1081
+ if (pluginState === 'ready') {
1082
+ return {
1083
+ ok: true,
1084
+ message: result.status === 0
1085
+ ? 'codex: plugin installed and enabled from Personal marketplace'
1086
+ : 'codex: plugin already installed and enabled from Personal marketplace',
1087
+ };
1088
+ }
1089
+ if (pluginState === 'disabled') {
1090
+ return {
1091
+ ok: false,
1092
+ message: 'codex: Memorix plugin is installed but disabled. Enable it in Codex Plugins before any legacy MCP or project hooks are removed.',
1093
+ };
1094
+ }
1095
+ return {
1096
+ ok: false,
1097
+ message: 'codex: plugin registration finished, but Codex did not confirm an enabled Memorix plugin. Legacy MCP and project hooks were kept unchanged.',
1098
+ };
925
1099
  }
926
1100
 
927
1101
  const detail = (result.stderr || result.stdout || result.error?.message || '').trim();
@@ -1106,7 +1280,9 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1106
1280
  const hasAntigravityPlugin = plan.actions.includes('antigravity-plugin') && agent === 'antigravity';
1107
1281
  const hasHermesPlugin = plan.actions.includes('hermes-plugin') && agent === 'hermes';
1108
1282
  const hasOmpPackage = plan.actions.includes('omp-package') && agent === 'omp';
1109
- const wantsMcpConfig = plan.mcp !== 'none' && agent !== 'pi';
1283
+ // Codex's supported default is the user-level plugin. Never create a
1284
+ // project-local .codex/config.toml as an implicit fallback.
1285
+ const wantsMcpConfig = plan.mcp !== 'none' && agent !== 'pi' && !(agent === 'codex' && !global);
1110
1286
 
1111
1287
  if (PLUGIN_PACKAGE_AGENTS.has(agent) && plan.includePlugin && !global) {
1112
1288
  p.log.info(`${agent}: project setup leaves user-level plugins untouched. Run \`memorix setup --agent ${agent} --global\` to install its plugin, skills, and lifecycle hooks.`);
@@ -1115,7 +1291,7 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1115
1291
  if (hasPluginPackage) {
1116
1292
  const result = await installPluginPackage({
1117
1293
  agent: agent as PluginInstallOptions['agent'],
1118
- includeHooks: plan.actions.includes('hooks'),
1294
+ includeHooks: plan.includeHooks,
1119
1295
  });
1120
1296
  p.log.success(`${agent}: plugin package -> ${result.pluginPath}`);
1121
1297
  if (result.marketplacePath) {
@@ -1123,17 +1299,16 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1123
1299
  }
1124
1300
  if (agent === 'codex') {
1125
1301
  const install = tryInstallCodexPlugin();
1126
- if (install.ok) p.log.success(install.message);
1127
- else p.log.warn(install.message);
1128
1302
  if (install.ok) {
1129
- try {
1130
- const migration = await removeLegacyCodexMemorixMcpConfig();
1131
- if (migration.removed) {
1132
- p.log.info(`codex: removed legacy source-path MCP config from ${migration.configPath}`);
1133
- }
1134
- } catch {
1135
- p.log.warn('codex: plugin installed, but the legacy MCP config could not be checked');
1136
- }
1303
+ p.log.success(install.message);
1304
+ const migration = await migrateLegacyCodexIntegration({ projectRoot: process.cwd() });
1305
+ if (migration.mcp === 'removed') p.log.info('codex: removed the legacy source-path MCP block; the enabled plugin now owns MCP.');
1306
+ if (migration.mcp === 'preserved') p.log.warn('codex: kept a customized legacy Memorix MCP block; remove it manually only after confirming plugin MCP works.');
1307
+ if (migration.projectHooks === 'removed') p.log.info('codex: removed a legacy project-local Memorix hooks.json; plugin hooks now own lifecycle capture.');
1308
+ if (migration.projectHooks === 'preserved') p.log.warn('codex: kept a project .codex/hooks.json because it contains non-Memorix commands.');
1309
+ if (migration.pluginFolder === 'removed') p.log.info('codex: removed the migrated legacy plugin folder under ~/.codex/plugins.');
1310
+ } else {
1311
+ p.log.warn(install.message);
1137
1312
  }
1138
1313
  } else if (agent === 'claude' && result.marketplaceRoot) {
1139
1314
  const install = tryInstallClaudePlugin(result.marketplaceRoot);
@@ -1224,6 +1399,8 @@ export async function installAgentSetup(agent: AgentName, plan: SetupPlan, globa
1224
1399
  const result = await installMcpConfig({ agent: agent as McpConfigAgent, projectRoot: targetRoot, global, mcp });
1225
1400
  p.log.success(`${agent}: MCP config -> ${result.configPath}`);
1226
1401
  }
1402
+ } else if (agent === 'codex' && !global && plan.mcp !== 'none') {
1403
+ p.log.info('codex: project-local .codex config is intentionally untouched. Run `memorix setup --agent codex --global` to install the user-level plugin.');
1227
1404
  }
1228
1405
 
1229
1406
  if (hasPluginPackage) {
@@ -23,6 +23,7 @@ import { resetDotenv } from '../config/dotenv-loader.js';
23
23
  import { initProjectRoot } from '../config/yaml-loader.js';
24
24
  import { clearProjectRoot } from '../config/yaml-loader.js';
25
25
  import { scopeKnowledgeGraphToProject } from '../memory/graph-scope.js';
26
+ import { projectObservationRetention, summarizeRetentionProjections } from '../memory/retention.js';
26
27
  import { canManageObservation, filterReadableObservations } from '../memory/visibility.js';
27
28
  import type { Observation } from '../types.js';
28
29
 
@@ -249,7 +250,7 @@ async function handleApi(
249
250
  const observations = filterDashboardObservations(
250
251
  await getObservationStore().loadByProject(effectiveProjectId, { status: 'active' }),
251
252
  effectiveProjectId,
252
- ) as Array<{ type?: string; id?: number; createdAt?: string; title?: string; entityName?: string; relatedEntities?: string[]; status?: string }>;
253
+ );
253
254
  const nextId = await getObservationStore().loadIdCounter();
254
255
 
255
256
  // Project-scoped graph counts (must match /api/graph and /api/export)
@@ -290,21 +291,18 @@ async function handleApi(
290
291
  filesModified: (o as any).filesModified,
291
292
  }));
292
293
 
293
- // Retention summary
294
- let retentionSummary = { active: 0, stale: 0, archive: 0, immune: 0 };
295
- for (const obs of observations) {
296
- const age = now - new Date((obs as any).createdAt || now).getTime();
297
- const ageHours = age / (1000 * 60 * 60);
298
- const importance = (obs as any).importance ?? 5;
299
- const accessCount = (obs as any).accessCount ?? 0;
300
- const lambda = 0.01;
301
- const score = Math.min(importance * Math.exp(-lambda * ageHours) + Math.min(accessCount * 0.5, 3), 10);
302
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
303
- if (isImmune) retentionSummary.immune++;
304
- if (score >= 3) retentionSummary.active++;
305
- else if (score >= 1) retentionSummary.stale++;
306
- else retentionSummary.archive++;
307
- }
294
+ const retentionReferenceTime = new Date(now);
295
+ const retention = summarizeRetentionProjections(
296
+ observations
297
+ .filter((observation) => observation.type !== 'probe')
298
+ .map((observation) => projectObservationRetention(observation, { referenceTime: retentionReferenceTime })),
299
+ );
300
+ const retentionSummary = {
301
+ active: retention.active,
302
+ stale: retention.stale,
303
+ archive: retention.archiveCandidates,
304
+ immune: retention.immune,
305
+ };
308
306
 
309
307
  // Recent observations (last 10, exclude probe)
310
308
  const sorted = [...observations].filter(o => o.type !== 'probe')
@@ -399,56 +397,38 @@ async function handleApi(
399
397
  const observations = filterDashboardObservations(
400
398
  await getObservationStore().loadByProject(effectiveProjectId, { status: 'active' }),
401
399
  effectiveProjectId,
402
- ) as Array<{
403
- id?: number;
404
- title?: string;
405
- type?: string;
406
- importance?: number;
407
- accessCount?: number;
408
- lastAccessedAt?: string;
409
- createdAt?: string;
410
- entityName?: string;
411
- }>;
412
-
413
- const now = Date.now();
414
- // Exclude probe from retention display -- not durable knowledge
415
- const scored = observations.filter(obs => obs.type !== 'probe').map((obs) => {
416
- const age = now - new Date(obs.createdAt || now).getTime();
417
- const ageHours = age / (1000 * 60 * 60);
418
- const importance = obs.importance ?? 5;
419
- const accessCount = obs.accessCount ?? 0;
420
-
421
- // Exponential decay: score = importance * e^(-λt) + access_bonus
422
- const lambda = 0.01;
423
- const decayScore = importance * Math.exp(-lambda * ageHours);
424
- const accessBonus = Math.min(accessCount * 0.5, 3);
425
- const score = Math.min(decayScore + accessBonus, 10);
426
-
427
- // Immune if importance >= 8 or type is 'gotcha' or 'decision'
428
- const isImmune = importance >= 8 || obs.type === 'gotcha' || obs.type === 'decision';
429
-
430
- return {
431
- id: obs.id,
432
- title: obs.title,
433
- type: obs.type,
434
- entityName: obs.entityName,
435
- score: Math.round(score * 100) / 100,
436
- isImmune,
437
- ageHours: Math.round(ageHours * 10) / 10,
438
- accessCount,
439
- };
440
- });
441
-
442
- // Sort by score descending
443
- scored.sort((a, b) => b.score - a.score);
400
+ );
444
401
 
445
- const activeCount = scored.filter((s) => s.score >= 3).length;
446
- const staleCount = scored.filter((s) => s.score < 3 && s.score >= 1).length;
447
- const archiveCount = scored.filter((s) => s.score < 1).length;
448
- const immuneCount = scored.filter((s) => s.isImmune).length;
402
+ const referenceTime = new Date();
403
+ const rows = observations
404
+ .filter((observation) => observation.type !== 'probe')
405
+ .map((observation) => ({
406
+ observation,
407
+ retention: projectObservationRetention(observation, { referenceTime }),
408
+ }))
409
+ .sort((a, b) => b.retention.displayScore - a.retention.displayScore);
410
+ const summary = summarizeRetentionProjections(rows.map((row) => row.retention));
411
+ const scored = rows.map(({ observation, retention }) => ({
412
+ id: observation.id,
413
+ title: observation.title,
414
+ type: observation.type,
415
+ entityName: observation.entityName,
416
+ score: retention.displayScore,
417
+ isImmune: retention.immune,
418
+ zone: retention.zone,
419
+ ageHours: retention.ageHours,
420
+ accessCount: retention.accessCount,
421
+ effectiveRetentionDays: retention.effectiveRetentionDays,
422
+ immunityReason: retention.immunityReason,
423
+ }));
449
424
 
450
425
  sendJson(res, {
451
- summary: { active: activeCount, stale: staleCount, archive: archiveCount, immune: immuneCount },
426
+ summary: {
427
+ active: summary.active,
428
+ stale: summary.stale,
429
+ archive: summary.archiveCandidates,
430
+ immune: summary.immune,
431
+ },
452
432
  items: scored,
453
433
  });
454
434
  break;