@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.
- package/README.md +1 -1
- package/bin/engram.js +4 -1
- package/dashboard/dist/assets/index-C0aJJ5-D.js +109 -0
- package/dashboard/dist/assets/index-DFWnnKIv.css +1 -0
- package/dashboard/dist/index.html +2 -2
- package/dashboard/package.json +2 -1
- package/package.json +1 -1
- package/src/import/parsers/claude.js +69 -17
- package/src/import/parsers/cursorrules.js +55 -16
- package/src/import/parsers/env.js +52 -11
- package/src/import/parsers/git.js +54 -11
- package/src/import/parsers/obsidian.js +17 -2
- package/src/import/parsers/package.js +55 -8
- package/src/import/parsers/shell.js +60 -30
- package/src/import/parsers/ssh.js +57 -10
- package/src/import/wizard.js +14 -5
- package/src/memory/analytics.js +259 -0
- package/src/memory/health.js +100 -0
- package/src/server/rest.js +121 -5
- package/dashboard/dist/assets/index-D0xT6oKC.css +0 -1
- package/dashboard/dist/assets/index-D3bysGhj.js +0 -45
|
@@ -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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
81
|
+
if (filesToParse.length === 0) {
|
|
51
82
|
result.warnings.push('No shell history found');
|
|
52
83
|
return result;
|
|
53
84
|
}
|
|
54
85
|
|
|
55
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
13
|
-
path:
|
|
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
|
-
|
|
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 (
|
|
58
|
+
if (filesToParse.length === 0) {
|
|
27
59
|
result.warnings.push('No ~/.ssh/config found');
|
|
28
60
|
return result;
|
|
29
61
|
}
|
|
30
62
|
|
|
31
|
-
|
|
32
|
-
const
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
}
|
package/src/import/wizard.js
CHANGED
|
@@ -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
|
|
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(
|
|
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,
|
|
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
|
|
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
|
+
}
|