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,726 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
3
|
+
import { dirname, join, basename } from 'path';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
5
|
+
import type { ActiveFeatureContext, FeatureFile, FeatureChange, FeatureQuery, HotContext } from '../types/index.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Context Resurrection - Restore mental state from last session
|
|
9
|
+
* "Last time you worked on auth, you were stuck on X"
|
|
10
|
+
*/
|
|
11
|
+
export interface ResurrectedContext {
|
|
12
|
+
/** What files were you working on? */
|
|
13
|
+
activeFiles: string[];
|
|
14
|
+
/** What were you trying to do? */
|
|
15
|
+
lastQueries: string[];
|
|
16
|
+
/** What decisions were made during this context? */
|
|
17
|
+
sessionDecisions: string[];
|
|
18
|
+
/** Where did you leave off? */
|
|
19
|
+
lastEditedFile: string | null;
|
|
20
|
+
lastEditTime: Date | null;
|
|
21
|
+
/** What was the blocker (if any)? */
|
|
22
|
+
possibleBlocker: string | null;
|
|
23
|
+
/** Suggested next steps */
|
|
24
|
+
suggestedActions: string[];
|
|
25
|
+
/** Context summary for AI */
|
|
26
|
+
summary: string;
|
|
27
|
+
/** Time since last activity */
|
|
28
|
+
timeSinceLastActive: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ContextResurrectionOptions {
|
|
32
|
+
/** Specific feature name to resurrect */
|
|
33
|
+
featureName?: string;
|
|
34
|
+
/** Include file contents in resurrection */
|
|
35
|
+
includeFileContents?: boolean;
|
|
36
|
+
/** Maximum files to include */
|
|
37
|
+
maxFiles?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MAX_FILES = 20;
|
|
41
|
+
const MAX_CHANGES = 50;
|
|
42
|
+
const MAX_QUERIES = 20;
|
|
43
|
+
const MAX_RECENT_CONTEXTS = 5;
|
|
44
|
+
const TTL_MINUTES = 30;
|
|
45
|
+
const HOT_CACHE_MAX_FILES = 15;
|
|
46
|
+
|
|
47
|
+
export class FeatureContextManager extends EventEmitter {
|
|
48
|
+
private current: ActiveFeatureContext | null = null;
|
|
49
|
+
private recent: ActiveFeatureContext[] = [];
|
|
50
|
+
private fileContents: Map<string, string> = new Map();
|
|
51
|
+
private projectPath: string;
|
|
52
|
+
private dataDir: string;
|
|
53
|
+
private persistPath: string;
|
|
54
|
+
private inactivityTimer: NodeJS.Timeout | null = null;
|
|
55
|
+
|
|
56
|
+
constructor(projectPath: string, dataDir: string) {
|
|
57
|
+
super();
|
|
58
|
+
this.projectPath = projectPath;
|
|
59
|
+
this.dataDir = dataDir;
|
|
60
|
+
this.persistPath = join(dataDir, 'feature-context.json');
|
|
61
|
+
this.load();
|
|
62
|
+
this.startInactivityTimer();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ========== FILE TRACKING ==========
|
|
66
|
+
|
|
67
|
+
onFileOpened(filePath: string): void {
|
|
68
|
+
this.ensureContext();
|
|
69
|
+
this.touchFile(filePath);
|
|
70
|
+
this.preloadFile(filePath);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
onFileEdited(filePath: string, diff: string, linesChanged: number[] = []): void {
|
|
74
|
+
this.ensureContext();
|
|
75
|
+
this.touchFile(filePath);
|
|
76
|
+
this.recordChange(filePath, diff, linesChanged);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private touchFile(filePath: string): void {
|
|
80
|
+
if (!this.current) return;
|
|
81
|
+
|
|
82
|
+
// Normalize path to relative
|
|
83
|
+
const relativePath = this.toRelativePath(filePath);
|
|
84
|
+
const existing = this.current.files.find(f => f.path === relativePath);
|
|
85
|
+
|
|
86
|
+
if (existing) {
|
|
87
|
+
existing.lastTouched = new Date();
|
|
88
|
+
existing.touchCount++;
|
|
89
|
+
} else {
|
|
90
|
+
this.current.files.push({
|
|
91
|
+
path: relativePath,
|
|
92
|
+
lastTouched: new Date(),
|
|
93
|
+
touchCount: 1,
|
|
94
|
+
recentLines: []
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Trim if over limit - keep most touched files
|
|
98
|
+
if (this.current.files.length > MAX_FILES) {
|
|
99
|
+
this.current.files = this.current.files
|
|
100
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
101
|
+
.slice(0, MAX_FILES);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.current.lastActiveAt = new Date();
|
|
106
|
+
this.save();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ========== CHANGE TRACKING ==========
|
|
110
|
+
|
|
111
|
+
private recordChange(filePath: string, diff: string, linesChanged: number[]): void {
|
|
112
|
+
if (!this.current) return;
|
|
113
|
+
|
|
114
|
+
const relativePath = this.toRelativePath(filePath);
|
|
115
|
+
|
|
116
|
+
this.current.changes.unshift({
|
|
117
|
+
file: relativePath,
|
|
118
|
+
timestamp: new Date(),
|
|
119
|
+
diff: diff.slice(0, 200), // Limit diff size
|
|
120
|
+
linesChanged
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Trim old changes
|
|
124
|
+
if (this.current.changes.length > MAX_CHANGES) {
|
|
125
|
+
this.current.changes = this.current.changes.slice(0, MAX_CHANGES);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Update file's recent lines
|
|
129
|
+
const fileEntry = this.current.files.find(f => f.path === relativePath);
|
|
130
|
+
if (fileEntry && linesChanged.length > 0) {
|
|
131
|
+
fileEntry.recentLines = linesChanged.slice(0, 10);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.save();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ========== QUERY TRACKING ==========
|
|
138
|
+
|
|
139
|
+
onQuery(query: string, filesUsed: string[]): void {
|
|
140
|
+
this.ensureContext();
|
|
141
|
+
if (!this.current) return;
|
|
142
|
+
|
|
143
|
+
const relativeFiles = filesUsed.map(f => this.toRelativePath(f));
|
|
144
|
+
|
|
145
|
+
this.current.queries.unshift({
|
|
146
|
+
query,
|
|
147
|
+
timestamp: new Date(),
|
|
148
|
+
filesUsed: relativeFiles
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Trim old queries
|
|
152
|
+
if (this.current.queries.length > MAX_QUERIES) {
|
|
153
|
+
this.current.queries = this.current.queries.slice(0, MAX_QUERIES);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Touch files that were used
|
|
157
|
+
relativeFiles.forEach(f => this.touchFile(f));
|
|
158
|
+
|
|
159
|
+
this.current.lastActiveAt = new Date();
|
|
160
|
+
this.save();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ========== HOT CACHE ==========
|
|
164
|
+
|
|
165
|
+
private async preloadFile(filePath: string): Promise<void> {
|
|
166
|
+
const relativePath = this.toRelativePath(filePath);
|
|
167
|
+
|
|
168
|
+
if (this.fileContents.has(relativePath)) return;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const absolutePath = this.toAbsolutePath(relativePath);
|
|
172
|
+
const content = readFileSync(absolutePath, 'utf-8');
|
|
173
|
+
this.fileContents.set(relativePath, content);
|
|
174
|
+
|
|
175
|
+
// Trim cache if too large
|
|
176
|
+
if (this.fileContents.size > HOT_CACHE_MAX_FILES && this.current) {
|
|
177
|
+
// Find least recently touched file and remove it
|
|
178
|
+
const filesInContext = new Set(this.current.files.map(f => f.path));
|
|
179
|
+
for (const [path] of this.fileContents) {
|
|
180
|
+
if (!filesInContext.has(path)) {
|
|
181
|
+
this.fileContents.delete(path);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// File might not exist or be unreadable
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getFileContent(filePath: string): string | null {
|
|
192
|
+
const relativePath = this.toRelativePath(filePath);
|
|
193
|
+
return this.fileContents.get(relativePath) || null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ========== CONTEXT RETRIEVAL ==========
|
|
197
|
+
|
|
198
|
+
getHotContext(): HotContext {
|
|
199
|
+
if (!this.current) {
|
|
200
|
+
return { files: [], changes: [], queries: [], summary: '' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Rank files by touchCount * recency
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const rankedFiles = this.current.files
|
|
206
|
+
.map(f => ({
|
|
207
|
+
...f,
|
|
208
|
+
score: f.touchCount * (1 / (now - f.lastTouched.getTime() + 1))
|
|
209
|
+
}))
|
|
210
|
+
.sort((a, b) => b.score - a.score)
|
|
211
|
+
.slice(0, 10);
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
files: rankedFiles.map(f => ({
|
|
215
|
+
path: f.path,
|
|
216
|
+
content: this.fileContents.get(f.path) || null,
|
|
217
|
+
touchCount: f.touchCount
|
|
218
|
+
})),
|
|
219
|
+
changes: this.current.changes.slice(0, 10),
|
|
220
|
+
queries: this.current.queries.slice(0, 5),
|
|
221
|
+
summary: this.generateSummary()
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private generateSummary(): string {
|
|
226
|
+
if (!this.current) return '';
|
|
227
|
+
|
|
228
|
+
const topFiles = this.current.files
|
|
229
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
230
|
+
.slice(0, 5)
|
|
231
|
+
.map(f => basename(f.path))
|
|
232
|
+
.join(', ');
|
|
233
|
+
|
|
234
|
+
const recentChanges = this.current.changes.length;
|
|
235
|
+
const durationMs = Date.now() - this.current.startedAt.getTime();
|
|
236
|
+
const durationMin = Math.round(durationMs / 60000);
|
|
237
|
+
|
|
238
|
+
if (topFiles) {
|
|
239
|
+
return `Working on: ${topFiles} | ${recentChanges} changes | ${durationMin} min`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return `Session active | ${durationMin} min`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
getCurrentContext(): ActiveFeatureContext | null {
|
|
246
|
+
return this.current;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
getRecentContexts(): ActiveFeatureContext[] {
|
|
250
|
+
return this.recent;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
getCurrentSummary(): { name: string; files: number; changes: number; duration: number } | null {
|
|
254
|
+
if (!this.current) return null;
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
name: this.current.name,
|
|
258
|
+
files: this.current.files.length,
|
|
259
|
+
changes: this.current.changes.length,
|
|
260
|
+
duration: Math.round((Date.now() - this.current.startedAt.getTime()) / 60000)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ========== CONTEXT MANAGEMENT ==========
|
|
265
|
+
|
|
266
|
+
private ensureContext(): void {
|
|
267
|
+
if (!this.current || this.current.status !== 'active') {
|
|
268
|
+
this.startNewContext();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
startNewContext(name?: string): ActiveFeatureContext {
|
|
273
|
+
// Save current context to recent
|
|
274
|
+
if (this.current) {
|
|
275
|
+
this.current.status = 'paused';
|
|
276
|
+
this.recent.unshift(this.current);
|
|
277
|
+
this.recent = this.recent.slice(0, MAX_RECENT_CONTEXTS);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
this.current = {
|
|
281
|
+
id: randomUUID(),
|
|
282
|
+
name: name || 'Untitled Feature',
|
|
283
|
+
files: [],
|
|
284
|
+
changes: [],
|
|
285
|
+
queries: [],
|
|
286
|
+
startedAt: new Date(),
|
|
287
|
+
lastActiveAt: new Date(),
|
|
288
|
+
status: 'active'
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Clear hot cache for new context
|
|
292
|
+
this.fileContents.clear();
|
|
293
|
+
this.save();
|
|
294
|
+
|
|
295
|
+
this.emit('context-started', this.current);
|
|
296
|
+
return this.current;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
setContextName(name: string): boolean {
|
|
300
|
+
if (!this.current) return false;
|
|
301
|
+
this.current.name = name;
|
|
302
|
+
this.save();
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
switchToRecent(contextId: string): boolean {
|
|
307
|
+
const found = this.recent.find(c => c.id === contextId);
|
|
308
|
+
if (!found) return false;
|
|
309
|
+
|
|
310
|
+
// Save current to recent
|
|
311
|
+
if (this.current) {
|
|
312
|
+
this.current.status = 'paused';
|
|
313
|
+
this.recent.unshift(this.current);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Remove found from recent and make it current
|
|
317
|
+
this.recent = this.recent.filter(c => c.id !== contextId);
|
|
318
|
+
this.current = found;
|
|
319
|
+
this.current.status = 'active';
|
|
320
|
+
this.current.lastActiveAt = new Date();
|
|
321
|
+
|
|
322
|
+
// Reload files into hot cache
|
|
323
|
+
this.reloadFiles();
|
|
324
|
+
this.save();
|
|
325
|
+
|
|
326
|
+
this.emit('context-switched', this.current);
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private reloadFiles(): void {
|
|
331
|
+
this.fileContents.clear();
|
|
332
|
+
if (!this.current) return;
|
|
333
|
+
|
|
334
|
+
// Preload top 10 files
|
|
335
|
+
const topFiles = this.current.files
|
|
336
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
337
|
+
.slice(0, 10);
|
|
338
|
+
|
|
339
|
+
for (const file of topFiles) {
|
|
340
|
+
this.preloadFile(file.path);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
completeContext(): boolean {
|
|
345
|
+
if (!this.current) return false;
|
|
346
|
+
this.current.status = 'completed';
|
|
347
|
+
this.recent.unshift(this.current);
|
|
348
|
+
this.recent = this.recent.slice(0, MAX_RECENT_CONTEXTS);
|
|
349
|
+
this.current = null;
|
|
350
|
+
this.fileContents.clear();
|
|
351
|
+
this.save();
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ========== AUTO MANAGEMENT ==========
|
|
356
|
+
|
|
357
|
+
private startInactivityTimer(): void {
|
|
358
|
+
if (this.inactivityTimer) {
|
|
359
|
+
clearInterval(this.inactivityTimer);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
this.inactivityTimer = setInterval(() => {
|
|
363
|
+
if (!this.current) return;
|
|
364
|
+
|
|
365
|
+
const inactiveMs = Date.now() - this.current.lastActiveAt.getTime();
|
|
366
|
+
if (inactiveMs > TTL_MINUTES * 60 * 1000) {
|
|
367
|
+
this.current.status = 'paused';
|
|
368
|
+
this.emit('context-paused', this.current);
|
|
369
|
+
this.save();
|
|
370
|
+
}
|
|
371
|
+
}, 60000); // Check every minute
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ========== PERSISTENCE ==========
|
|
375
|
+
|
|
376
|
+
private save(): void {
|
|
377
|
+
try {
|
|
378
|
+
const dir = dirname(this.persistPath);
|
|
379
|
+
if (!existsSync(dir)) {
|
|
380
|
+
mkdirSync(dir, { recursive: true });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const data = {
|
|
384
|
+
current: this.current,
|
|
385
|
+
recent: this.recent
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
writeFileSync(this.persistPath, JSON.stringify(data, null, 2));
|
|
389
|
+
} catch (error) {
|
|
390
|
+
console.error('Error saving feature context:', error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private load(): void {
|
|
395
|
+
try {
|
|
396
|
+
if (existsSync(this.persistPath)) {
|
|
397
|
+
const data = JSON.parse(readFileSync(this.persistPath, 'utf-8'));
|
|
398
|
+
|
|
399
|
+
// Parse dates in current context
|
|
400
|
+
if (data.current) {
|
|
401
|
+
this.current = this.parseContext(data.current);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Parse dates in recent contexts
|
|
405
|
+
if (data.recent) {
|
|
406
|
+
this.recent = data.recent.map((c: ActiveFeatureContext) => this.parseContext(c));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Reload files if we have a current context
|
|
410
|
+
if (this.current && this.current.status === 'active') {
|
|
411
|
+
this.reloadFiles();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error('Error loading feature context:', error);
|
|
416
|
+
this.current = null;
|
|
417
|
+
this.recent = [];
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private parseContext(data: ActiveFeatureContext): ActiveFeatureContext {
|
|
422
|
+
return {
|
|
423
|
+
...data,
|
|
424
|
+
startedAt: new Date(data.startedAt),
|
|
425
|
+
lastActiveAt: new Date(data.lastActiveAt),
|
|
426
|
+
files: data.files.map(f => ({
|
|
427
|
+
...f,
|
|
428
|
+
lastTouched: new Date(f.lastTouched)
|
|
429
|
+
})),
|
|
430
|
+
changes: data.changes.map(c => ({
|
|
431
|
+
...c,
|
|
432
|
+
timestamp: new Date(c.timestamp)
|
|
433
|
+
})),
|
|
434
|
+
queries: data.queries.map(q => ({
|
|
435
|
+
...q,
|
|
436
|
+
timestamp: new Date(q.timestamp)
|
|
437
|
+
}))
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ========== PATH UTILITIES ==========
|
|
442
|
+
|
|
443
|
+
private toRelativePath(filePath: string): string {
|
|
444
|
+
if (filePath.startsWith(this.projectPath)) {
|
|
445
|
+
return filePath.slice(this.projectPath.length).replace(/^[\/\\]/, '');
|
|
446
|
+
}
|
|
447
|
+
return filePath;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private toAbsolutePath(relativePath: string): string {
|
|
451
|
+
if (relativePath.startsWith(this.projectPath)) {
|
|
452
|
+
return relativePath;
|
|
453
|
+
}
|
|
454
|
+
return join(this.projectPath, relativePath);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ========== CONTEXT RESURRECTION ==========
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Resurrect context from last session - restore mental state
|
|
461
|
+
* Returns what you were working on, where you left off, and possible blockers
|
|
462
|
+
*/
|
|
463
|
+
resurrectContext(options: ContextResurrectionOptions = {}): ResurrectedContext {
|
|
464
|
+
const { featureName, maxFiles = 5 } = options;
|
|
465
|
+
|
|
466
|
+
// Find the context to resurrect
|
|
467
|
+
let contextToResurrect: ActiveFeatureContext | null = null;
|
|
468
|
+
|
|
469
|
+
if (featureName) {
|
|
470
|
+
// Find by name
|
|
471
|
+
contextToResurrect = this.recent.find(c =>
|
|
472
|
+
c.name.toLowerCase().includes(featureName.toLowerCase())
|
|
473
|
+
) || null;
|
|
474
|
+
|
|
475
|
+
if (!contextToResurrect && this.current?.name.toLowerCase().includes(featureName.toLowerCase())) {
|
|
476
|
+
contextToResurrect = this.current;
|
|
477
|
+
}
|
|
478
|
+
} else {
|
|
479
|
+
// Use most recent context (current or first in recent)
|
|
480
|
+
contextToResurrect = this.current || this.recent[0] || null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!contextToResurrect) {
|
|
484
|
+
return {
|
|
485
|
+
activeFiles: [],
|
|
486
|
+
lastQueries: [],
|
|
487
|
+
sessionDecisions: [],
|
|
488
|
+
lastEditedFile: null,
|
|
489
|
+
lastEditTime: null,
|
|
490
|
+
possibleBlocker: null,
|
|
491
|
+
suggestedActions: ['Start a new feature context with memory_record'],
|
|
492
|
+
summary: 'No previous context found to resurrect.',
|
|
493
|
+
timeSinceLastActive: 'N/A',
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Extract information from context
|
|
498
|
+
const activeFiles = contextToResurrect.files
|
|
499
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
500
|
+
.slice(0, maxFiles)
|
|
501
|
+
.map(f => f.path);
|
|
502
|
+
|
|
503
|
+
const lastQueries = contextToResurrect.queries
|
|
504
|
+
.slice(0, 3)
|
|
505
|
+
.map(q => q.query);
|
|
506
|
+
|
|
507
|
+
// Find last edited file (most recent change)
|
|
508
|
+
const lastChange = contextToResurrect.changes[0];
|
|
509
|
+
const lastEditedFile = lastChange?.file || null;
|
|
510
|
+
const lastEditTime = lastChange?.timestamp || null;
|
|
511
|
+
|
|
512
|
+
// Detect possible blocker
|
|
513
|
+
const possibleBlocker = this.detectBlocker(contextToResurrect);
|
|
514
|
+
|
|
515
|
+
// Generate suggested actions
|
|
516
|
+
const suggestedActions = this.suggestNextSteps(contextToResurrect);
|
|
517
|
+
|
|
518
|
+
// Calculate time since last active
|
|
519
|
+
const timeSinceLastActive = this.formatTimeSince(contextToResurrect.lastActiveAt);
|
|
520
|
+
|
|
521
|
+
// Generate summary
|
|
522
|
+
const summary = this.generateResurrectionSummary(contextToResurrect, possibleBlocker);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
activeFiles,
|
|
526
|
+
lastQueries,
|
|
527
|
+
sessionDecisions: [], // Would need to cross-reference with decisions storage
|
|
528
|
+
lastEditedFile,
|
|
529
|
+
lastEditTime,
|
|
530
|
+
possibleBlocker,
|
|
531
|
+
suggestedActions,
|
|
532
|
+
summary,
|
|
533
|
+
timeSinceLastActive,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Detect what might have been blocking progress when session ended
|
|
539
|
+
*/
|
|
540
|
+
private detectBlocker(context: ActiveFeatureContext): string | null {
|
|
541
|
+
if (context.queries.length === 0) {
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Check last few queries for error/problem patterns
|
|
546
|
+
const errorPatterns = [
|
|
547
|
+
/error/i,
|
|
548
|
+
/fix/i,
|
|
549
|
+
/bug/i,
|
|
550
|
+
/issue/i,
|
|
551
|
+
/problem/i,
|
|
552
|
+
/not working/i,
|
|
553
|
+
/doesn't work/i,
|
|
554
|
+
/how to/i,
|
|
555
|
+
/why/i,
|
|
556
|
+
/failed/i,
|
|
557
|
+
/broken/i,
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
// Check last 3 queries
|
|
561
|
+
for (let i = 0; i < Math.min(3, context.queries.length); i++) {
|
|
562
|
+
const query = context.queries[i];
|
|
563
|
+
if (!query) continue;
|
|
564
|
+
|
|
565
|
+
for (const pattern of errorPatterns) {
|
|
566
|
+
if (pattern.test(query.query)) {
|
|
567
|
+
return query.query;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check if the same file was touched many times (sign of struggling)
|
|
573
|
+
const fileTouchCounts = new Map<string, number>();
|
|
574
|
+
for (const change of context.changes.slice(0, 10)) {
|
|
575
|
+
const count = fileTouchCounts.get(change.file) || 0;
|
|
576
|
+
fileTouchCounts.set(change.file, count + 1);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
for (const [file, count] of fileTouchCounts.entries()) {
|
|
580
|
+
if (count >= 5) {
|
|
581
|
+
return `Multiple edits to ${basename(file)} - possible implementation challenge`;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Suggest next steps based on context state
|
|
590
|
+
*/
|
|
591
|
+
private suggestNextSteps(context: ActiveFeatureContext): string[] {
|
|
592
|
+
const suggestions: string[] = [];
|
|
593
|
+
|
|
594
|
+
// If there was a potential blocker, suggest addressing it
|
|
595
|
+
const blocker = this.detectBlocker(context);
|
|
596
|
+
if (blocker) {
|
|
597
|
+
suggestions.push(`Resume investigating: "${blocker.slice(0, 50)}..."`);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Suggest continuing with most-touched files
|
|
601
|
+
const topFiles = context.files
|
|
602
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
603
|
+
.slice(0, 2);
|
|
604
|
+
|
|
605
|
+
if (topFiles.length > 0 && topFiles[0]) {
|
|
606
|
+
suggestions.push(`Continue working on ${basename(topFiles[0].path)}`);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// If there were recent changes, suggest reviewing them
|
|
610
|
+
if (context.changes.length > 0) {
|
|
611
|
+
suggestions.push('Review recent changes before continuing');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Generic suggestions if nothing specific
|
|
615
|
+
if (suggestions.length === 0) {
|
|
616
|
+
suggestions.push(`Continue "${context.name}" feature work`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return suggestions;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Format time since a date in human-readable form
|
|
624
|
+
*/
|
|
625
|
+
private formatTimeSince(date: Date): string {
|
|
626
|
+
const now = new Date();
|
|
627
|
+
const diffMs = now.getTime() - date.getTime();
|
|
628
|
+
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
629
|
+
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
630
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
631
|
+
|
|
632
|
+
if (diffMins < 60) {
|
|
633
|
+
return `${diffMins} minutes ago`;
|
|
634
|
+
} else if (diffHours < 24) {
|
|
635
|
+
return `${diffHours} hours ago`;
|
|
636
|
+
} else if (diffDays === 1) {
|
|
637
|
+
return 'yesterday';
|
|
638
|
+
} else if (diffDays < 7) {
|
|
639
|
+
return `${diffDays} days ago`;
|
|
640
|
+
} else if (diffDays < 30) {
|
|
641
|
+
const weeks = Math.floor(diffDays / 7);
|
|
642
|
+
return `${weeks} week${weeks > 1 ? 's' : ''} ago`;
|
|
643
|
+
} else {
|
|
644
|
+
const months = Math.floor(diffDays / 30);
|
|
645
|
+
return `${months} month${months > 1 ? 's' : ''} ago`;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Generate a summary of the resurrection context for AI
|
|
651
|
+
*/
|
|
652
|
+
private generateResurrectionSummary(context: ActiveFeatureContext, blocker: string | null): string {
|
|
653
|
+
const parts: string[] = [];
|
|
654
|
+
|
|
655
|
+
// Feature name and duration
|
|
656
|
+
const durationMs = context.lastActiveAt.getTime() - context.startedAt.getTime();
|
|
657
|
+
const durationMins = Math.round(durationMs / 60000);
|
|
658
|
+
parts.push(`Feature: "${context.name}" (${durationMins} min session)`);
|
|
659
|
+
|
|
660
|
+
// Status
|
|
661
|
+
parts.push(`Status: ${context.status}`);
|
|
662
|
+
|
|
663
|
+
// Files worked on
|
|
664
|
+
if (context.files.length > 0) {
|
|
665
|
+
const topFiles = context.files
|
|
666
|
+
.sort((a, b) => b.touchCount - a.touchCount)
|
|
667
|
+
.slice(0, 3)
|
|
668
|
+
.map(f => basename(f.path));
|
|
669
|
+
parts.push(`Main files: ${topFiles.join(', ')}`);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Changes made
|
|
673
|
+
if (context.changes.length > 0) {
|
|
674
|
+
parts.push(`Changes: ${context.changes.length} edits`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Blocker
|
|
678
|
+
if (blocker) {
|
|
679
|
+
parts.push(`Possible blocker: "${blocker.slice(0, 50)}..."`);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Last activity
|
|
683
|
+
parts.push(`Last active: ${this.formatTimeSince(context.lastActiveAt)}`);
|
|
684
|
+
|
|
685
|
+
return parts.join(' | ');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Get all contexts that can be resurrected
|
|
690
|
+
*/
|
|
691
|
+
getResurrectableContexts(): Array<{ id: string; name: string; lastActive: Date; summary: string }> {
|
|
692
|
+
const contexts: Array<{ id: string; name: string; lastActive: Date; summary: string }> = [];
|
|
693
|
+
|
|
694
|
+
// Include current if paused
|
|
695
|
+
if (this.current && this.current.status === 'paused') {
|
|
696
|
+
contexts.push({
|
|
697
|
+
id: this.current.id,
|
|
698
|
+
name: this.current.name,
|
|
699
|
+
lastActive: this.current.lastActiveAt,
|
|
700
|
+
summary: this.generateResurrectionSummary(this.current, null),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Include recent contexts
|
|
705
|
+
for (const ctx of this.recent) {
|
|
706
|
+
contexts.push({
|
|
707
|
+
id: ctx.id,
|
|
708
|
+
name: ctx.name,
|
|
709
|
+
lastActive: ctx.lastActiveAt,
|
|
710
|
+
summary: this.generateResurrectionSummary(ctx, null),
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return contexts.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ========== CLEANUP ==========
|
|
718
|
+
|
|
719
|
+
shutdown(): void {
|
|
720
|
+
if (this.inactivityTimer) {
|
|
721
|
+
clearInterval(this.inactivityTimer);
|
|
722
|
+
this.inactivityTimer = null;
|
|
723
|
+
}
|
|
724
|
+
this.save();
|
|
725
|
+
}
|
|
726
|
+
}
|