@hbarefoot/engram 1.1.0 → 1.3.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,260 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { validateContent } from '../../extract/secrets.js';
5
+
6
+ const CLAUDE_LOCATIONS = [
7
+ '.claude',
8
+ 'CLAUDE.md',
9
+ '.claude/settings.json',
10
+ '.claude/commands'
11
+ ];
12
+
13
+ /**
14
+ * Detect if .claude project files exist
15
+ * @param {Object} [options] - Detection options
16
+ * @param {string} [options.cwd] - Working directory to scan
17
+ * @param {string[]} [options.paths] - Additional directories to scan
18
+ * @returns {{ found: boolean, path: string|null, paths: string[] }}
19
+ */
20
+ export function detect(options = {}) {
21
+ const cwd = options.cwd || process.cwd();
22
+ const foundPaths = [];
23
+ const seen = new Set();
24
+
25
+ // Check cwd
26
+ for (const loc of CLAUDE_LOCATIONS) {
27
+ const fullPath = path.resolve(cwd, loc);
28
+ if (fs.existsSync(fullPath)) {
29
+ const dir = path.resolve(cwd);
30
+ if (!seen.has(dir)) {
31
+ seen.add(dir);
32
+ foundPaths.push(dir);
33
+ }
34
+ break;
35
+ }
36
+ }
37
+
38
+ // Always check home directory for user-level .claude
39
+ const homeDir = os.homedir();
40
+ for (const loc of CLAUDE_LOCATIONS) {
41
+ const fullPath = path.resolve(homeDir, loc);
42
+ if (fs.existsSync(fullPath)) {
43
+ const dir = path.resolve(homeDir);
44
+ if (!seen.has(dir)) {
45
+ seen.add(dir);
46
+ foundPaths.push(dir);
47
+ }
48
+ break;
49
+ }
50
+ }
51
+
52
+ // Check additional paths
53
+ if (options.paths && Array.isArray(options.paths)) {
54
+ for (const extraDir of options.paths) {
55
+ for (const loc of CLAUDE_LOCATIONS) {
56
+ const fullPath = path.resolve(extraDir, loc);
57
+ if (fs.existsSync(fullPath)) {
58
+ const dir = path.resolve(extraDir);
59
+ if (!seen.has(dir)) {
60
+ seen.add(dir);
61
+ foundPaths.push(dir);
62
+ }
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ return {
70
+ found: foundPaths.length > 0,
71
+ path: foundPaths[0] || null,
72
+ paths: foundPaths
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Parse .claude project files into memory candidates
78
+ * Scans cwd, home directory, and any additional paths
79
+ */
80
+ export async function parse(options = {}) {
81
+ const result = { source: 'claude', memories: [], skipped: [], warnings: [] };
82
+ const detected = detect(options);
83
+ const dirsToScan = detected.paths.length > 0 ? detected.paths : [options.cwd || process.cwd()];
84
+
85
+ for (const dir of dirsToScan) {
86
+ // Parse CLAUDE.md
87
+ const claudeMdPath = path.resolve(dir, 'CLAUDE.md');
88
+ if (fs.existsSync(claudeMdPath)) {
89
+ parseClaudeMd(claudeMdPath, result);
90
+ }
91
+
92
+ // Parse .claude/settings.json
93
+ const settingsPath = path.resolve(dir, '.claude/settings.json');
94
+ if (fs.existsSync(settingsPath)) {
95
+ parseClaudeSettings(settingsPath, result);
96
+ }
97
+
98
+ // Parse .claude/commands directory
99
+ const commandsPath = path.resolve(dir, '.claude/commands');
100
+ if (fs.existsSync(commandsPath) && fs.statSync(commandsPath).isDirectory()) {
101
+ parseClaudeCommands(commandsPath, result);
102
+ }
103
+ }
104
+
105
+ if (result.memories.length === 0) {
106
+ result.warnings.push('No .claude project files found or no extractable content');
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Parse CLAUDE.md into structured memories
114
+ */
115
+ function parseClaudeMd(filePath, result) {
116
+ const content = fs.readFileSync(filePath, 'utf-8');
117
+ const lines = content.split('\n');
118
+
119
+ let currentSection = null;
120
+ let currentBlock = [];
121
+
122
+ for (const rawLine of lines) {
123
+ const line = rawLine.trim();
124
+
125
+ // Section headers
126
+ if (/^#{1,3}\s+/.test(line)) {
127
+ if (currentBlock.length > 0) {
128
+ flushClaudeBlock(currentBlock, currentSection, result);
129
+ currentBlock = [];
130
+ }
131
+ currentSection = line.replace(/^#{1,3}\s+/, '').trim();
132
+ continue;
133
+ }
134
+
135
+ // Skip empty lines
136
+ if (!line) {
137
+ if (currentBlock.length > 0) {
138
+ flushClaudeBlock(currentBlock, currentSection, result);
139
+ currentBlock = [];
140
+ }
141
+ continue;
142
+ }
143
+
144
+ // Skip code blocks (fences)
145
+ if (line.startsWith('```')) continue;
146
+
147
+ // Collect content
148
+ const cleaned = line.replace(/^[-*]\s+/, '').trim();
149
+ if (cleaned.length > 15) {
150
+ currentBlock.push(cleaned);
151
+ }
152
+ }
153
+
154
+ if (currentBlock.length > 0) {
155
+ flushClaudeBlock(currentBlock, currentSection, result);
156
+ }
157
+ }
158
+
159
+ function flushClaudeBlock(lines, section, result) {
160
+ // Group related lines into single memories when they're short
161
+ const grouped = [];
162
+ let current = [];
163
+
164
+ for (const line of lines) {
165
+ if (line.length > 100) {
166
+ // Long lines become individual memories
167
+ if (current.length > 0) {
168
+ grouped.push(current.join('. '));
169
+ current = [];
170
+ }
171
+ grouped.push(line);
172
+ } else {
173
+ current.push(line);
174
+ if (current.join('. ').length > 150) {
175
+ grouped.push(current.join('. '));
176
+ current = [];
177
+ }
178
+ }
179
+ }
180
+ if (current.length > 0) grouped.push(current.join('. '));
181
+
182
+ for (const text of grouped) {
183
+ const validation = validateContent(text, { autoRedact: false });
184
+ if (!validation.valid) {
185
+ result.skipped.push({ content: text, reason: 'Contains secrets' });
186
+ result.warnings.push('Skipped CLAUDE.md section containing sensitive data');
187
+ continue;
188
+ }
189
+
190
+ const content = section
191
+ ? `Claude project context (${section}): ${text}`
192
+ : `Claude project context: ${text}`;
193
+
194
+ if (content.length < 25) continue;
195
+
196
+ result.memories.push({
197
+ content,
198
+ category: 'fact',
199
+ entity: null,
200
+ confidence: 0.9,
201
+ tags: ['claude-project', 'project-context'],
202
+ source: 'import:claude'
203
+ });
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Parse .claude/settings.json
209
+ */
210
+ function parseClaudeSettings(filePath, result) {
211
+ try {
212
+ const settings = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
213
+
214
+ if (settings.permissions?.allow) {
215
+ const content = `Claude project allows these tool permissions: ${settings.permissions.allow.join(', ')}`;
216
+ result.memories.push({
217
+ content,
218
+ category: 'fact',
219
+ entity: 'claude-code',
220
+ confidence: 0.9,
221
+ tags: ['claude-project', 'permissions'],
222
+ source: 'import:claude'
223
+ });
224
+ }
225
+ } catch {
226
+ result.warnings.push('Failed to parse .claude/settings.json');
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Parse .claude/commands directory
232
+ */
233
+ function parseClaudeCommands(dirPath, result) {
234
+ try {
235
+ const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
236
+
237
+ for (const file of files) {
238
+ const name = path.basename(file, '.md');
239
+ const content = `Claude project has custom command: /${name}`;
240
+ result.memories.push({
241
+ content,
242
+ category: 'fact',
243
+ entity: 'claude-code',
244
+ confidence: 0.9,
245
+ tags: ['claude-project', 'custom-commands'],
246
+ source: 'import:claude'
247
+ });
248
+ }
249
+ } catch {
250
+ result.warnings.push('Failed to read .claude/commands directory');
251
+ }
252
+ }
253
+
254
+ export const meta = {
255
+ name: 'claude',
256
+ label: '.claude files',
257
+ description: 'Project context and instructions from Claude Code',
258
+ category: 'fact',
259
+ locations: CLAUDE_LOCATIONS
260
+ };
@@ -0,0 +1,192 @@
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
+ * Known locations for .cursorrules files
8
+ */
9
+ const CURSORRULES_LOCATIONS = [
10
+ '.cursorrules',
11
+ '.cursor/rules'
12
+ ];
13
+
14
+ /**
15
+ * Detect if .cursorrules exists
16
+ * @param {Object} options
17
+ * @param {string} [options.cwd] - Working directory to scan
18
+ * @param {string[]} [options.paths] - Additional directories to scan
19
+ * @returns {{ found: boolean, path: string|null, paths: string[] }}
20
+ */
21
+ export function detect(options = {}) {
22
+ const cwd = options.cwd || process.cwd();
23
+ const foundPaths = [];
24
+ const seen = new Set();
25
+
26
+ // Check cwd
27
+ for (const loc of CURSORRULES_LOCATIONS) {
28
+ const fullPath = path.resolve(cwd, loc);
29
+ if (!seen.has(fullPath) && fs.existsSync(fullPath)) {
30
+ seen.add(fullPath);
31
+ foundPaths.push(fullPath);
32
+ }
33
+ }
34
+
35
+ // Check home directory
36
+ const homePath = path.resolve(os.homedir(), '.cursorrules');
37
+ if (!seen.has(homePath) && fs.existsSync(homePath)) {
38
+ seen.add(homePath);
39
+ foundPaths.push(homePath);
40
+ }
41
+
42
+ // Check additional paths
43
+ if (options.paths && Array.isArray(options.paths)) {
44
+ for (const dir of options.paths) {
45
+ for (const loc of CURSORRULES_LOCATIONS) {
46
+ const fullPath = path.resolve(dir, loc);
47
+ if (!seen.has(fullPath) && fs.existsSync(fullPath)) {
48
+ seen.add(fullPath);
49
+ foundPaths.push(fullPath);
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ return {
56
+ found: foundPaths.length > 0,
57
+ path: foundPaths[0] || null,
58
+ paths: foundPaths
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Parse .cursorrules file into memory candidates
64
+ * @param {Object} options
65
+ * @param {string} [options.cwd] - Working directory
66
+ * @param {string} [options.filePath] - Explicit file path
67
+ * @returns {Object} Parse result with memories, skipped, warnings
68
+ */
69
+ export async function parse(options = {}) {
70
+ const result = { source: 'cursorrules', memories: [], skipped: [], warnings: [] };
71
+
72
+ // If explicit filePath, parse just that one (backward compat)
73
+ if (options.filePath) {
74
+ parseOneCursorrules(options.filePath, result);
75
+ return result;
76
+ }
77
+
78
+ const detected = detect(options);
79
+ if (!detected.found) {
80
+ result.warnings.push('No .cursorrules file found');
81
+ return result;
82
+ }
83
+
84
+ for (const filePath of detected.paths) {
85
+ parseOneCursorrules(filePath, result);
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Parse a single .cursorrules file and accumulate results
93
+ */
94
+ function parseOneCursorrules(filePath, result) {
95
+ if (!filePath || !fs.existsSync(filePath)) {
96
+ result.warnings.push('No .cursorrules file found');
97
+ return;
98
+ }
99
+
100
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
101
+ if (!content) {
102
+ result.warnings.push('.cursorrules file is empty');
103
+ return;
104
+ }
105
+
106
+ const lines = content.split('\n');
107
+ let currentSection = null;
108
+ let currentBlock = [];
109
+
110
+ for (const rawLine of lines) {
111
+ const line = rawLine.trim();
112
+
113
+ // Skip empty lines between blocks
114
+ if (!line) {
115
+ if (currentBlock.length > 0) {
116
+ flushBlock(currentBlock, currentSection, result);
117
+ currentBlock = [];
118
+ }
119
+ continue;
120
+ }
121
+
122
+ // Detect section headers (markdown-style or YAML-style)
123
+ if (/^#{1,3}\s+/.test(line)) {
124
+ if (currentBlock.length > 0) {
125
+ flushBlock(currentBlock, currentSection, result);
126
+ currentBlock = [];
127
+ }
128
+ currentSection = line.replace(/^#{1,3}\s+/, '').trim();
129
+ continue;
130
+ }
131
+
132
+ // YAML-style top-level key
133
+ if (/^[a-zA-Z_-]+:\s*$/.test(line)) {
134
+ if (currentBlock.length > 0) {
135
+ flushBlock(currentBlock, currentSection, result);
136
+ currentBlock = [];
137
+ }
138
+ currentSection = line.replace(':', '').trim();
139
+ continue;
140
+ }
141
+
142
+ // Collect content lines (strip list markers)
143
+ const cleaned = line.replace(/^[-*]\s+/, '').replace(/^-\s+/, '').trim();
144
+ if (cleaned.length > 10) {
145
+ currentBlock.push(cleaned);
146
+ }
147
+ }
148
+
149
+ // Flush remaining block
150
+ if (currentBlock.length > 0) {
151
+ flushBlock(currentBlock, currentSection, result);
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Convert a block of lines into memory candidates
157
+ */
158
+ function flushBlock(lines, section, result) {
159
+ for (const line of lines) {
160
+ // Run secret detection
161
+ const validation = validateContent(line, { autoRedact: false });
162
+ if (!validation.valid) {
163
+ result.skipped.push({ content: line, reason: 'Contains secrets' });
164
+ result.warnings.push('Skipped rule containing sensitive data');
165
+ continue;
166
+ }
167
+
168
+ // Build memory content with section context
169
+ const content = section
170
+ ? `Cursor rule (${section}): ${line}`
171
+ : `Cursor rule: ${line}`;
172
+
173
+ if (content.length < 20) continue;
174
+
175
+ result.memories.push({
176
+ content,
177
+ category: 'preference',
178
+ entity: null,
179
+ confidence: 0.85,
180
+ tags: ['cursorrules', 'coding-style'],
181
+ source: 'import:cursorrules'
182
+ });
183
+ }
184
+ }
185
+
186
+ export const meta = {
187
+ name: 'cursorrules',
188
+ label: '.cursorrules',
189
+ description: 'Coding preferences and style rules from Cursor',
190
+ category: 'preference',
191
+ locations: CURSORRULES_LOCATIONS
192
+ };
@@ -0,0 +1,170 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Detect if .env.example exists
6
+ * @param {Object} [options] - Detection options
7
+ * @param {string} [options.cwd] - Working directory to scan
8
+ * @param {string[]} [options.paths] - Additional directories to scan
9
+ * @returns {{ found: boolean, path: string|null, paths: string[] }}
10
+ */
11
+ export function detect(options = {}) {
12
+ const candidates = ['.env.example', '.env.sample', '.env.template'];
13
+ const baseDir = options.cwd || process.cwd();
14
+ const foundPaths = [];
15
+ const seen = new Set();
16
+
17
+ // Check cwd
18
+ for (const name of candidates) {
19
+ const filePath = path.resolve(baseDir, name);
20
+ if (!seen.has(filePath) && fs.existsSync(filePath)) {
21
+ seen.add(filePath);
22
+ foundPaths.push(filePath);
23
+ }
24
+ }
25
+
26
+ // Check additional paths
27
+ if (options.paths && Array.isArray(options.paths)) {
28
+ for (const dir of options.paths) {
29
+ for (const name of candidates) {
30
+ const filePath = path.resolve(dir, name);
31
+ if (!seen.has(filePath) && fs.existsSync(filePath)) {
32
+ seen.add(filePath);
33
+ foundPaths.push(filePath);
34
+ }
35
+ }
36
+ }
37
+ }
38
+
39
+ return {
40
+ found: foundPaths.length > 0,
41
+ path: foundPaths[0] || null,
42
+ paths: foundPaths
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse .env.example for environment variable NAMES only.
48
+ * NEVER extracts actual values — only variable names and comments.
49
+ */
50
+ export async function parse(options = {}) {
51
+ const result = { source: 'env', memories: [], skipped: [], warnings: [] };
52
+
53
+ // If explicit filePath, parse just that one (backward compat)
54
+ if (options.filePath) {
55
+ parseOneEnv(options.filePath, result);
56
+ return result;
57
+ }
58
+
59
+ const detected = detect(options);
60
+ if (!detected.found) {
61
+ result.warnings.push('No .env.example file found');
62
+ return result;
63
+ }
64
+
65
+ for (const filePath of detected.paths) {
66
+ parseOneEnv(filePath, result);
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Parse a single .env.example file and accumulate results
74
+ */
75
+ function parseOneEnv(filePath, result) {
76
+ if (!fs.existsSync(filePath)) {
77
+ result.warnings.push(`No .env.example file found at ${filePath}`);
78
+ return;
79
+ }
80
+
81
+ const content = fs.readFileSync(filePath, 'utf-8');
82
+ const lines = content.split('\n');
83
+
84
+ const variables = [];
85
+ let currentComment = null;
86
+
87
+ for (const rawLine of lines) {
88
+ const line = rawLine.trim();
89
+
90
+ // Track comments as context
91
+ if (line.startsWith('#')) {
92
+ currentComment = line.replace(/^#+\s*/, '').trim();
93
+ continue;
94
+ }
95
+
96
+ if (!line) {
97
+ currentComment = null;
98
+ continue;
99
+ }
100
+
101
+ // Parse KEY=value (only extract KEY)
102
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
103
+ if (match) {
104
+ variables.push({
105
+ name: match[1],
106
+ comment: currentComment
107
+ });
108
+ currentComment = null;
109
+ }
110
+ }
111
+
112
+ if (variables.length === 0) {
113
+ result.warnings.push('.env.example has no variables');
114
+ return;
115
+ }
116
+
117
+ // Group variables by prefix for cleaner memories
118
+ const groups = {};
119
+ for (const v of variables) {
120
+ const prefix = v.name.split('_')[0];
121
+ if (!groups[prefix]) groups[prefix] = [];
122
+ groups[prefix].push(v);
123
+ }
124
+
125
+ // Create summary memory with all variable names
126
+ const varNames = variables.map(v => v.name);
127
+ result.memories.push({
128
+ content: `Project environment variables (names only): ${varNames.join(', ')}`,
129
+ category: 'fact',
130
+ entity: 'environment',
131
+ confidence: 0.9,
132
+ tags: ['env-example', 'configuration'],
133
+ source: 'import:env'
134
+ });
135
+
136
+ // Create grouped memories for larger variable sets
137
+ for (const [prefix, vars] of Object.entries(groups)) {
138
+ if (vars.length >= 2) {
139
+ const names = vars.map(v => v.name).join(', ');
140
+ const comments = vars
141
+ .filter(v => v.comment)
142
+ .map(v => `${v.name}: ${v.comment}`)
143
+ .join('; ');
144
+
145
+ const content = comments
146
+ ? `Environment config group "${prefix}": ${names}. ${comments}`
147
+ : `Environment config group "${prefix}": ${names}`;
148
+
149
+ result.memories.push({
150
+ content,
151
+ category: 'fact',
152
+ entity: 'environment',
153
+ confidence: 0.85,
154
+ tags: ['env-example', 'configuration'],
155
+ source: 'import:env'
156
+ });
157
+ }
158
+ }
159
+
160
+ // Security warning
161
+ result.warnings.push('Only variable NAMES were extracted — no values or secrets');
162
+ }
163
+
164
+ export const meta = {
165
+ name: 'env',
166
+ label: '.env.example',
167
+ description: 'Environment variable names only (NEVER values)',
168
+ category: 'fact',
169
+ locations: ['.env.example', '.env.sample', '.env.template']
170
+ };