@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,208 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { validateContent } from '../../extract/secrets.js';
4
+
5
+ const CLAUDE_LOCATIONS = [
6
+ '.claude',
7
+ 'CLAUDE.md',
8
+ '.claude/settings.json',
9
+ '.claude/commands'
10
+ ];
11
+
12
+ /**
13
+ * Detect if .claude project files exist
14
+ */
15
+ export function detect(options = {}) {
16
+ const cwd = options.cwd || process.cwd();
17
+
18
+ for (const loc of CLAUDE_LOCATIONS) {
19
+ const fullPath = path.resolve(cwd, loc);
20
+ if (fs.existsSync(fullPath)) {
21
+ return { found: true, path: path.resolve(cwd) };
22
+ }
23
+ }
24
+
25
+ return { found: false, path: null };
26
+ }
27
+
28
+ /**
29
+ * Parse .claude project files into memory candidates
30
+ */
31
+ export async function parse(options = {}) {
32
+ const result = { source: 'claude', memories: [], skipped: [], warnings: [] };
33
+ const cwd = options.cwd || process.cwd();
34
+
35
+ // Parse CLAUDE.md
36
+ const claudeMdPath = path.resolve(cwd, 'CLAUDE.md');
37
+ if (fs.existsSync(claudeMdPath)) {
38
+ parseClaudeMd(claudeMdPath, result);
39
+ }
40
+
41
+ // Parse .claude/settings.json
42
+ const settingsPath = path.resolve(cwd, '.claude/settings.json');
43
+ if (fs.existsSync(settingsPath)) {
44
+ parseClaudeSettings(settingsPath, result);
45
+ }
46
+
47
+ // Parse .claude/commands directory
48
+ const commandsPath = path.resolve(cwd, '.claude/commands');
49
+ if (fs.existsSync(commandsPath) && fs.statSync(commandsPath).isDirectory()) {
50
+ parseClaudeCommands(commandsPath, result);
51
+ }
52
+
53
+ if (result.memories.length === 0) {
54
+ result.warnings.push('No .claude project files found or no extractable content');
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Parse CLAUDE.md into structured memories
62
+ */
63
+ function parseClaudeMd(filePath, result) {
64
+ const content = fs.readFileSync(filePath, 'utf-8');
65
+ const lines = content.split('\n');
66
+
67
+ let currentSection = null;
68
+ let currentBlock = [];
69
+
70
+ for (const rawLine of lines) {
71
+ const line = rawLine.trim();
72
+
73
+ // Section headers
74
+ if (/^#{1,3}\s+/.test(line)) {
75
+ if (currentBlock.length > 0) {
76
+ flushClaudeBlock(currentBlock, currentSection, result);
77
+ currentBlock = [];
78
+ }
79
+ currentSection = line.replace(/^#{1,3}\s+/, '').trim();
80
+ continue;
81
+ }
82
+
83
+ // Skip empty lines
84
+ if (!line) {
85
+ if (currentBlock.length > 0) {
86
+ flushClaudeBlock(currentBlock, currentSection, result);
87
+ currentBlock = [];
88
+ }
89
+ continue;
90
+ }
91
+
92
+ // Skip code blocks (fences)
93
+ if (line.startsWith('```')) continue;
94
+
95
+ // Collect content
96
+ const cleaned = line.replace(/^[-*]\s+/, '').trim();
97
+ if (cleaned.length > 15) {
98
+ currentBlock.push(cleaned);
99
+ }
100
+ }
101
+
102
+ if (currentBlock.length > 0) {
103
+ flushClaudeBlock(currentBlock, currentSection, result);
104
+ }
105
+ }
106
+
107
+ function flushClaudeBlock(lines, section, result) {
108
+ // Group related lines into single memories when they're short
109
+ const grouped = [];
110
+ let current = [];
111
+
112
+ for (const line of lines) {
113
+ if (line.length > 100) {
114
+ // Long lines become individual memories
115
+ if (current.length > 0) {
116
+ grouped.push(current.join('. '));
117
+ current = [];
118
+ }
119
+ grouped.push(line);
120
+ } else {
121
+ current.push(line);
122
+ if (current.join('. ').length > 150) {
123
+ grouped.push(current.join('. '));
124
+ current = [];
125
+ }
126
+ }
127
+ }
128
+ if (current.length > 0) grouped.push(current.join('. '));
129
+
130
+ for (const text of grouped) {
131
+ const validation = validateContent(text, { autoRedact: false });
132
+ if (!validation.valid) {
133
+ result.skipped.push({ content: text, reason: 'Contains secrets' });
134
+ result.warnings.push('Skipped CLAUDE.md section containing sensitive data');
135
+ continue;
136
+ }
137
+
138
+ const content = section
139
+ ? `Claude project context (${section}): ${text}`
140
+ : `Claude project context: ${text}`;
141
+
142
+ if (content.length < 25) continue;
143
+
144
+ result.memories.push({
145
+ content,
146
+ category: 'fact',
147
+ entity: null,
148
+ confidence: 0.9,
149
+ tags: ['claude-project', 'project-context'],
150
+ source: 'import:claude'
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Parse .claude/settings.json
157
+ */
158
+ function parseClaudeSettings(filePath, result) {
159
+ try {
160
+ const settings = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
161
+
162
+ if (settings.permissions?.allow) {
163
+ const content = `Claude project allows these tool permissions: ${settings.permissions.allow.join(', ')}`;
164
+ result.memories.push({
165
+ content,
166
+ category: 'fact',
167
+ entity: 'claude-code',
168
+ confidence: 0.9,
169
+ tags: ['claude-project', 'permissions'],
170
+ source: 'import:claude'
171
+ });
172
+ }
173
+ } catch {
174
+ result.warnings.push('Failed to parse .claude/settings.json');
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Parse .claude/commands directory
180
+ */
181
+ function parseClaudeCommands(dirPath, result) {
182
+ try {
183
+ const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.md'));
184
+
185
+ for (const file of files) {
186
+ const name = path.basename(file, '.md');
187
+ const content = `Claude project has custom command: /${name}`;
188
+ result.memories.push({
189
+ content,
190
+ category: 'fact',
191
+ entity: 'claude-code',
192
+ confidence: 0.9,
193
+ tags: ['claude-project', 'custom-commands'],
194
+ source: 'import:claude'
195
+ });
196
+ }
197
+ } catch {
198
+ result.warnings.push('Failed to read .claude/commands directory');
199
+ }
200
+ }
201
+
202
+ export const meta = {
203
+ name: 'claude',
204
+ label: '.claude files',
205
+ description: 'Project context and instructions from Claude Code',
206
+ category: 'fact',
207
+ locations: CLAUDE_LOCATIONS
208
+ };
@@ -0,0 +1,153 @@
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
+ * @returns {Object} Detection result
19
+ */
20
+ export function detect(options = {}) {
21
+ const cwd = options.cwd || process.cwd();
22
+
23
+ for (const loc of CURSORRULES_LOCATIONS) {
24
+ const fullPath = path.resolve(cwd, loc);
25
+ if (fs.existsSync(fullPath)) {
26
+ return { found: true, path: fullPath };
27
+ }
28
+ }
29
+
30
+ // Also check home directory
31
+ const homePath = path.join(os.homedir(), '.cursorrules');
32
+ if (fs.existsSync(homePath)) {
33
+ return { found: true, path: homePath };
34
+ }
35
+
36
+ return { found: false, path: null };
37
+ }
38
+
39
+ /**
40
+ * Parse .cursorrules file into memory candidates
41
+ * @param {Object} options
42
+ * @param {string} [options.cwd] - Working directory
43
+ * @param {string} [options.filePath] - Explicit file path
44
+ * @returns {Object} Parse result with memories, skipped, warnings
45
+ */
46
+ export async function parse(options = {}) {
47
+ const result = { source: 'cursorrules', memories: [], skipped: [], warnings: [] };
48
+
49
+ const filePath = options.filePath || (() => {
50
+ const detected = detect(options);
51
+ return detected.path;
52
+ })();
53
+
54
+ if (!filePath || !fs.existsSync(filePath)) {
55
+ result.warnings.push('No .cursorrules file found');
56
+ return result;
57
+ }
58
+
59
+ const content = fs.readFileSync(filePath, 'utf-8').trim();
60
+ if (!content) {
61
+ result.warnings.push('.cursorrules file is empty');
62
+ return result;
63
+ }
64
+
65
+ const lines = content.split('\n');
66
+ let currentSection = null;
67
+ let currentBlock = [];
68
+
69
+ for (const rawLine of lines) {
70
+ const line = rawLine.trim();
71
+
72
+ // Skip empty lines between blocks
73
+ if (!line) {
74
+ if (currentBlock.length > 0) {
75
+ flushBlock(currentBlock, currentSection, result);
76
+ currentBlock = [];
77
+ }
78
+ continue;
79
+ }
80
+
81
+ // Detect section headers (markdown-style or YAML-style)
82
+ if (/^#{1,3}\s+/.test(line)) {
83
+ if (currentBlock.length > 0) {
84
+ flushBlock(currentBlock, currentSection, result);
85
+ currentBlock = [];
86
+ }
87
+ currentSection = line.replace(/^#{1,3}\s+/, '').trim();
88
+ continue;
89
+ }
90
+
91
+ // YAML-style top-level key
92
+ if (/^[a-zA-Z_-]+:\s*$/.test(line)) {
93
+ if (currentBlock.length > 0) {
94
+ flushBlock(currentBlock, currentSection, result);
95
+ currentBlock = [];
96
+ }
97
+ currentSection = line.replace(':', '').trim();
98
+ continue;
99
+ }
100
+
101
+ // Collect content lines (strip list markers)
102
+ const cleaned = line.replace(/^[-*]\s+/, '').replace(/^-\s+/, '').trim();
103
+ if (cleaned.length > 10) {
104
+ currentBlock.push(cleaned);
105
+ }
106
+ }
107
+
108
+ // Flush remaining block
109
+ if (currentBlock.length > 0) {
110
+ flushBlock(currentBlock, currentSection, result);
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Convert a block of lines into memory candidates
118
+ */
119
+ function flushBlock(lines, section, result) {
120
+ for (const line of lines) {
121
+ // Run secret detection
122
+ const validation = validateContent(line, { autoRedact: false });
123
+ if (!validation.valid) {
124
+ result.skipped.push({ content: line, reason: 'Contains secrets' });
125
+ result.warnings.push('Skipped rule containing sensitive data');
126
+ continue;
127
+ }
128
+
129
+ // Build memory content with section context
130
+ const content = section
131
+ ? `Cursor rule (${section}): ${line}`
132
+ : `Cursor rule: ${line}`;
133
+
134
+ if (content.length < 20) continue;
135
+
136
+ result.memories.push({
137
+ content,
138
+ category: 'preference',
139
+ entity: null,
140
+ confidence: 0.85,
141
+ tags: ['cursorrules', 'coding-style'],
142
+ source: 'import:cursorrules'
143
+ });
144
+ }
145
+ }
146
+
147
+ export const meta = {
148
+ name: 'cursorrules',
149
+ label: '.cursorrules',
150
+ description: 'Coding preferences and style rules from Cursor',
151
+ category: 'preference',
152
+ locations: CURSORRULES_LOCATIONS
153
+ };
@@ -0,0 +1,129 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Detect if .env.example exists
6
+ */
7
+ export function detect(options = {}) {
8
+ const candidates = ['.env.example', '.env.sample', '.env.template'];
9
+ const baseDir = options.cwd || process.cwd();
10
+
11
+ for (const name of candidates) {
12
+ const filePath = path.resolve(baseDir, name);
13
+ if (fs.existsSync(filePath)) {
14
+ return { found: true, path: filePath };
15
+ }
16
+ }
17
+
18
+ return { found: false, path: null };
19
+ }
20
+
21
+ /**
22
+ * Parse .env.example for environment variable NAMES only.
23
+ * NEVER extracts actual values — only variable names and comments.
24
+ */
25
+ export async function parse(options = {}) {
26
+ const result = { source: 'env', memories: [], skipped: [], warnings: [] };
27
+
28
+ const filePath = options.filePath || (() => {
29
+ const detected = detect(options);
30
+ return detected.path;
31
+ })();
32
+
33
+ if (!filePath || !fs.existsSync(filePath)) {
34
+ result.warnings.push('No .env.example file found');
35
+ return result;
36
+ }
37
+
38
+ const content = fs.readFileSync(filePath, 'utf-8');
39
+ const lines = content.split('\n');
40
+
41
+ const variables = [];
42
+ let currentComment = null;
43
+
44
+ for (const rawLine of lines) {
45
+ const line = rawLine.trim();
46
+
47
+ // Track comments as context
48
+ if (line.startsWith('#')) {
49
+ currentComment = line.replace(/^#+\s*/, '').trim();
50
+ continue;
51
+ }
52
+
53
+ if (!line) {
54
+ currentComment = null;
55
+ continue;
56
+ }
57
+
58
+ // Parse KEY=value (only extract KEY)
59
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)=/);
60
+ if (match) {
61
+ variables.push({
62
+ name: match[1],
63
+ comment: currentComment
64
+ });
65
+ currentComment = null;
66
+ }
67
+ }
68
+
69
+ if (variables.length === 0) {
70
+ result.warnings.push('.env.example has no variables');
71
+ return result;
72
+ }
73
+
74
+ // Group variables by prefix for cleaner memories
75
+ const groups = {};
76
+ for (const v of variables) {
77
+ const prefix = v.name.split('_')[0];
78
+ if (!groups[prefix]) groups[prefix] = [];
79
+ groups[prefix].push(v);
80
+ }
81
+
82
+ // Create summary memory with all variable names
83
+ const varNames = variables.map(v => v.name);
84
+ result.memories.push({
85
+ content: `Project environment variables (names only): ${varNames.join(', ')}`,
86
+ category: 'fact',
87
+ entity: 'environment',
88
+ confidence: 0.9,
89
+ tags: ['env-example', 'configuration'],
90
+ source: 'import:env'
91
+ });
92
+
93
+ // Create grouped memories for larger variable sets
94
+ for (const [prefix, vars] of Object.entries(groups)) {
95
+ if (vars.length >= 2) {
96
+ const names = vars.map(v => v.name).join(', ');
97
+ const comments = vars
98
+ .filter(v => v.comment)
99
+ .map(v => `${v.name}: ${v.comment}`)
100
+ .join('; ');
101
+
102
+ const content = comments
103
+ ? `Environment config group "${prefix}": ${names}. ${comments}`
104
+ : `Environment config group "${prefix}": ${names}`;
105
+
106
+ result.memories.push({
107
+ content,
108
+ category: 'fact',
109
+ entity: 'environment',
110
+ confidence: 0.85,
111
+ tags: ['env-example', 'configuration'],
112
+ source: 'import:env'
113
+ });
114
+ }
115
+ }
116
+
117
+ // Security warning
118
+ result.warnings.push('Only variable NAMES were extracted — no values or secrets');
119
+
120
+ return result;
121
+ }
122
+
123
+ export const meta = {
124
+ name: 'env',
125
+ label: '.env.example',
126
+ description: 'Environment variable names only (NEVER values)',
127
+ category: 'fact',
128
+ locations: ['.env.example', '.env.sample', '.env.template']
129
+ };
@@ -0,0 +1,194 @@
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 GITCONFIG_LOCATIONS = [
7
+ path.join(os.homedir(), '.gitconfig'),
8
+ path.join(os.homedir(), '.config/git/config')
9
+ ];
10
+
11
+ /**
12
+ * Detect if git config exists
13
+ */
14
+ export function detect() {
15
+ for (const loc of GITCONFIG_LOCATIONS) {
16
+ if (fs.existsSync(loc)) {
17
+ return { found: true, path: loc };
18
+ }
19
+ }
20
+ return { found: false, path: null };
21
+ }
22
+
23
+ /**
24
+ * Parse ~/.gitconfig into memory candidates
25
+ */
26
+ export async function parse(options = {}) {
27
+ const result = { source: 'git', memories: [], skipped: [], warnings: [] };
28
+
29
+ const filePath = options.filePath || (() => {
30
+ const detected = detect();
31
+ return detected.path;
32
+ })();
33
+
34
+ if (!filePath || !fs.existsSync(filePath)) {
35
+ result.warnings.push('No .gitconfig found');
36
+ return result;
37
+ }
38
+
39
+ const content = fs.readFileSync(filePath, 'utf-8');
40
+ const sections = parseIniFile(content);
41
+
42
+ // Extract user info
43
+ if (sections.user) {
44
+ if (sections.user.name) {
45
+ result.memories.push({
46
+ content: `Git user name: ${sections.user.name}`,
47
+ category: 'fact',
48
+ entity: 'git',
49
+ confidence: 1.0,
50
+ tags: ['gitconfig', 'user-info'],
51
+ source: 'import:git'
52
+ });
53
+ }
54
+
55
+ if (sections.user.email) {
56
+ // Validate — email itself isn't a secret, but check anyway
57
+ const validation = validateContent(sections.user.email, { autoRedact: false });
58
+ if (validation.valid) {
59
+ result.memories.push({
60
+ content: `Git user email: ${sections.user.email}`,
61
+ category: 'fact',
62
+ entity: 'git',
63
+ confidence: 1.0,
64
+ tags: ['gitconfig', 'user-info'],
65
+ source: 'import:git'
66
+ });
67
+ }
68
+ }
69
+
70
+ if (sections.user.signingkey) {
71
+ // Don't extract the actual key, just note that signing is configured
72
+ result.memories.push({
73
+ content: 'Git commit signing is configured',
74
+ category: 'fact',
75
+ entity: 'git',
76
+ confidence: 1.0,
77
+ tags: ['gitconfig', 'security'],
78
+ source: 'import:git'
79
+ });
80
+ }
81
+ }
82
+
83
+ // Extract core settings
84
+ if (sections.core) {
85
+ const coreSettings = [];
86
+ if (sections.core.editor) coreSettings.push(`editor: ${sections.core.editor}`);
87
+ if (sections.core.autocrlf) coreSettings.push(`autocrlf: ${sections.core.autocrlf}`);
88
+ if (sections.core.pager) coreSettings.push(`pager: ${sections.core.pager}`);
89
+
90
+ if (coreSettings.length > 0) {
91
+ result.memories.push({
92
+ content: `Git core settings: ${coreSettings.join(', ')}`,
93
+ category: 'preference',
94
+ entity: 'git',
95
+ confidence: 0.9,
96
+ tags: ['gitconfig', 'preferences'],
97
+ source: 'import:git'
98
+ });
99
+ }
100
+ }
101
+
102
+ // Extract aliases
103
+ if (sections.alias) {
104
+ const aliases = Object.entries(sections.alias);
105
+ if (aliases.length > 0) {
106
+ const aliasStr = aliases
107
+ .slice(0, 10) // Limit to 10 most relevant
108
+ .map(([name, cmd]) => `${name}="${cmd}"`)
109
+ .join(', ');
110
+
111
+ result.memories.push({
112
+ content: `Git aliases: ${aliasStr}`,
113
+ category: 'pattern',
114
+ entity: 'git',
115
+ confidence: 0.85,
116
+ tags: ['gitconfig', 'aliases'],
117
+ source: 'import:git'
118
+ });
119
+ }
120
+ }
121
+
122
+ // Extract default branch
123
+ if (sections.init?.defaultBranch) {
124
+ result.memories.push({
125
+ content: `Git default branch: ${sections.init.defaultBranch}`,
126
+ category: 'preference',
127
+ entity: 'git',
128
+ confidence: 1.0,
129
+ tags: ['gitconfig', 'preferences'],
130
+ source: 'import:git'
131
+ });
132
+ }
133
+
134
+ // Extract merge/diff tool preferences
135
+ if (sections.merge?.tool || sections.diff?.tool) {
136
+ const tools = [];
137
+ if (sections.merge?.tool) tools.push(`merge tool: ${sections.merge.tool}`);
138
+ if (sections.diff?.tool) tools.push(`diff tool: ${sections.diff.tool}`);
139
+
140
+ result.memories.push({
141
+ content: `Git ${tools.join(', ')}`,
142
+ category: 'preference',
143
+ entity: 'git',
144
+ confidence: 0.9,
145
+ tags: ['gitconfig', 'tools'],
146
+ source: 'import:git'
147
+ });
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ /**
154
+ * Simple INI file parser for git config format
155
+ */
156
+ function parseIniFile(content) {
157
+ const sections = {};
158
+ let currentSection = null;
159
+
160
+ for (const rawLine of content.split('\n')) {
161
+ const line = rawLine.trim();
162
+
163
+ if (!line || line.startsWith('#') || line.startsWith(';')) continue;
164
+
165
+ // Section header [section] or [section "subsection"]
166
+ const sectionMatch = line.match(/^\[([^\s\]]+)(?:\s+"([^"]+)")?\]$/);
167
+ if (sectionMatch) {
168
+ const sectionName = sectionMatch[2]
169
+ ? `${sectionMatch[1]}.${sectionMatch[2]}`
170
+ : sectionMatch[1];
171
+ currentSection = sectionName;
172
+ if (!sections[currentSection]) sections[currentSection] = {};
173
+ continue;
174
+ }
175
+
176
+ // Key = value
177
+ if (currentSection) {
178
+ const kvMatch = line.match(/^(\w+)\s*=\s*(.*)$/);
179
+ if (kvMatch) {
180
+ sections[currentSection][kvMatch[1]] = kvMatch[2].trim();
181
+ }
182
+ }
183
+ }
184
+
185
+ return sections;
186
+ }
187
+
188
+ export const meta = {
189
+ name: 'git',
190
+ label: 'git config',
191
+ description: 'Name, email, aliases, and preferences from git configuration',
192
+ category: 'fact',
193
+ locations: GITCONFIG_LOCATIONS.map(p => p.replace(os.homedir(), '~'))
194
+ };