@hbarefoot/engram 1.0.0 → 1.2.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 +174 -425
- package/bin/engram.js +275 -90
- package/dashboard/dist/assets/index-D0xT6oKC.css +1 -0
- package/dashboard/dist/assets/index-D3bysGhj.js +45 -0
- package/dashboard/dist/engram-logo.png +0 -0
- package/dashboard/dist/favicon.png +0 -0
- package/dashboard/dist/index.html +3 -3
- package/package.json +19 -10
- package/src/embed/index.js +116 -16
- package/src/extract/secrets.js +1 -1
- package/src/import/index.js +259 -0
- package/src/import/parsers/claude.js +208 -0
- package/src/import/parsers/cursorrules.js +153 -0
- package/src/import/parsers/env.js +129 -0
- package/src/import/parsers/git.js +194 -0
- package/src/import/parsers/obsidian.js +219 -0
- package/src/import/parsers/package.js +177 -0
- package/src/import/parsers/shell.js +256 -0
- package/src/import/parsers/ssh.js +132 -0
- package/src/import/wizard.js +280 -0
- package/src/server/mcp.js +5 -1
- package/src/server/rest.js +152 -1
- package/src/utils/format.js +224 -0
- package/dashboard/dist/assets/index-BHkLa5w_.css +0 -1
- package/dashboard/dist/assets/index-D9QR_Cnu.js +0 -45
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { validateContent } from '../../extract/secrets.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Common Obsidian vault locations
|
|
8
|
+
*/
|
|
9
|
+
const VAULT_SEARCH_DIRS = [
|
|
10
|
+
os.homedir(),
|
|
11
|
+
path.join(os.homedir(), 'Documents'),
|
|
12
|
+
path.join(os.homedir(), 'Notes'),
|
|
13
|
+
path.join(os.homedir(), 'Obsidian')
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect Obsidian vaults by looking for .obsidian directories
|
|
18
|
+
*/
|
|
19
|
+
export function detect(options = {}) {
|
|
20
|
+
const searchDirs = options.vaultPaths || VAULT_SEARCH_DIRS;
|
|
21
|
+
const vaults = [];
|
|
22
|
+
|
|
23
|
+
for (const dir of searchDirs) {
|
|
24
|
+
if (!fs.existsSync(dir)) continue;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (!entry.isDirectory()) continue;
|
|
30
|
+
const obsidianDir = path.join(dir, entry.name, '.obsidian');
|
|
31
|
+
if (fs.existsSync(obsidianDir)) {
|
|
32
|
+
vaults.push(path.join(dir, entry.name));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Permission denied or other error, skip
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { found: vaults.length > 0, path: vaults[0] || null, vaults };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse Obsidian vaults for notes tagged with #engram or in a specific folder
|
|
45
|
+
*/
|
|
46
|
+
export async function parse(options = {}) {
|
|
47
|
+
const result = { source: 'obsidian', memories: [], skipped: [], warnings: [] };
|
|
48
|
+
|
|
49
|
+
const detection = detect(options);
|
|
50
|
+
const vaults = options.vaultPaths || detection.vaults || [];
|
|
51
|
+
|
|
52
|
+
if (vaults.length === 0) {
|
|
53
|
+
result.warnings.push('No Obsidian vaults found');
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tag = options.tag || '#engram';
|
|
58
|
+
const folder = options.folder || 'engram';
|
|
59
|
+
const maxNotes = options.maxNotes || 50;
|
|
60
|
+
|
|
61
|
+
let notesProcessed = 0;
|
|
62
|
+
|
|
63
|
+
for (const vaultPath of vaults) {
|
|
64
|
+
if (notesProcessed >= maxNotes) break;
|
|
65
|
+
|
|
66
|
+
// Look for notes with #engram tag
|
|
67
|
+
const taggedNotes = findTaggedNotes(vaultPath, tag, maxNotes - notesProcessed);
|
|
68
|
+
for (const note of taggedNotes) {
|
|
69
|
+
processNote(note, result);
|
|
70
|
+
notesProcessed++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Look for notes in engram/ folder
|
|
74
|
+
const folderPath = path.join(vaultPath, folder);
|
|
75
|
+
if (fs.existsSync(folderPath)) {
|
|
76
|
+
const folderNotes = findNotesInFolder(folderPath, maxNotes - notesProcessed);
|
|
77
|
+
for (const note of folderNotes) {
|
|
78
|
+
processNote(note, result);
|
|
79
|
+
notesProcessed++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (result.memories.length === 0) {
|
|
85
|
+
result.warnings.push(`No notes found with tag "${tag}" or in "${folder}/" folder`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find markdown files containing a specific tag
|
|
93
|
+
*/
|
|
94
|
+
function findTaggedNotes(vaultPath, tag, maxResults) {
|
|
95
|
+
const notes = [];
|
|
96
|
+
const tagPattern = tag.startsWith('#') ? tag : `#${tag}`;
|
|
97
|
+
|
|
98
|
+
function walk(dir, depth = 0) {
|
|
99
|
+
if (depth > 5 || notes.length >= maxResults) return;
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (notes.length >= maxResults) break;
|
|
105
|
+
|
|
106
|
+
const fullPath = path.join(dir, entry.name);
|
|
107
|
+
|
|
108
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
109
|
+
walk(fullPath, depth + 1);
|
|
110
|
+
} else if (entry.name.endsWith('.md')) {
|
|
111
|
+
try {
|
|
112
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
113
|
+
if (content.includes(tagPattern)) {
|
|
114
|
+
notes.push({ path: fullPath, content, name: entry.name });
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Skip unreadable files
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Skip unreadable directories
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
walk(vaultPath);
|
|
127
|
+
return notes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find markdown files in a specific folder
|
|
132
|
+
*/
|
|
133
|
+
function findNotesInFolder(folderPath, maxResults) {
|
|
134
|
+
const notes = [];
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const entries = fs.readdirSync(folderPath, { withFileTypes: true });
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
if (notes.length >= maxResults) break;
|
|
140
|
+
|
|
141
|
+
if (entry.name.endsWith('.md')) {
|
|
142
|
+
const fullPath = path.join(folderPath, entry.name);
|
|
143
|
+
try {
|
|
144
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
145
|
+
notes.push({ path: fullPath, content, name: entry.name });
|
|
146
|
+
} catch {
|
|
147
|
+
// Skip unreadable files
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Skip unreadable directories
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return notes;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Process a single note into memory candidates
|
|
160
|
+
*/
|
|
161
|
+
function processNote(note, result) {
|
|
162
|
+
const lines = note.content.split('\n');
|
|
163
|
+
let currentHeading = path.basename(note.name, '.md');
|
|
164
|
+
|
|
165
|
+
for (const rawLine of lines) {
|
|
166
|
+
const line = rawLine.trim();
|
|
167
|
+
|
|
168
|
+
// Track headings for context
|
|
169
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
170
|
+
currentHeading = line.replace(/^#{1,3}\s+/, '').trim();
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Skip empty, tags-only, or very short lines
|
|
175
|
+
if (!line || line === '---' || line.startsWith('```')) continue;
|
|
176
|
+
|
|
177
|
+
// Strip markdown formatting
|
|
178
|
+
const cleaned = line
|
|
179
|
+
.replace(/^[-*]\s+/, '')
|
|
180
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Inline links
|
|
181
|
+
.replace(/[*_`~]/g, '') // Bold, italic, code
|
|
182
|
+
.replace(/#\w+/g, '') // Tags
|
|
183
|
+
.trim();
|
|
184
|
+
|
|
185
|
+
if (cleaned.length < 15) continue;
|
|
186
|
+
|
|
187
|
+
// Validate for secrets
|
|
188
|
+
const validation = validateContent(cleaned, { autoRedact: false });
|
|
189
|
+
if (!validation.valid) {
|
|
190
|
+
result.skipped.push({ content: cleaned.substring(0, 50), reason: 'Contains secrets' });
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Determine category from content
|
|
195
|
+
let category = 'fact';
|
|
196
|
+
if (/prefer|like|dislike|always use|never use/i.test(cleaned)) {
|
|
197
|
+
category = 'preference';
|
|
198
|
+
} else if (/decided|chose|switched/i.test(cleaned)) {
|
|
199
|
+
category = 'decision';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
result.memories.push({
|
|
203
|
+
content: `From Obsidian (${currentHeading}): ${cleaned}`,
|
|
204
|
+
category,
|
|
205
|
+
entity: null,
|
|
206
|
+
confidence: 0.75,
|
|
207
|
+
tags: ['obsidian', 'notes'],
|
|
208
|
+
source: 'import:obsidian'
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const meta = {
|
|
214
|
+
name: 'obsidian',
|
|
215
|
+
label: 'Obsidian vaults',
|
|
216
|
+
description: 'Notes tagged with #engram or in engram/ folder',
|
|
217
|
+
category: 'fact',
|
|
218
|
+
locations: ['~/Documents/*/engram/', '~/*/.obsidian']
|
|
219
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detect if package.json exists
|
|
6
|
+
*/
|
|
7
|
+
export function detect(options = {}) {
|
|
8
|
+
const cwd = options.cwd || process.cwd();
|
|
9
|
+
const filePath = path.resolve(cwd, 'package.json');
|
|
10
|
+
return { found: fs.existsSync(filePath), path: fs.existsSync(filePath) ? filePath : null };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse package.json into memory candidates
|
|
15
|
+
*/
|
|
16
|
+
export async function parse(options = {}) {
|
|
17
|
+
const result = { source: 'package', memories: [], skipped: [], warnings: [] };
|
|
18
|
+
const cwd = options.cwd || process.cwd();
|
|
19
|
+
const filePath = options.filePath || path.resolve(cwd, 'package.json');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(filePath)) {
|
|
22
|
+
result.warnings.push('No package.json found');
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let pkg;
|
|
27
|
+
try {
|
|
28
|
+
pkg = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
29
|
+
} catch {
|
|
30
|
+
result.warnings.push('Failed to parse package.json');
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Project name and description
|
|
35
|
+
if (pkg.name) {
|
|
36
|
+
const desc = pkg.description ? ` — ${pkg.description}` : '';
|
|
37
|
+
result.memories.push({
|
|
38
|
+
content: `Project "${pkg.name}" (v${pkg.version || '0.0.0'})${desc}`,
|
|
39
|
+
category: 'fact',
|
|
40
|
+
entity: pkg.name,
|
|
41
|
+
confidence: 1.0,
|
|
42
|
+
tags: ['package-json', 'project-info'],
|
|
43
|
+
source: 'import:package'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Module type
|
|
48
|
+
if (pkg.type) {
|
|
49
|
+
result.memories.push({
|
|
50
|
+
content: `Project uses ${pkg.type === 'module' ? 'ESM (ES modules)' : 'CommonJS'} module system`,
|
|
51
|
+
category: 'fact',
|
|
52
|
+
entity: 'modules',
|
|
53
|
+
confidence: 1.0,
|
|
54
|
+
tags: ['package-json', 'module-system'],
|
|
55
|
+
source: 'import:package'
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Engines
|
|
60
|
+
if (pkg.engines) {
|
|
61
|
+
const engines = Object.entries(pkg.engines)
|
|
62
|
+
.map(([name, version]) => `${name} ${version}`)
|
|
63
|
+
.join(', ');
|
|
64
|
+
result.memories.push({
|
|
65
|
+
content: `Project requires: ${engines}`,
|
|
66
|
+
category: 'fact',
|
|
67
|
+
entity: 'runtime',
|
|
68
|
+
confidence: 1.0,
|
|
69
|
+
tags: ['package-json', 'engines'],
|
|
70
|
+
source: 'import:package'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Scripts (key developer commands)
|
|
75
|
+
if (pkg.scripts) {
|
|
76
|
+
const importantScripts = ['start', 'dev', 'build', 'test', 'lint', 'deploy'];
|
|
77
|
+
const relevantScripts = Object.entries(pkg.scripts)
|
|
78
|
+
.filter(([key]) => importantScripts.some(s => key.includes(s)));
|
|
79
|
+
|
|
80
|
+
if (relevantScripts.length > 0) {
|
|
81
|
+
const scriptList = relevantScripts
|
|
82
|
+
.map(([key, cmd]) => `"${key}": ${cmd}`)
|
|
83
|
+
.join('; ');
|
|
84
|
+
result.memories.push({
|
|
85
|
+
content: `Project npm scripts: ${scriptList}`,
|
|
86
|
+
category: 'fact',
|
|
87
|
+
entity: 'npm',
|
|
88
|
+
confidence: 0.95,
|
|
89
|
+
tags: ['package-json', 'scripts'],
|
|
90
|
+
source: 'import:package'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Key dependencies (frameworks, databases, etc.)
|
|
96
|
+
const allDeps = {
|
|
97
|
+
...pkg.dependencies,
|
|
98
|
+
...pkg.devDependencies
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (Object.keys(allDeps).length > 0) {
|
|
102
|
+
// Categorize notable dependencies
|
|
103
|
+
const frameworks = [];
|
|
104
|
+
const databases = [];
|
|
105
|
+
const tools = [];
|
|
106
|
+
const testing = [];
|
|
107
|
+
|
|
108
|
+
const categories = {
|
|
109
|
+
frameworks: ['react', 'vue', 'angular', 'svelte', 'next', 'nuxt', 'express', 'fastify', 'koa', 'hapi', 'nest', 'electron', 'tauri'],
|
|
110
|
+
databases: ['better-sqlite3', 'sqlite3', 'pg', 'mysql2', 'mongoose', 'prisma', 'sequelize', 'typeorm', 'drizzle', 'redis', 'ioredis'],
|
|
111
|
+
tools: ['typescript', 'vite', 'webpack', 'rollup', 'esbuild', 'tailwindcss', 'eslint', 'prettier', 'commander', 'inquirer'],
|
|
112
|
+
testing: ['vitest', 'jest', 'mocha', 'chai', 'cypress', 'playwright']
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
for (const dep of Object.keys(allDeps)) {
|
|
116
|
+
const base = dep.replace(/^@[^/]+\//, '');
|
|
117
|
+
if (categories.frameworks.some(f => base.includes(f))) frameworks.push(dep);
|
|
118
|
+
else if (categories.databases.some(d => base.includes(d))) databases.push(dep);
|
|
119
|
+
else if (categories.testing.some(t => base.includes(t))) testing.push(dep);
|
|
120
|
+
else if (categories.tools.some(t => base.includes(t))) tools.push(dep);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (frameworks.length > 0) {
|
|
124
|
+
result.memories.push({
|
|
125
|
+
content: `Project frameworks/libraries: ${frameworks.join(', ')}`,
|
|
126
|
+
category: 'fact',
|
|
127
|
+
entity: 'tech-stack',
|
|
128
|
+
confidence: 1.0,
|
|
129
|
+
tags: ['package-json', 'frameworks'],
|
|
130
|
+
source: 'import:package'
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (databases.length > 0) {
|
|
135
|
+
result.memories.push({
|
|
136
|
+
content: `Project database dependencies: ${databases.join(', ')}`,
|
|
137
|
+
category: 'fact',
|
|
138
|
+
entity: 'database',
|
|
139
|
+
confidence: 1.0,
|
|
140
|
+
tags: ['package-json', 'databases'],
|
|
141
|
+
source: 'import:package'
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (testing.length > 0) {
|
|
146
|
+
result.memories.push({
|
|
147
|
+
content: `Project testing tools: ${testing.join(', ')}`,
|
|
148
|
+
category: 'fact',
|
|
149
|
+
entity: 'testing',
|
|
150
|
+
confidence: 1.0,
|
|
151
|
+
tags: ['package-json', 'testing'],
|
|
152
|
+
source: 'import:package'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (tools.length > 0) {
|
|
157
|
+
result.memories.push({
|
|
158
|
+
content: `Project dev tools: ${tools.join(', ')}`,
|
|
159
|
+
category: 'fact',
|
|
160
|
+
entity: 'tooling',
|
|
161
|
+
confidence: 1.0,
|
|
162
|
+
tags: ['package-json', 'dev-tools'],
|
|
163
|
+
source: 'import:package'
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const meta = {
|
|
172
|
+
name: 'package',
|
|
173
|
+
label: 'package.json',
|
|
174
|
+
description: 'Tech stack, scripts, and dependencies',
|
|
175
|
+
category: 'fact',
|
|
176
|
+
locations: ['package.json']
|
|
177
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { detectSecrets } from '../../extract/secrets.js';
|
|
5
|
+
|
|
6
|
+
const HISTORY_LOCATIONS = [
|
|
7
|
+
path.join(os.homedir(), '.zsh_history'),
|
|
8
|
+
path.join(os.homedir(), '.bash_history'),
|
|
9
|
+
path.join(os.homedir(), '.local/share/fish/fish_history')
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Commands that likely contain secrets and should be skipped
|
|
14
|
+
*/
|
|
15
|
+
const SECRET_COMMAND_PATTERNS = [
|
|
16
|
+
/export\s+\w*(KEY|SECRET|TOKEN|PASSWORD|PASS|API|AUTH)/i,
|
|
17
|
+
/curl.*(-H|--header)\s+['"]?(Authorization|X-Api-Key|Bearer)/i,
|
|
18
|
+
/curl.*(-u|--user)\s+/i,
|
|
19
|
+
/mysql.*-p/,
|
|
20
|
+
/psql.*password/i,
|
|
21
|
+
/echo\s+['"]?(sk-|pk_|ghp_|AKIA)/i,
|
|
22
|
+
/aws\s+configure/i,
|
|
23
|
+
/login.*--password/i,
|
|
24
|
+
/--token\s+\S+/i
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect if shell history exists
|
|
29
|
+
*/
|
|
30
|
+
export function detect() {
|
|
31
|
+
for (const loc of HISTORY_LOCATIONS) {
|
|
32
|
+
if (fs.existsSync(loc)) {
|
|
33
|
+
return { found: true, path: loc };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { found: false, path: null };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse shell history to extract command patterns
|
|
41
|
+
*/
|
|
42
|
+
export async function parse(options = {}) {
|
|
43
|
+
const result = { source: 'shell', memories: [], skipped: [], warnings: [] };
|
|
44
|
+
|
|
45
|
+
const filePath = options.filePath || (() => {
|
|
46
|
+
const detected = detect();
|
|
47
|
+
return detected.path;
|
|
48
|
+
})();
|
|
49
|
+
|
|
50
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
51
|
+
result.warnings.push('No shell history found');
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
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
|
|
63
|
+
const frequency = {};
|
|
64
|
+
const baseCommands = {};
|
|
65
|
+
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get base command (first word)
|
|
74
|
+
const base = cmd.split(/\s+/)[0];
|
|
75
|
+
if (!base || base.length < 2) continue;
|
|
76
|
+
|
|
77
|
+
baseCommands[base] = (baseCommands[base] || 0) + 1;
|
|
78
|
+
|
|
79
|
+
// Track full commands for pattern detection (normalize args)
|
|
80
|
+
const normalized = normalizeCommand(cmd);
|
|
81
|
+
frequency[normalized] = (frequency[normalized] || 0) + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Top base commands (tools the user uses most)
|
|
85
|
+
const topCommands = Object.entries(baseCommands)
|
|
86
|
+
.sort((a, b) => b[1] - a[1])
|
|
87
|
+
.filter(([cmd]) => !isBoringCommand(cmd))
|
|
88
|
+
.slice(0, 15);
|
|
89
|
+
|
|
90
|
+
if (topCommands.length > 0) {
|
|
91
|
+
const cmdStr = topCommands
|
|
92
|
+
.map(([cmd, count]) => `${cmd} (${count}x)`)
|
|
93
|
+
.join(', ');
|
|
94
|
+
|
|
95
|
+
result.memories.push({
|
|
96
|
+
content: `Most used shell commands: ${cmdStr}`,
|
|
97
|
+
category: 'pattern',
|
|
98
|
+
entity: 'shell',
|
|
99
|
+
confidence: 0.8,
|
|
100
|
+
tags: ['shell-history', 'command-patterns'],
|
|
101
|
+
source: 'import:shell'
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Detect frequently used tool patterns
|
|
106
|
+
const patterns = detectPatterns(frequency);
|
|
107
|
+
for (const pattern of patterns) {
|
|
108
|
+
result.memories.push({
|
|
109
|
+
content: pattern.description,
|
|
110
|
+
category: 'pattern',
|
|
111
|
+
entity: pattern.entity,
|
|
112
|
+
confidence: pattern.confidence,
|
|
113
|
+
tags: ['shell-history', 'workflow-patterns'],
|
|
114
|
+
source: 'import:shell'
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Detect package manager preference
|
|
119
|
+
const pmCounts = {
|
|
120
|
+
npm: baseCommands['npm'] || 0,
|
|
121
|
+
yarn: baseCommands['yarn'] || 0,
|
|
122
|
+
pnpm: baseCommands['pnpm'] || 0,
|
|
123
|
+
bun: baseCommands['bun'] || 0
|
|
124
|
+
};
|
|
125
|
+
const topPM = Object.entries(pmCounts).sort((a, b) => b[1] - a[1])[0];
|
|
126
|
+
if (topPM && topPM[1] > 5) {
|
|
127
|
+
result.memories.push({
|
|
128
|
+
content: `Preferred package manager: ${topPM[0]} (used ${topPM[1]} times in history)`,
|
|
129
|
+
category: 'preference',
|
|
130
|
+
entity: topPM[0],
|
|
131
|
+
confidence: 0.75,
|
|
132
|
+
tags: ['shell-history', 'package-manager'],
|
|
133
|
+
source: 'import:shell'
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extract clean commands from history file content
|
|
142
|
+
*/
|
|
143
|
+
function extractCommands(raw, { isZsh, isFish }) {
|
|
144
|
+
const lines = raw.split('\n');
|
|
145
|
+
const commands = [];
|
|
146
|
+
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
let cmd;
|
|
149
|
+
|
|
150
|
+
if (isZsh) {
|
|
151
|
+
// Zsh history format: ": timestamp:0;command"
|
|
152
|
+
const match = line.match(/^:\s*\d+:\d+;(.+)/);
|
|
153
|
+
cmd = match ? match[1] : line;
|
|
154
|
+
} else if (isFish) {
|
|
155
|
+
// Fish history format: "- cmd: command"
|
|
156
|
+
const match = line.match(/^- cmd:\s*(.+)/);
|
|
157
|
+
if (!match) continue;
|
|
158
|
+
cmd = match[1];
|
|
159
|
+
} else {
|
|
160
|
+
cmd = line;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cmd = cmd.trim();
|
|
164
|
+
if (cmd && cmd.length > 3 && cmd.length < 500) {
|
|
165
|
+
commands.push(cmd);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return commands;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if a command likely contains secrets
|
|
174
|
+
*/
|
|
175
|
+
function containsSecret(cmd) {
|
|
176
|
+
for (const pattern of SECRET_COMMAND_PATTERNS) {
|
|
177
|
+
if (pattern.test(cmd)) return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const detection = detectSecrets(cmd);
|
|
181
|
+
return detection.hasSecrets;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Normalize a command for frequency counting
|
|
186
|
+
*/
|
|
187
|
+
function normalizeCommand(cmd) {
|
|
188
|
+
// Replace specific paths/args with placeholders
|
|
189
|
+
return cmd
|
|
190
|
+
.replace(/['"]/g, '')
|
|
191
|
+
.replace(/\s+\S*\/\S+/g, ' <path>')
|
|
192
|
+
.replace(/\s+-\w\s+\S+/g, ' -<flag> <arg>')
|
|
193
|
+
.split(/\s+/)
|
|
194
|
+
.slice(0, 3)
|
|
195
|
+
.join(' ')
|
|
196
|
+
.trim();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Check if a command is too generic to be interesting
|
|
201
|
+
*/
|
|
202
|
+
function isBoringCommand(cmd) {
|
|
203
|
+
const boring = ['ls', 'cd', 'pwd', 'echo', 'cat', 'clear', 'exit', 'history',
|
|
204
|
+
'which', 'whoami', 'date', 'cal', 'man', 'true', 'false', 'test'];
|
|
205
|
+
return boring.includes(cmd);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Detect interesting workflow patterns from command frequency
|
|
210
|
+
*/
|
|
211
|
+
function detectPatterns(frequency) {
|
|
212
|
+
const patterns = [];
|
|
213
|
+
|
|
214
|
+
const dockerCount = Object.entries(frequency)
|
|
215
|
+
.filter(([cmd]) => cmd.startsWith('docker'))
|
|
216
|
+
.reduce((sum, [, c]) => sum + c, 0);
|
|
217
|
+
if (dockerCount > 10) {
|
|
218
|
+
patterns.push({
|
|
219
|
+
description: `Uses Docker frequently (${dockerCount} commands in history)`,
|
|
220
|
+
entity: 'docker',
|
|
221
|
+
confidence: 0.8
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const gitCount = Object.entries(frequency)
|
|
226
|
+
.filter(([cmd]) => cmd.startsWith('git'))
|
|
227
|
+
.reduce((sum, [, c]) => sum + c, 0);
|
|
228
|
+
if (gitCount > 20) {
|
|
229
|
+
patterns.push({
|
|
230
|
+
description: `Heavy git user (${gitCount} commands in history)`,
|
|
231
|
+
entity: 'git',
|
|
232
|
+
confidence: 0.8
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sshCount = Object.entries(frequency)
|
|
237
|
+
.filter(([cmd]) => cmd.startsWith('ssh'))
|
|
238
|
+
.reduce((sum, [, c]) => sum + c, 0);
|
|
239
|
+
if (sshCount > 5) {
|
|
240
|
+
patterns.push({
|
|
241
|
+
description: `Regularly uses SSH for remote access (${sshCount} connections)`,
|
|
242
|
+
entity: 'ssh',
|
|
243
|
+
confidence: 0.75
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return patterns;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export const meta = {
|
|
251
|
+
name: 'shell',
|
|
252
|
+
label: 'Shell history',
|
|
253
|
+
description: 'Frequent commands and workflow patterns',
|
|
254
|
+
category: 'pattern',
|
|
255
|
+
locations: HISTORY_LOCATIONS.map(p => p.replace(os.homedir(), '~'))
|
|
256
|
+
};
|