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.
- package/CONTRIBUTING.md +127 -0
- package/LICENSE +21 -0
- package/README.md +305 -0
- package/dist/index.js +38016 -0
- package/esbuild.config.js +26 -0
- package/package.json +63 -0
- package/src/cli/commands.ts +382 -0
- package/src/core/adr-exporter.ts +253 -0
- package/src/core/architecture/architecture-enforcement.ts +228 -0
- package/src/core/architecture/duplicate-detector.ts +288 -0
- package/src/core/architecture/index.ts +6 -0
- package/src/core/architecture/pattern-learner.ts +306 -0
- package/src/core/architecture/pattern-library.ts +403 -0
- package/src/core/architecture/pattern-validator.ts +324 -0
- package/src/core/change-intelligence/bug-correlator.ts +444 -0
- package/src/core/change-intelligence/change-intelligence.ts +221 -0
- package/src/core/change-intelligence/change-tracker.ts +334 -0
- package/src/core/change-intelligence/fix-suggester.ts +340 -0
- package/src/core/change-intelligence/index.ts +5 -0
- package/src/core/code-verifier.ts +843 -0
- package/src/core/confidence/confidence-scorer.ts +251 -0
- package/src/core/confidence/conflict-checker.ts +289 -0
- package/src/core/confidence/index.ts +5 -0
- package/src/core/confidence/source-tracker.ts +263 -0
- package/src/core/confidence/warning-detector.ts +241 -0
- package/src/core/context-rot/compaction.ts +284 -0
- package/src/core/context-rot/context-health.ts +243 -0
- package/src/core/context-rot/context-rot-prevention.ts +213 -0
- package/src/core/context-rot/critical-context.ts +221 -0
- package/src/core/context-rot/drift-detector.ts +255 -0
- package/src/core/context-rot/index.ts +7 -0
- package/src/core/context.ts +263 -0
- package/src/core/decision-extractor.ts +339 -0
- package/src/core/decisions.ts +69 -0
- package/src/core/deja-vu.ts +421 -0
- package/src/core/engine.ts +1455 -0
- package/src/core/feature-context.ts +726 -0
- package/src/core/ghost-mode.ts +412 -0
- package/src/core/learning.ts +485 -0
- package/src/core/living-docs/activity-tracker.ts +296 -0
- package/src/core/living-docs/architecture-generator.ts +428 -0
- package/src/core/living-docs/changelog-generator.ts +348 -0
- package/src/core/living-docs/component-generator.ts +230 -0
- package/src/core/living-docs/doc-engine.ts +110 -0
- package/src/core/living-docs/doc-validator.ts +282 -0
- package/src/core/living-docs/index.ts +8 -0
- package/src/core/project-manager.ts +297 -0
- package/src/core/summarizer.ts +267 -0
- package/src/core/test-awareness/change-validator.ts +499 -0
- package/src/core/test-awareness/index.ts +5 -0
- package/src/index.ts +49 -0
- package/src/indexing/ast.ts +563 -0
- package/src/indexing/embeddings.ts +85 -0
- package/src/indexing/indexer.ts +245 -0
- package/src/indexing/watcher.ts +78 -0
- package/src/server/gateways/aggregator.ts +374 -0
- package/src/server/gateways/index.ts +473 -0
- package/src/server/gateways/memory-ghost.ts +343 -0
- package/src/server/gateways/memory-query.ts +452 -0
- package/src/server/gateways/memory-record.ts +346 -0
- package/src/server/gateways/memory-review.ts +410 -0
- package/src/server/gateways/memory-status.ts +517 -0
- package/src/server/gateways/memory-verify.ts +392 -0
- package/src/server/gateways/router.ts +434 -0
- package/src/server/gateways/types.ts +610 -0
- package/src/server/mcp.ts +154 -0
- package/src/server/resources.ts +85 -0
- package/src/server/tools.ts +2261 -0
- package/src/storage/database.ts +262 -0
- package/src/storage/tier1.ts +135 -0
- package/src/storage/tier2.ts +764 -0
- package/src/storage/tier3.ts +123 -0
- package/src/types/documentation.ts +619 -0
- package/src/types/index.ts +222 -0
- package/src/utils/config.ts +193 -0
- package/src/utils/files.ts +117 -0
- package/src/utils/time.ts +37 -0
- 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
|
+
}
|