@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.
@@ -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
+ };