neuronlayer 0.1.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.
Files changed (78) hide show
  1. package/CONTRIBUTING.md +127 -0
  2. package/LICENSE +21 -0
  3. package/README.md +305 -0
  4. package/dist/index.js +38016 -0
  5. package/esbuild.config.js +26 -0
  6. package/package.json +63 -0
  7. package/src/cli/commands.ts +382 -0
  8. package/src/core/adr-exporter.ts +253 -0
  9. package/src/core/architecture/architecture-enforcement.ts +228 -0
  10. package/src/core/architecture/duplicate-detector.ts +288 -0
  11. package/src/core/architecture/index.ts +6 -0
  12. package/src/core/architecture/pattern-learner.ts +306 -0
  13. package/src/core/architecture/pattern-library.ts +403 -0
  14. package/src/core/architecture/pattern-validator.ts +324 -0
  15. package/src/core/change-intelligence/bug-correlator.ts +444 -0
  16. package/src/core/change-intelligence/change-intelligence.ts +221 -0
  17. package/src/core/change-intelligence/change-tracker.ts +334 -0
  18. package/src/core/change-intelligence/fix-suggester.ts +340 -0
  19. package/src/core/change-intelligence/index.ts +5 -0
  20. package/src/core/code-verifier.ts +843 -0
  21. package/src/core/confidence/confidence-scorer.ts +251 -0
  22. package/src/core/confidence/conflict-checker.ts +289 -0
  23. package/src/core/confidence/index.ts +5 -0
  24. package/src/core/confidence/source-tracker.ts +263 -0
  25. package/src/core/confidence/warning-detector.ts +241 -0
  26. package/src/core/context-rot/compaction.ts +284 -0
  27. package/src/core/context-rot/context-health.ts +243 -0
  28. package/src/core/context-rot/context-rot-prevention.ts +213 -0
  29. package/src/core/context-rot/critical-context.ts +221 -0
  30. package/src/core/context-rot/drift-detector.ts +255 -0
  31. package/src/core/context-rot/index.ts +7 -0
  32. package/src/core/context.ts +263 -0
  33. package/src/core/decision-extractor.ts +339 -0
  34. package/src/core/decisions.ts +69 -0
  35. package/src/core/deja-vu.ts +421 -0
  36. package/src/core/engine.ts +1455 -0
  37. package/src/core/feature-context.ts +726 -0
  38. package/src/core/ghost-mode.ts +412 -0
  39. package/src/core/learning.ts +485 -0
  40. package/src/core/living-docs/activity-tracker.ts +296 -0
  41. package/src/core/living-docs/architecture-generator.ts +428 -0
  42. package/src/core/living-docs/changelog-generator.ts +348 -0
  43. package/src/core/living-docs/component-generator.ts +230 -0
  44. package/src/core/living-docs/doc-engine.ts +110 -0
  45. package/src/core/living-docs/doc-validator.ts +282 -0
  46. package/src/core/living-docs/index.ts +8 -0
  47. package/src/core/project-manager.ts +297 -0
  48. package/src/core/summarizer.ts +267 -0
  49. package/src/core/test-awareness/change-validator.ts +499 -0
  50. package/src/core/test-awareness/index.ts +5 -0
  51. package/src/index.ts +49 -0
  52. package/src/indexing/ast.ts +563 -0
  53. package/src/indexing/embeddings.ts +85 -0
  54. package/src/indexing/indexer.ts +245 -0
  55. package/src/indexing/watcher.ts +78 -0
  56. package/src/server/gateways/aggregator.ts +374 -0
  57. package/src/server/gateways/index.ts +473 -0
  58. package/src/server/gateways/memory-ghost.ts +343 -0
  59. package/src/server/gateways/memory-query.ts +452 -0
  60. package/src/server/gateways/memory-record.ts +346 -0
  61. package/src/server/gateways/memory-review.ts +410 -0
  62. package/src/server/gateways/memory-status.ts +517 -0
  63. package/src/server/gateways/memory-verify.ts +392 -0
  64. package/src/server/gateways/router.ts +434 -0
  65. package/src/server/gateways/types.ts +610 -0
  66. package/src/server/mcp.ts +154 -0
  67. package/src/server/resources.ts +85 -0
  68. package/src/server/tools.ts +2261 -0
  69. package/src/storage/database.ts +262 -0
  70. package/src/storage/tier1.ts +135 -0
  71. package/src/storage/tier2.ts +764 -0
  72. package/src/storage/tier3.ts +123 -0
  73. package/src/types/documentation.ts +619 -0
  74. package/src/types/index.ts +222 -0
  75. package/src/utils/config.ts +193 -0
  76. package/src/utils/files.ts +117 -0
  77. package/src/utils/time.ts +37 -0
  78. package/src/utils/tokens.ts +52 -0
