@hbarefoot/engram 1.2.0 → 1.4.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.
@@ -26,14 +26,41 @@ const SECRET_COMMAND_PATTERNS = [
26
26
 
27
27
  /**
28
28
  * Detect if shell history exists
29
+ * @param {Object} [options] - Detection options
30
+ * @param {string[]} [options.paths] - Additional directories to scan for shell history
31
+ * @returns {{ found: boolean, path: string|null, paths: string[] }}
29
32
  */
30
- export function detect() {
33
+ export function detect(options = {}) {
34
+ const foundPaths = [];
35
+ const seen = new Set();
36
+
31
37
  for (const loc of HISTORY_LOCATIONS) {
32
- if (fs.existsSync(loc)) {
33
- return { found: true, path: loc };
38
+ const resolved = path.resolve(loc);
39
+ if (!seen.has(resolved) && fs.existsSync(resolved)) {
40
+ seen.add(resolved);
41
+ foundPaths.push(resolved);
42
+ }
43
+ }
44
+
45
+ // Check additional paths for shell history files
46
+ if (options.paths && Array.isArray(options.paths)) {
47
+ for (const dir of options.paths) {
48
+ for (const name of ['.zsh_history', '.bash_history', '.local/share/fish/fish_history']) {
49
+ const loc = path.join(dir, name);
50
+ const resolved = path.resolve(loc);
51
+ if (!seen.has(resolved) && fs.existsSync(resolved)) {
52
+ seen.add(resolved);
53
+ foundPaths.push(resolved);
54
+ }
55
+ }
34
56
  }
35
57
  }
36
- return { found: false, path: null };
58
+
59
+ return {
60
+ found: foundPaths.length > 0,
61
+ path: foundPaths[0] || null,
62
+ paths: foundPaths
63
+ };
37
64
  }
38
65
 
39
66
  /**
@@ -42,43 +69,46 @@ export function detect() {
42
69
  export async function parse(options = {}) {
43
70
  const result = { source: 'shell', memories: [], skipped: [], warnings: [] };
44
71
 
45
- const filePath = options.filePath || (() => {
46
- const detected = detect();
47
- return detected.path;
48
- })();
72
+ // Determine which files to parse
73
+ let filesToParse;
74
+ if (options.filePath) {
75
+ filesToParse = [options.filePath];
76
+ } else {
77
+ const detected = detect(options);
78
+ filesToParse = detected.paths;
79
+ }
49
80
 
50
- if (!filePath || !fs.existsSync(filePath)) {
81
+ if (filesToParse.length === 0) {
51
82
  result.warnings.push('No shell history found');
52
83
  return result;
53
84
  }
54
85
 
55
- const isZsh = filePath.includes('zsh');
56
- const isFish = filePath.includes('fish');
57
- const raw = fs.readFileSync(filePath, 'utf-8');
58
-
59
- // Extract commands from history
60
- const commands = extractCommands(raw, { isZsh, isFish });
61
-
62
- // Count command frequency
86
+ // Merge commands from all history files
63
87
  const frequency = {};
64
88
  const baseCommands = {};
65
89
 
66
- for (const cmd of commands) {
67
- // Skip commands with potential secrets
68
- if (containsSecret(cmd)) {
69
- result.skipped.push({ content: cmd.substring(0, 50), reason: 'Potential secret in command' });
70
- continue;
71
- }
90
+ for (const filePath of filesToParse) {
91
+ if (!fs.existsSync(filePath)) continue;
72
92
 
73
- // Get base command (first word)
74
- const base = cmd.split(/\s+/)[0];
75
- if (!base || base.length < 2) continue;
93
+ const isZsh = filePath.includes('zsh');
94
+ const isFish = filePath.includes('fish');
95
+ const raw = fs.readFileSync(filePath, 'utf-8');
96
+ const commands = extractCommands(raw, { isZsh, isFish });
76
97
 
77
- baseCommands[base] = (baseCommands[base] || 0) + 1;
98
+ for (const cmd of commands) {
99
+ if (containsSecret(cmd)) {
100
+ result.skipped.push({ content: cmd.substring(0, 50), reason: 'Potential secret in command' });
101
+ continue;
102
+ }
78
103
 
79
- // Track full commands for pattern detection (normalize args)
80
- const normalized = normalizeCommand(cmd);
81
- frequency[normalized] = (frequency[normalized] || 0) + 1;
104
+ const base = cmd.split(/\s+/)[0];
105
+ if (!base || base.length < 2) continue;
106
+
107
+ baseCommands[base] = (baseCommands[base] || 0) + 1;
108
+
109
+ const normalized = normalizeCommand(cmd);
110
+ frequency[normalized] = (frequency[normalized] || 0) + 1;
111
+ }
82
112
  }
83
113
 
84
114
  // Top base commands (tools the user uses most)
@@ -6,11 +6,36 @@ const SSH_CONFIG_PATH = path.join(os.homedir(), '.ssh/config');
6
6
 
7
7
  /**
8
8
  * Detect if SSH config exists
9
+ * @param {Object} [options] - Detection options
10
+ * @param {string[]} [options.paths] - Additional directories to scan for SSH config
11
+ * @returns {{ found: boolean, path: string|null, paths: string[] }}
9
12
  */
10
- export function detect() {
13
+ export function detect(options = {}) {
14
+ const foundPaths = [];
15
+ const seen = new Set();
16
+
17
+ const resolved = path.resolve(SSH_CONFIG_PATH);
18
+ if (fs.existsSync(resolved)) {
19
+ seen.add(resolved);
20
+ foundPaths.push(resolved);
21
+ }
22
+
23
+ // Check additional paths for SSH config
24
+ if (options.paths && Array.isArray(options.paths)) {
25
+ for (const dir of options.paths) {
26
+ const loc = path.join(dir, '.ssh/config');
27
+ const r = path.resolve(loc);
28
+ if (!seen.has(r) && fs.existsSync(r)) {
29
+ seen.add(r);
30
+ foundPaths.push(r);
31
+ }
32
+ }
33
+ }
34
+
11
35
  return {
12
- found: fs.existsSync(SSH_CONFIG_PATH),
13
- path: fs.existsSync(SSH_CONFIG_PATH) ? SSH_CONFIG_PATH : null
36
+ found: foundPaths.length > 0,
37
+ path: foundPaths[0] || null,
38
+ paths: foundPaths
14
39
  };
15
40
  }
16
41
 
@@ -21,23 +46,45 @@ export function detect() {
21
46
  export async function parse(options = {}) {
22
47
  const result = { source: 'ssh', memories: [], skipped: [], warnings: [] };
23
48
 
24
- const filePath = options.filePath || SSH_CONFIG_PATH;
49
+ // Determine which files to parse
50
+ let filesToParse;
51
+ if (options.filePath) {
52
+ filesToParse = [options.filePath];
53
+ } else {
54
+ const detected = detect(options);
55
+ filesToParse = detected.paths;
56
+ }
25
57
 
26
- if (!fs.existsSync(filePath)) {
58
+ if (filesToParse.length === 0) {
27
59
  result.warnings.push('No ~/.ssh/config found');
28
60
  return result;
29
61
  }
30
62
 
31
- const content = fs.readFileSync(filePath, 'utf-8');
32
- const hosts = parseSSHConfig(content);
63
+ // Merge hosts from all config files, dedup by host name
64
+ const allHosts = [];
65
+ const seenHostNames = new Set();
66
+
67
+ for (const filePath of filesToParse) {
68
+ if (!fs.existsSync(filePath)) continue;
69
+
70
+ const content = fs.readFileSync(filePath, 'utf-8');
71
+ const hosts = parseSSHConfig(content);
72
+
73
+ for (const host of hosts) {
74
+ if (!seenHostNames.has(host.name)) {
75
+ seenHostNames.add(host.name);
76
+ allHosts.push(host);
77
+ }
78
+ }
79
+ }
33
80
 
34
- if (hosts.length === 0) {
81
+ if (allHosts.length === 0) {
35
82
  result.warnings.push('No SSH hosts found in config');
36
83
  return result;
37
84
  }
38
85
 
39
86
  // Filter out wildcard-only hosts
40
- const namedHosts = hosts.filter(h => h.name !== '*' && !h.name.includes('*'));
87
+ const namedHosts = allHosts.filter(h => h.name !== '*' && !h.name.includes('*'));
41
88
 
42
89
  if (namedHosts.length === 0) {
43
90
  result.warnings.push('Only wildcard SSH hosts found');
@@ -70,7 +117,7 @@ export async function parse(options = {}) {
70
117
  }
71
118
 
72
119
  // Warn about skipped sensitive fields
73
- const skippedCount = hosts.reduce((sum, h) => sum + (h.identityFile ? 1 : 0), 0);
120
+ const skippedCount = allHosts.reduce((sum, h) => sum + (h.identityFile ? 1 : 0), 0);
74
121
  if (skippedCount > 0) {
75
122
  result.warnings.push(`Skipped ${skippedCount} IdentityFile entries (security)`);
76
123
  }
@@ -1,4 +1,5 @@
1
1
  import { createInterface } from 'readline';
2
+ import os from 'os';
2
3
  import { detectSources, scanSources, commitMemories } from './index.js';
3
4
  import { initDatabase } from '../memory/store.js';
4
5
  import { loadConfig, getDatabasePath } from '../config/index.js';
@@ -16,6 +17,7 @@ import chalk from 'chalk';
16
17
  * @param {boolean} [options.dryRun] - Preview without committing
17
18
  * @param {string} [options.namespace] - Override namespace
18
19
  * @param {string} [options.config] - Config file path
20
+ * @param {string[]} [options.paths] - Additional directories to scan
19
21
  */
20
22
  export async function runWizard(options = {}) {
21
23
  const config = loadConfig(options.config);
@@ -42,7 +44,9 @@ export async function runWizard(options = {}) {
42
44
  // Step 1: Detect available sources
43
45
  const spin = spinner('Scanning for sources...');
44
46
  spin.start();
45
- const sources = await detectSources({ cwd: process.cwd() });
47
+ const scanOpts = { cwd: process.cwd() };
48
+ if (options.paths) scanOpts.paths = options.paths;
49
+ const sources = await detectSources(scanOpts);
46
50
  const foundSources = sources.filter(s => s.detected.found);
47
51
  const notFoundSources = sources.filter(s => !s.detected.found);
48
52
 
@@ -57,13 +61,16 @@ export async function runWizard(options = {}) {
57
61
  spin.succeed(`Found ${foundSources.length} source${foundSources.length === 1 ? '' : 's'}`);
58
62
  console.log('');
59
63
 
60
- const srcTable = createTable({ head: ['#', 'Source', 'Description', 'Path'] });
64
+ const srcTable = createTable({ head: ['#', 'Source', 'Description', 'Path(s)'] });
61
65
  foundSources.forEach((s, i) => {
66
+ const pathsDisplay = s.detected.paths && s.detected.paths.length > 1
67
+ ? s.detected.paths.map(p => p.replace(os.homedir(), '~')).join(', ')
68
+ : (s.detected.path || '-').replace(os.homedir(), '~');
62
69
  srcTable.push([
63
70
  String(i + 1),
64
71
  chalk.bold(s.label),
65
72
  s.description,
66
- chalk.dim(s.detected.path || '-')
73
+ chalk.dim(pathsDisplay)
67
74
  ]);
68
75
  });
69
76
  console.log(srcTable.toString());
@@ -99,7 +106,7 @@ export async function runWizard(options = {}) {
99
106
  console.log('');
100
107
  const scanSpin = spinner('Scanning sources...');
101
108
  scanSpin.start();
102
- const scanResult = await scanSources(selectedIds, { cwd: process.cwd() });
109
+ const scanResult = await scanSources(selectedIds, scanOpts);
103
110
  scanSpin.succeed(`Found ${scanResult.memories.length} memories from ${selectedIds.length} source${selectedIds.length === 1 ? '' : 's'}`);
104
111
 
105
112
  if (scanResult.skipped.length > 0) {
@@ -205,7 +212,9 @@ async function runNonInteractive(config, options) {
205
212
 
206
213
  const scanSpin = spinner(`Scanning ${options.source}...`);
207
214
  scanSpin.start();
208
- const scanResult = await scanSources([options.source], { cwd: process.cwd() });
215
+ const nonInteractiveOpts = { cwd: process.cwd() };
216
+ if (options.paths) nonInteractiveOpts.paths = options.paths;
217
+ const scanResult = await scanSources([options.source], nonInteractiveOpts);
209
218
  scanSpin.succeed(`Found ${scanResult.memories.length} memories`);
210
219
 
211
220
  if (scanResult.warnings.length > 0) {
@@ -0,0 +1,259 @@
1
+ import { getStats, getMemoriesWithEmbeddings } from './store.js';
2
+ import { cosineSimilarity } from '../embed/index.js';
3
+ import * as logger from '../utils/logger.js';
4
+
5
+ /**
6
+ * Get analytics overview: totals, date-range counts, average confidence, health inputs
7
+ * @param {Database} db - SQLite database instance
8
+ * @returns {Object} Overview analytics
9
+ */
10
+ export function getOverview(db) {
11
+ const stats = getStats(db);
12
+ const now = Date.now();
13
+ const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
14
+ const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
15
+
16
+ const createdLast7Days = db.prepare(
17
+ 'SELECT COUNT(*) as count FROM memories WHERE created_at >= ?'
18
+ ).get(sevenDaysAgo).count;
19
+
20
+ const createdLast30Days = db.prepare(
21
+ 'SELECT COUNT(*) as count FROM memories WHERE created_at >= ?'
22
+ ).get(thirtyDaysAgo).count;
23
+
24
+ const avgConfidence = db.prepare(
25
+ 'SELECT AVG(confidence) as avg FROM memories'
26
+ ).get().avg || 0;
27
+
28
+ const totalRecalled = db.prepare(
29
+ 'SELECT COUNT(*) as count FROM memories WHERE access_count > 0'
30
+ ).get().count;
31
+
32
+ const accessedLast30Days = db.prepare(
33
+ 'SELECT COUNT(*) as count FROM memories WHERE last_accessed >= ?'
34
+ ).get(thirtyDaysAgo).count;
35
+
36
+ return {
37
+ totalMemories: stats.total,
38
+ byCategory: stats.byCategory,
39
+ byNamespace: stats.byNamespace,
40
+ withEmbeddings: stats.withEmbeddings,
41
+ createdLast7Days,
42
+ createdLast30Days,
43
+ avgConfidence: Math.round(avgConfidence * 100) / 100,
44
+ totalRecalled,
45
+ accessedLast30Days,
46
+ recallRate: stats.total > 0
47
+ ? Math.round((totalRecalled / stats.total) * 100)
48
+ : 0
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Get stale memories (not accessed in N+ days)
54
+ * @param {Database} db
55
+ * @param {number} daysThreshold - Days since last access (default 30)
56
+ * @param {number} limit - Max results (default 50)
57
+ * @returns {Object} { items, count }
58
+ */
59
+ export function getStaleMemories(db, daysThreshold = 30, limit = 50) {
60
+ const now = Date.now();
61
+ const threshold = now - daysThreshold * 24 * 60 * 60 * 1000;
62
+
63
+ // Stale = last_accessed before threshold, OR never accessed AND created before threshold
64
+ const countResult = db.prepare(`
65
+ SELECT COUNT(*) as count FROM memories
66
+ WHERE (last_accessed IS NOT NULL AND last_accessed < ?)
67
+ OR (last_accessed IS NULL AND created_at < ?)
68
+ `).get(threshold, threshold);
69
+
70
+ const rows = db.prepare(`
71
+ SELECT id, content, category, entity, confidence, last_accessed, created_at, access_count
72
+ FROM memories
73
+ WHERE (last_accessed IS NOT NULL AND last_accessed < ?)
74
+ OR (last_accessed IS NULL AND created_at < ?)
75
+ ORDER BY COALESCE(last_accessed, created_at) ASC
76
+ LIMIT ?
77
+ `).all(threshold, threshold, limit);
78
+
79
+ const items = rows.map(row => ({
80
+ id: row.id,
81
+ content: row.content,
82
+ category: row.category,
83
+ entity: row.entity,
84
+ confidence: row.confidence,
85
+ lastAccessed: row.last_accessed,
86
+ accessCount: row.access_count,
87
+ daysSinceAccess: Math.floor(
88
+ (now - (row.last_accessed || row.created_at)) / (1000 * 60 * 60 * 24)
89
+ )
90
+ }));
91
+
92
+ return { items, count: countResult.count };
93
+ }
94
+
95
+ /**
96
+ * Get memories that have never been recalled (access_count = 0)
97
+ * @param {Database} db
98
+ * @param {number} limit
99
+ * @returns {Object} { items, count }
100
+ */
101
+ export function getNeverRecalled(db, limit = 50) {
102
+ const now = Date.now();
103
+
104
+ const countResult = db.prepare(
105
+ 'SELECT COUNT(*) as count FROM memories WHERE access_count = 0'
106
+ ).get();
107
+
108
+ const rows = db.prepare(`
109
+ SELECT id, content, category, entity, confidence, created_at
110
+ FROM memories
111
+ WHERE access_count = 0
112
+ ORDER BY created_at ASC
113
+ LIMIT ?
114
+ `).all(limit);
115
+
116
+ const items = rows.map(row => ({
117
+ id: row.id,
118
+ content: row.content,
119
+ category: row.category,
120
+ entity: row.entity,
121
+ confidence: row.confidence,
122
+ createdAt: row.created_at,
123
+ daysSinceCreation: Math.floor(
124
+ (now - row.created_at) / (1000 * 60 * 60 * 24)
125
+ )
126
+ }));
127
+
128
+ return { items, count: countResult.count };
129
+ }
130
+
131
+ /**
132
+ * Find clusters of duplicate/similar memories
133
+ * Uses cosine similarity on embeddings, threshold 0.85
134
+ * @param {Database} db
135
+ * @param {number} threshold - Similarity threshold (default 0.85)
136
+ * @returns {Object} { clusters, totalDuplicates }
137
+ */
138
+ export function getDuplicateClusters(db, threshold = 0.85) {
139
+ const memories = getMemoriesWithEmbeddings(db);
140
+ const pairs = [];
141
+
142
+ // O(n²) comparison — same pattern as consolidate.js but lower threshold
143
+ for (let i = 0; i < memories.length; i++) {
144
+ for (let j = i + 1; j < memories.length; j++) {
145
+ const memA = memories[i];
146
+ const memB = memories[j];
147
+
148
+ if (memA.namespace !== memB.namespace) continue;
149
+
150
+ const similarity = cosineSimilarity(memA.embedding, memB.embedding);
151
+ if (similarity > threshold) {
152
+ pairs.push({ a: memA.id, b: memB.id, similarity, memA, memB });
153
+ }
154
+ }
155
+ }
156
+
157
+ // Group pairs into clusters using union-find
158
+ const parent = new Map();
159
+ function find(id) {
160
+ if (!parent.has(id)) parent.set(id, id);
161
+ if (parent.get(id) !== id) parent.set(id, find(parent.get(id)));
162
+ return parent.get(id);
163
+ }
164
+ function union(a, b) {
165
+ parent.set(find(a), find(b));
166
+ }
167
+
168
+ const memById = new Map();
169
+ const clusterSimilarity = new Map();
170
+
171
+ for (const { a, b, similarity, memA, memB } of pairs) {
172
+ memById.set(a, memA);
173
+ memById.set(b, memB);
174
+
175
+ const rootA = find(a);
176
+ const rootB = find(b);
177
+ union(a, b);
178
+ const newRoot = find(a);
179
+
180
+ const minSim = Math.min(
181
+ clusterSimilarity.get(rootA) ?? similarity,
182
+ clusterSimilarity.get(rootB) ?? similarity,
183
+ similarity
184
+ );
185
+ clusterSimilarity.set(newRoot, minSim);
186
+ }
187
+
188
+ // Build clusters
189
+ const clusterMap = new Map();
190
+ for (const id of memById.keys()) {
191
+ const root = find(id);
192
+ if (!clusterMap.has(root)) clusterMap.set(root, []);
193
+ clusterMap.get(root).push(id);
194
+ }
195
+
196
+ const clusters = [];
197
+ for (const [root, ids] of clusterMap.entries()) {
198
+ if (ids.length < 2) continue;
199
+ clusters.push({
200
+ memories: ids.map(id => {
201
+ const m = memById.get(id);
202
+ return {
203
+ id: m.id,
204
+ content: m.content,
205
+ category: m.category,
206
+ confidence: m.confidence,
207
+ accessCount: m.access_count
208
+ };
209
+ }),
210
+ similarity: Math.round((clusterSimilarity.get(root) || threshold) * 100) / 100
211
+ });
212
+ }
213
+
214
+ const totalDuplicates = clusters.reduce((sum, c) => sum + c.memories.length - 1, 0);
215
+
216
+ return { clusters, totalDuplicates };
217
+ }
218
+
219
+ /**
220
+ * Get daily trends data for the last N days
221
+ * @param {Database} db
222
+ * @param {number} days - Number of days to look back (default 30)
223
+ * @returns {Object} { daily: [{ date, created, avgConfidence }] }
224
+ */
225
+ export function getTrends(db, days = 30) {
226
+ const now = Date.now();
227
+ const startTime = now - days * 24 * 60 * 60 * 1000;
228
+
229
+ // Get daily creation counts and average confidence
230
+ const rows = db.prepare(`
231
+ SELECT
232
+ date(created_at / 1000, 'unixepoch') as date,
233
+ COUNT(*) as created,
234
+ ROUND(AVG(confidence), 2) as avgConfidence
235
+ FROM memories
236
+ WHERE created_at >= ?
237
+ GROUP BY date(created_at / 1000, 'unixepoch')
238
+ ORDER BY date ASC
239
+ `).all(startTime);
240
+
241
+ // Fill in missing days with zeros
242
+ const daily = [];
243
+ const dateSet = new Map(rows.map(r => [r.date, r]));
244
+ const current = new Date(startTime);
245
+ const end = new Date(now);
246
+
247
+ while (current <= end) {
248
+ const dateStr = current.toISOString().split('T')[0];
249
+ const row = dateSet.get(dateStr);
250
+ daily.push({
251
+ date: dateStr,
252
+ created: row ? row.created : 0,
253
+ avgConfidence: row ? row.avgConfidence : null
254
+ });
255
+ current.setDate(current.getDate() + 1);
256
+ }
257
+
258
+ return { daily };
259
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Calculate a composite memory health score (0-100)
3
+ *
4
+ * Factors and weights (sum to 1.0):
5
+ * - Recall rate: % of memories accessed at least once (0.25)
6
+ * - Freshness: % of memories accessed in last 30 days (0.20)
7
+ * - Confidence: average confidence score (0.20)
8
+ * - Diversity: category distribution entropy (0.15)
9
+ * - Growth: positive trend in memory creation (0.10)
10
+ * - Cleanliness: inverse of duplicate ratio (0.10)
11
+ *
12
+ * @param {Object} overview - From analytics.getOverview()
13
+ * @param {number} duplicateCount - From analytics.getDuplicateClusters().totalDuplicates
14
+ * @param {Object} trends - From analytics.getTrends()
15
+ * @returns {number} Health score 0-100
16
+ */
17
+ export function calculateHealthScore(overview, duplicateCount = 0, trends = null) {
18
+ if (overview.totalMemories === 0) return 0;
19
+
20
+ // 1. Recall rate (0.25): % of memories ever accessed
21
+ const recallRate = overview.totalRecalled / overview.totalMemories;
22
+
23
+ // 2. Freshness (0.20): % of memories accessed in last 30 days
24
+ const freshness = overview.accessedLast30Days / overview.totalMemories;
25
+
26
+ // 3. Confidence avg (0.20): already 0-1
27
+ const confidence = overview.avgConfidence;
28
+
29
+ // 4. Diversity (0.15): Shannon entropy of category distribution, normalized
30
+ const diversity = calculateDiversity(overview.byCategory);
31
+
32
+ // 5. Growth (0.10): whether memories are being actively created
33
+ const growth = calculateGrowth(overview, trends);
34
+
35
+ // 6. Cleanliness (0.10): inverse of duplicate ratio
36
+ const duplicateRatio = duplicateCount / overview.totalMemories;
37
+ const cleanliness = Math.max(0, 1 - duplicateRatio * 5); // 20% duplicates = 0
38
+
39
+ const weightedScore =
40
+ recallRate * 0.25 +
41
+ freshness * 0.20 +
42
+ confidence * 0.20 +
43
+ diversity * 0.15 +
44
+ growth * 0.10 +
45
+ cleanliness * 0.10;
46
+
47
+ return Math.round(Math.min(1, Math.max(0, weightedScore)) * 100);
48
+ }
49
+
50
+ /**
51
+ * Calculate category diversity using normalized Shannon entropy
52
+ * Max entropy = all 5 categories equally distributed
53
+ * @param {Object} byCategory - { preference: n, fact: n, ... }
54
+ * @returns {number} 0-1 diversity score
55
+ */
56
+ function calculateDiversity(byCategory) {
57
+ const counts = Object.values(byCategory);
58
+ const total = counts.reduce((s, c) => s + c, 0);
59
+ if (total === 0 || counts.length <= 1) return 0;
60
+
61
+ let entropy = 0;
62
+ for (const count of counts) {
63
+ if (count === 0) continue;
64
+ const p = count / total;
65
+ entropy -= p * Math.log2(p);
66
+ }
67
+
68
+ // Normalize by max possible entropy (5 categories)
69
+ const maxEntropy = Math.log2(5);
70
+ return entropy / maxEntropy;
71
+ }
72
+
73
+ /**
74
+ * Calculate growth score based on recent creation activity
75
+ * @param {Object} overview
76
+ * @param {Object|null} trends
77
+ * @returns {number} 0-1 growth score
78
+ */
79
+ function calculateGrowth(overview, trends) {
80
+ // If we have trends data, compare recent week vs prior weeks
81
+ if (trends && trends.daily && trends.daily.length >= 14) {
82
+ const daily = trends.daily;
83
+ const recentWeek = daily.slice(-7).reduce((s, d) => s + d.created, 0);
84
+ const priorWeek = daily.slice(-14, -7).reduce((s, d) => s + d.created, 0);
85
+
86
+ if (priorWeek === 0 && recentWeek > 0) return 1;
87
+ if (priorWeek === 0 && recentWeek === 0) return 0.3;
88
+ const ratio = recentWeek / priorWeek;
89
+ return Math.min(1, ratio / 2); // ratio of 2 = max growth score
90
+ }
91
+
92
+ // Fallback: use 7-day vs 30-day ratio
93
+ if (overview.createdLast30Days === 0) return 0;
94
+ const weeklyRate = overview.createdLast7Days / 7;
95
+ const monthlyRate = overview.createdLast30Days / 30;
96
+ if (monthlyRate === 0) return 0;
97
+
98
+ const ratio = weeklyRate / monthlyRate;
99
+ return Math.min(1, ratio / 2);
100
+ }