@@ -0,0 +1,263 @@
1
+ import { dirname, relative, basename } from 'path';
2
+ import { Tier1Storage } from '../storage/tier1.js';
3
+ import { Tier2Storage } from '../storage/tier2.js';
4
+ import { Tier3Storage } from '../storage/tier3.js';
5
+ import { EmbeddingGenerator } from '../indexing/embeddings.js';
6
+ import { TokenBudget, estimateTokens } from '../utils/tokens.js';
7
+ import type { FeatureContextManager } from './feature-context.js';
8
+ import type { AssembledContext, AssemblyOptions, SearchResult, Decision, ContextParts, HotContext } from '../types/index.js';
9
+
10
+ export class ContextAssembler {
11
+ private tier1: Tier1Storage;
12
+ private tier2: Tier2Storage;
13
+ private tier3: Tier3Storage;
14
+ private embeddingGenerator: EmbeddingGenerator;
15
+ private featureContextManager: FeatureContextManager | null = null;
16
+
17
+ constructor(
18
+ tier1: Tier1Storage,
19
+ tier2: Tier2Storage,
20
+ tier3: Tier3Storage,
21
+ embeddingGenerator: EmbeddingGenerator
22
+ ) {
23
+ this.tier1 = tier1;
24
+ this.tier2 = tier2;
25
+ this.tier3 = tier3;
26
+ this.embeddingGenerator = embeddingGenerator;
27
+ }
28
+
29
+ // Set feature context manager (injected after construction to avoid circular deps)
30
+ setFeatureContextManager(manager: FeatureContextManager): void {
31
+ this.featureContextManager = manager;
32
+ }
33
+
34
+ async assemble(query: string, options: AssemblyOptions = {}): Promise<AssembledContext> {
35
+ const budget = new TokenBudget(options.maxTokens || 6000);
36
+ const queryEmbedding = await this.embeddingGenerator.embed(query);
37
+
38
+ // Step 0: HOT CONTEXT (HIGHEST PRIORITY) - Active Feature Context
39
+ let hotContext: HotContext | null = null;
40
+ if (this.featureContextManager) {
41
+ hotContext = this.featureContextManager.getHotContext();
42
+ if (hotContext.files.length > 0 || hotContext.changes.length > 0) {
43
+ const hotText = this.formatHotContext(hotContext);
44
+ if (budget.canFit(hotText)) {
45
+ budget.allocate(hotText, 'hot-context');
46
+ }
47
+ }
48
+ }
49
+
50
+ // Step 1: Load and format Tier 1 (working context)
51
+ const working = this.tier1.getContext();
52
+ const workingText = this.formatTier1(working);
53
+
54
+ if (workingText && budget.canFit(workingText)) {
55
+ budget.allocate(workingText, 'tier1');
56
+ }
57
+
58
+ // Step 2: Semantic search in Tier 2
59
+ const searchResults = this.tier2.search(queryEmbedding, 20);
60
+ const rankedResults = this.rankResults(searchResults, options.currentFile);
61
+
62
+ const tier2Content: SearchResult[] = [];
63
+ for (const result of rankedResults) {
64
+ const formatted = this.formatSearchResult(result);
65
+ if (budget.canFit(formatted)) {
66
+ budget.allocate(formatted, 'tier2');
67
+ tier2Content.push(result);
68
+ } else {
69
+ break;
70
+ }
71
+ }
72
+
73
+ // Step 3: Query Tier 3 if budget remains
74
+ const tier3Content: string[] = [];
75
+ if (budget.remaining() > 200) {
76
+ const archives = this.tier3.searchRelevant(query, 3);
77
+ for (const archive of archives) {
78
+ if (archive.summary && budget.canFit(archive.summary)) {
79
+ budget.allocate(archive.summary, 'tier3');
80
+ tier3Content.push(archive.summary);
81
+ }
82
+ }
83
+ }
84
+
85
+ // Step 4: Get relevant decisions
86
+ let decisions: Decision[] = [];
87
+ try {
88
+ decisions = this.tier2.searchDecisions(queryEmbedding, 5);
89
+ } catch {
90
+ decisions = this.tier2.getRecentDecisions(5);
91
+ }
92
+
93
+ const decisionsText = this.formatDecisions(decisions);
94
+ if (decisionsText && budget.canFit(decisionsText)) {
95
+ budget.allocate(decisionsText, 'decisions');
96
+ }
97
+
98
+ // Step 5: Assemble final context
99
+ const context = this.formatFinalContext({
100
+ working: { activeFile: working.activeFile },
101
+ relevant: tier2Content,
102
+ archive: tier3Content,
103
+ decisions
104
+ }, hotContext);
105
+
106
+ return {
107
+ context,
108
+ sources: tier2Content.map(r => r.file),
109
+ tokenCount: budget.used(),
110
+ decisions
111
+ };
112
+ }
113
+
114
+ private formatHotContext(hot: HotContext): string {
115
+ let output = `## Active Feature Context\n\n`;
116
+
117
+ if (hot.summary) {
118
+ output += `**${hot.summary}**\n\n`;
119
+ }
120
+
121
+ if (hot.files.length > 0) {
122
+ output += `### Files You're Working On\n`;
123
+ for (const f of hot.files.slice(0, 8)) {
124
+ output += `- \`${f.path}\` (touched ${f.touchCount}x)\n`;
125
+ }
126
+ output += `\n`;
127
+ }
128
+
129
+ if (hot.changes.length > 0) {
130
+ output += `### Recent Changes\n`;
131
+ for (const c of hot.changes.slice(0, 5)) {
132
+ const fileName = basename(c.file);
133
+ output += `- \`${fileName}\`: ${c.diff}\n`;
134
+ }
135
+ output += `\n`;
136
+ }
137
+
138
+ if (hot.queries.length > 0) {
139
+ output += `### Recent Questions\n`;
140
+ for (const q of hot.queries.slice(0, 3)) {
141
+ output += `- "${q.query}"\n`;
142
+ }
143
+ output += `\n`;
144
+ }
145
+
146
+ return output;
147
+ }
148
+
149
+ private formatTier1(context: { activeFile: { path: string; content: string; language: string } | null }): string {
150
+ if (!context.activeFile) {
151
+ return '';
152
+ }
153
+
154
+ return `### Currently Active File
155
+ File: ${context.activeFile.path}
156
+ \`\`\`${context.activeFile.language}
157
+ ${context.activeFile.content}
158
+ \`\`\`
159
+ `;
160
+ }
161
+
162
+ private formatSearchResult(result: SearchResult): string {
163
+ const score = result.score !== undefined ? result.score : result.similarity;
164
+ return `#### ${result.file} (relevance: ${(score * 100).toFixed(0)}%)
165
+ \`\`\`
166
+ ${result.preview}
167
+ \`\`\`
168
+ `;
169
+ }
170
+
171
+ private formatDecisions(decisions: Decision[]): string {
172
+ if (decisions.length === 0) {
173
+ return '';
174
+ }
175
+
176
+ return decisions.map(d => {
177
+ const date = d.createdAt.toLocaleDateString();
178
+ return `- **${d.title}** (${date})
179
+ ${d.description}`;
180
+ }).join('\n\n');
181
+ }
182
+
183
+ private rankResults(results: SearchResult[], currentFile?: string): SearchResult[] {
184
+ const filesViewed = this.tier1.getFilesViewed();
185
+
186
+ return results
187
+ .map(r => {
188
+ let score = r.similarity;
189
+
190
+ // Boost: Same directory as current file
191
+ if (currentFile && dirname(r.file) === dirname(currentFile)) {
192
+ score *= 1.5;
193
+ }
194
+
195
+ // Boost: Recently modified (within 24 hours)
196
+ const hoursSinceModified = (Date.now() - r.lastModified) / 3600000;
197
+ if (hoursSinceModified < 24) {
198
+ score *= 1 + (0.3 * (24 - hoursSinceModified) / 24);
199
+ }
200
+
201
+ // Boost: Recently viewed in session
202
+ if (filesViewed.includes(r.file)) {
203
+ score *= 1.3;
204
+ }
205
+
206
+ return { ...r, score };
207
+ })
208
+ .sort((a, b) => (b.score || 0) - (a.score || 0));
209
+ }
210
+
211
+ private formatFinalContext(parts: ContextParts, hotContext?: HotContext | null): string {
212
+ const sections: string[] = [];
213
+
214
+ // Hot context first (highest priority)
215
+ if (hotContext && (hotContext.files.length > 0 || hotContext.changes.length > 0)) {
216
+ sections.push(this.formatHotContext(hotContext));
217
+ }
218
+
219
+ sections.push('## Codebase Context\n');
220
+
221
+ // Working file
222
+ if (parts.working.activeFile) {
223
+ sections.push(`### Working File
224
+ File: ${parts.working.activeFile.path}
225
+ \`\`\`${parts.working.activeFile.language}
226
+ ${parts.working.activeFile.content}
227
+ \`\`\`
228
+ `);
229
+ }
230
+
231
+ // Relevant code
232
+ if (parts.relevant.length > 0) {
233
+ sections.push('### Relevant Code\n');
234
+ for (const r of parts.relevant) {
235
+ const score = r.score !== undefined ? r.score : r.similarity;
236
+ sections.push(`#### ${r.file} (relevance: ${(score * 100).toFixed(0)}%)
237
+ \`\`\`
238
+ ${r.preview}
239
+ \`\`\`
240
+ `);
241
+ }
242
+ }
243
+
244
+ // Decisions
245
+ if (parts.decisions.length > 0) {
246
+ sections.push('### Architecture Decisions\n');
247
+ for (const d of parts.decisions) {
248
+ const date = d.createdAt.toLocaleDateString();
249
+ sections.push(`- **${d.title}** (${date})
250
+ ${d.description}
251
+ `);
252
+ }
253
+ }
254
+
255
+ // Archive
256
+ if (parts.archive.length > 0) {
257
+ sections.push('### Historical Context\n');
258
+ sections.push(parts.archive.join('\n\n'));
259
+ }
260
+
261
+ return sections.join('\n').trim();
262
+ }
263
+ }
@@ -0,0 +1,339 @@
1
+ import { execSync } from 'child_process';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { glob } from 'glob';
5
+ import type { Decision } from '../types/index.js';
6
+ import { randomUUID } from 'crypto';
7
+
8
+ interface ExtractedDecision {
9
+ title: string;
10
+ description: string;
11
+ source: 'git' | 'comment' | 'adr';
12
+ file?: string;
13
+ line?: number;
14
+ tags: string[];
15
+ }
16
+
17
+ // Patterns for detecting decisions in commit messages
18
+ const COMMIT_PATTERNS = [
19
+ // Conventional commits with architectural significance
20
+ /^(?:feat|refactor|perf|build|ci)\(([^)]+)\):\s*(.+)/i,
21
+ // Explicit decision markers
22
+ /^(?:DECISION|ARCHITECTURE|ADR):\s*(.+)/i,
23
+ // "Use X instead of Y" pattern
24
+ /use\s+(\w+)\s+(?:instead of|over|rather than)\s+(\w+)/i,
25
+ // "Switch to X" pattern
26
+ /switch(?:ed|ing)?\s+to\s+(\w+)/i,
27
+ // "Implement X pattern"
28
+ /implement(?:ed|ing)?\s+(\w+)\s+pattern/i,
29
+ ];
30
+
31
+ // Patterns for detecting decisions in code comments
32
+ const COMMENT_PATTERNS = [
33
+ // Explicit decision markers
34
+ { pattern: /(?:\/\/|#|\/\*)\s*DECISION:\s*(.+?)(?:\*\/)?$/gm, tag: 'decision' },
35
+ { pattern: /(?:\/\/|#|\/\*)\s*ARCHITECTURE:\s*(.+?)(?:\*\/)?$/gm, tag: 'architecture' },
36
+ { pattern: /(?:\/\/|#|\/\*)\s*ADR:\s*(.+?)(?:\*\/)?$/gm, tag: 'adr' },
37
+ { pattern: /(?:\/\/|#|\/\*)\s*WHY:\s*(.+?)(?:\*\/)?$/gm, tag: 'rationale' },
38
+ { pattern: /(?:\/\/|#|\/\*)\s*NOTE:\s*(.+?)(?:\*\/)?$/gm, tag: 'note' },
39
+ { pattern: /(?:\/\/|#|\/\*)\s*IMPORTANT:\s*(.+?)(?:\*\/)?$/gm, tag: 'important' },
40
+ ];
41
+
42
+ // Tags to assign based on content
43
+ const TAG_KEYWORDS: Record<string, string[]> = {
44
+ database: ['database', 'db', 'sql', 'postgres', 'mysql', 'mongo', 'redis', 'sqlite', 'orm', 'prisma'],
45
+ authentication: ['auth', 'login', 'jwt', 'oauth', 'session', 'password', 'token'],
46
+ api: ['api', 'rest', 'graphql', 'endpoint', 'route', 'http'],
47
+ performance: ['performance', 'cache', 'optimize', 'speed', 'fast', 'slow', 'memory'],
48
+ security: ['security', 'encrypt', 'hash', 'ssl', 'https', 'csrf', 'xss', 'injection'],
49
+ testing: ['test', 'jest', 'vitest', 'mocha', 'cypress', 'e2e', 'unit'],
50
+ infrastructure: ['docker', 'kubernetes', 'aws', 'gcp', 'azure', 'deploy', 'ci', 'cd'],
51
+ frontend: ['react', 'vue', 'angular', 'svelte', 'css', 'ui', 'component'],
52
+ backend: ['server', 'node', 'express', 'fastify', 'middleware'],
53
+ };
54
+
55
+ export class DecisionExtractor {
56
+ private projectPath: string;
57
+ private isGitRepo: boolean;
58
+
59
+ constructor(projectPath: string) {
60
+ this.projectPath = projectPath;
61
+ this.isGitRepo = existsSync(join(projectPath, '.git'));
62
+ }
63
+
64
+ async extractAll(): Promise<ExtractedDecision[]> {
65
+ const decisions: ExtractedDecision[] = [];
66
+
67
+ // Extract from git commits
68
+ if (this.isGitRepo) {
69
+ try {
70
+ const gitDecisions = await this.extractFromGitCommits();
71
+ decisions.push(...gitDecisions);
72
+ } catch (error) {
73
+ console.error('Error extracting from git:', error);
74
+ }
75
+ }
76
+
77
+ // Extract from code comments
78
+ try {
79
+ const commentDecisions = await this.extractFromComments();
80
+ decisions.push(...commentDecisions);
81
+ } catch (error) {
82
+ console.error('Error extracting from comments:', error);
83
+ }
84
+
85
+ // Extract from ADR files
86
+ try {
87
+ const adrDecisions = await this.extractFromADRFiles();
88
+ decisions.push(...adrDecisions);
89
+ } catch (error) {
90
+ console.error('Error extracting from ADR files:', error);
91
+ }
92
+
93
+ return decisions;
94
+ }
95
+
96
+ private async extractFromGitCommits(): Promise<ExtractedDecision[]> {
97
+ const decisions: ExtractedDecision[] = [];
98
+
99
+ try {
100
+ // Get recent commits with architectural significance
101
+ const output = execSync(
102
+ 'git log --oneline -100 --format="%H|%s|%b"',
103
+ { cwd: this.projectPath, encoding: 'utf-8', maxBuffer: 1024 * 1024 }
104
+ );
105
+
106
+ const commits = output.split('\n').filter(Boolean);
107
+
108
+ for (const commit of commits) {
109
+ const [hash, subject, ...bodyParts] = commit.split('|');
110
+ const body = bodyParts.join('|');
111
+ const fullMessage = `${subject}\n${body}`.trim();
112
+
113
+ // Check for decision patterns
114
+ for (const pattern of COMMIT_PATTERNS) {
115
+ const match = fullMessage.match(pattern);
116
+ if (match) {
117
+ const title = this.extractTitle(subject || '');
118
+ const description = body || subject || '';
119
+
120
+ if (title && this.isArchitecturallySignificant(fullMessage)) {
121
+ decisions.push({
122
+ title,
123
+ description: description.slice(0, 500),
124
+ source: 'git',
125
+ tags: this.extractTags(fullMessage)
126
+ });
127
+ break;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ } catch (error) {
133
+ // Git command failed, probably not a git repo or no commits
134
+ }
135
+
136
+ return decisions.slice(0, 20); // Limit to 20 most recent
137
+ }
138
+
139
+ private async extractFromComments(): Promise<ExtractedDecision[]> {
140
+ const decisions: ExtractedDecision[] = [];
141
+
142
+ // Find all code files
143
+ const patterns = ['**/*.ts', '**/*.js', '**/*.py', '**/*.go', '**/*.java', '**/*.rs'];
144
+ const ignorePatterns = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'];
145
+
146
+ for (const pattern of patterns) {
147
+ try {
148
+ const files = await glob(pattern, {
149
+ cwd: this.projectPath,
150
+ ignore: ignorePatterns,
151
+ absolute: true,
152
+ nodir: true
153
+ });
154
+
155
+ for (const file of files.slice(0, 100)) { // Limit files to scan
156
+ try {
157
+ const content = readFileSync(file, 'utf-8');
158
+ const lines = content.split('\n');
159
+
160
+ for (let i = 0; i < lines.length; i++) {
161
+ const line = lines[i] || '';
162
+
163
+ for (const { pattern, tag } of COMMENT_PATTERNS) {
164
+ pattern.lastIndex = 0; // Reset regex state
165
+ const match = pattern.exec(line);
166
+ if (match && match[1]) {
167
+ const relativePath = file.replace(this.projectPath, '').replace(/^[/\\]/, '');
168
+
169
+ decisions.push({
170
+ title: this.extractTitle(match[1]),
171
+ description: match[1],
172
+ source: 'comment',
173
+ file: relativePath,
174
+ line: i + 1,
175
+ tags: [tag, ...this.extractTags(match[1])]
176
+ });
177
+ }
178
+ }
179
+ }
180
+ } catch {
181
+ // Skip files that can't be read
182
+ }
183
+ }
184
+ } catch {
185
+ // Glob failed
186
+ }
187
+ }
188
+
189
+ return decisions;
190
+ }
191
+
192
+ private async extractFromADRFiles(): Promise<ExtractedDecision[]> {
193
+ const decisions: ExtractedDecision[] = [];
194
+
195
+ // Look for ADR files in common locations
196
+ const adrPatterns = [
197
+ '**/docs/decisions/*.md',
198
+ '**/docs/adr/*.md',
199
+ '**/adr/*.md',
200
+ '**/decisions/*.md'
201
+ ];
202
+
203
+ for (const pattern of adrPatterns) {
204
+ try {
205
+ const files = await glob(pattern, {
206
+ cwd: this.projectPath,
207
+ ignore: ['**/node_modules/**'],
208
+ absolute: true,
209
+ nodir: true
210
+ });
211
+
212
+ for (const file of files) {
213
+ try {
214
+ const content = readFileSync(file, 'utf-8');
215
+ const title = this.extractADRTitle(content);
216
+ const status = this.extractADRStatus(content);
217
+
218
+ if (title && status !== 'superseded' && status !== 'deprecated') {
219
+ const relativePath = file.replace(this.projectPath, '').replace(/^[/\\]/, '');
220
+
221
+ decisions.push({
222
+ title,
223
+ description: this.extractADRDescription(content),
224
+ source: 'adr',
225
+ file: relativePath,
226
+ tags: ['adr', ...this.extractTags(content)]
227
+ });
228
+ }
229
+ } catch {
230
+ // Skip files that can't be read
231
+ }
232
+ }
233
+ } catch {
234
+ // Glob failed
235
+ }
236
+ }
237
+
238
+ return decisions;
239
+ }
240
+
241
+ private extractTitle(text: string): string {
242
+ // Clean up the text to make a good title
243
+ let title = text
244
+ .replace(/^(?:feat|fix|refactor|perf|build|ci|docs|style|test|chore)\([^)]*\):\s*/i, '')
245
+ .replace(/^(?:DECISION|ARCHITECTURE|ADR|WHY|NOTE|IMPORTANT):\s*/i, '')
246
+ .trim();
247
+
248
+ // Capitalize first letter
249
+ if (title.length > 0) {
250
+ title = title.charAt(0).toUpperCase() + title.slice(1);
251
+ }
252
+
253
+ // Truncate if too long
254
+ if (title.length > 100) {
255
+ title = title.slice(0, 97) + '...';
256
+ }
257
+
258
+ return title;
259
+ }
260
+
261
+ private extractTags(text: string): string[] {
262
+ const tags: string[] = [];
263
+ const lowerText = text.toLowerCase();
264
+
265
+ for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) {
266
+ for (const keyword of keywords) {
267
+ if (lowerText.includes(keyword)) {
268
+ tags.push(tag);
269
+ break;
270
+ }
271
+ }
272
+ }
273
+
274
+ return [...new Set(tags)]; // Deduplicate
275
+ }
276
+
277
+ private isArchitecturallySignificant(text: string): boolean {
278
+ const significantKeywords = [
279
+ 'architecture', 'design', 'pattern', 'implement', 'refactor',
280
+ 'migrate', 'switch', 'replace', 'instead', 'because', 'why',
281
+ 'decision', 'chose', 'choose', 'use', 'adopt', 'introduce'
282
+ ];
283
+
284
+ const lowerText = text.toLowerCase();
285
+ return significantKeywords.some(keyword => lowerText.includes(keyword));
286
+ }
287
+
288
+ private extractADRTitle(content: string): string {
289
+ // Look for # Title or title: in YAML frontmatter
290
+ const titleMatch = content.match(/^#\s+(.+)$/m) ||
291
+ content.match(/^title:\s*["']?([^"'\n]+)["']?$/m);
292
+
293
+ return titleMatch ? titleMatch[1]!.trim() : '';
294
+ }
295
+
296
+ private extractADRStatus(content: string): string {
297
+ const statusMatch = content.match(/^(?:##\s*)?status:\s*["']?(\w+)["']?$/im);
298
+ return statusMatch ? statusMatch[1]!.toLowerCase() : 'accepted';
299
+ }
300
+
301
+ private extractADRDescription(content: string): string {
302
+ // Try to find context or decision section
303
+ const contextMatch = content.match(/##\s*Context\s*\n([\s\S]*?)(?=##|$)/i);
304
+ const decisionMatch = content.match(/##\s*Decision\s*\n([\s\S]*?)(?=##|$)/i);
305
+
306
+ let description = '';
307
+ if (contextMatch) {
308
+ description += contextMatch[1]!.trim();
309
+ }
310
+ if (decisionMatch) {
311
+ description += (description ? '\n\n' : '') + decisionMatch[1]!.trim();
312
+ }
313
+
314
+ if (!description) {
315
+ // Fallback: use first paragraph after title
316
+ const lines = content.split('\n');
317
+ const startIndex = lines.findIndex(l => l.startsWith('#'));
318
+ if (startIndex >= 0) {
319
+ const remaining = lines.slice(startIndex + 1).join('\n').trim();
320
+ const firstPara = remaining.split(/\n\n/)[0];
321
+ description = firstPara || '';
322
+ }
323
+ }
324
+
325
+ return description.slice(0, 1000);
326
+ }
327
+
328
+ // Convert extracted decisions to proper Decision objects
329
+ toDecisions(extracted: ExtractedDecision[]): Decision[] {
330
+ return extracted.map(e => ({
331
+ id: randomUUID(),
332
+ title: e.title,
333
+ description: `${e.description}\n\n[Source: ${e.source}${e.file ? `, ${e.file}` : ''}${e.line ? `:${e.line}` : ''}]`,
334
+ files: e.file ? [e.file] : [],
335
+ tags: e.tags,
336
+ createdAt: new Date()
337
+ }));
338
+ }
339
+ }
@@ -0,0 +1,69 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { Tier1Storage } from '../storage/tier1.js';
3
+ import { Tier2Storage } from '../storage/tier2.js';
4
+ import { EmbeddingGenerator } from '../indexing/embeddings.js';
5
+ import type { Decision } from '../types/index.js';
6
+
7
+ export class DecisionTracker {
8
+ private tier1: Tier1Storage;
9
+ private tier2: Tier2Storage;
10
+ private embeddingGenerator: EmbeddingGenerator;
11
+
12
+ constructor(
13
+ tier1: Tier1Storage,
14
+ tier2: Tier2Storage,
15
+ embeddingGenerator: EmbeddingGenerator
16
+ ) {
17
+ this.tier1 = tier1;
18
+ this.tier2 = tier2;
19
+ this.embeddingGenerator = embeddingGenerator;
20
+ }
21
+
22
+ async recordDecision(
23
+ title: string,
24
+ description: string,
25
+ files: string[] = [],
26
+ tags: string[] = []
27
+ ): Promise<Decision> {
28
+ const decision: Decision = {
29
+ id: randomUUID(),
30
+ title,
31
+ description,
32
+ files,
33
+ tags,
34
+ createdAt: new Date()
35
+ };
36
+
37
+ // Store in Tier 1 for immediate access
38
+ this.tier1.addDecision(decision);
39
+
40
+ // Generate embedding for semantic search
41
+ const textToEmbed = `${title}\n${description}\n${tags.join(' ')}`;
42
+ const embedding = await this.embeddingGenerator.embed(textToEmbed);
43
+
44
+ // Store in Tier 2 for persistence
45
+ this.tier2.upsertDecision(decision, embedding);
46
+
47
+ return decision;
48
+ }
49
+
50
+ getRecentDecisions(limit: number = 10): Decision[] {
51
+ // First try Tier 1 (faster)
52
+ const tier1Decisions = this.tier1.getRecentDecisions(limit);
53
+ if (tier1Decisions.length >= limit) {
54
+ return tier1Decisions;
55
+ }
56
+
57
+ // Fall back to Tier 2 for more
58
+ return this.tier2.getRecentDecisions(limit);
59
+ }
60
+
61
+ getDecision(id: string): Decision | null {
62
+ return this.tier2.getDecision(id);
63
+ }
64
+
65
+ async searchDecisions(query: string, limit: number = 5): Promise<Decision[]> {
66
+ const queryEmbedding = await this.embeddingGenerator.embed(query);
67
+ return this.tier2.searchDecisions(queryEmbedding, limit);
68
+ }
69
+ }