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,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Déjà Vu Detection - Similar Problem Recognition
|
|
3
|
+
*
|
|
4
|
+
* Detects when a query or code pattern is similar to something solved before.
|
|
5
|
+
* Surfaces past solutions to prevent reinventing the wheel.
|
|
6
|
+
* "You solved a similar problem 2 weeks ago in auth.ts"
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type Database from 'better-sqlite3';
|
|
10
|
+
import type { EmbeddingGenerator } from '../indexing/embeddings.js';
|
|
11
|
+
import type { Tier2Storage } from '../storage/tier2.js';
|
|
12
|
+
import { createHash } from 'crypto';
|
|
13
|
+
import { formatTimeAgoWithContext } from '../utils/time.js';
|
|
14
|
+
|
|
15
|
+
export interface DejaVuMatch {
|
|
16
|
+
type: 'query' | 'solution' | 'fix' | 'pattern';
|
|
17
|
+
similarity: number;
|
|
18
|
+
when: Date;
|
|
19
|
+
file: string;
|
|
20
|
+
snippet: string;
|
|
21
|
+
message: string;
|
|
22
|
+
context?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PastQuery {
|
|
26
|
+
queryHash: string;
|
|
27
|
+
queryText: string;
|
|
28
|
+
resultFiles: string[];
|
|
29
|
+
timestamp: Date;
|
|
30
|
+
wasUseful: boolean;
|
|
31
|
+
usefulness: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PastSolution {
|
|
35
|
+
file: string;
|
|
36
|
+
snippet: string;
|
|
37
|
+
query: string;
|
|
38
|
+
timestamp: Date;
|
|
39
|
+
similarity: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface QueryPatternRow {
|
|
43
|
+
id: number;
|
|
44
|
+
query_hash: string;
|
|
45
|
+
query_text: string;
|
|
46
|
+
result_files: string;
|
|
47
|
+
hit_count: number;
|
|
48
|
+
avg_usefulness: number;
|
|
49
|
+
last_used: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface UsageEventRow {
|
|
53
|
+
id: number;
|
|
54
|
+
event_type: string;
|
|
55
|
+
file_path: string | null;
|
|
56
|
+
query: string | null;
|
|
57
|
+
context_used: number;
|
|
58
|
+
timestamp: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class DejaVuDetector {
|
|
62
|
+
private db: Database.Database;
|
|
63
|
+
private tier2: Tier2Storage;
|
|
64
|
+
private embeddingGenerator: EmbeddingGenerator;
|
|
65
|
+
|
|
66
|
+
// Thresholds
|
|
67
|
+
private readonly SIMILARITY_THRESHOLD = 0.7;
|
|
68
|
+
private readonly MIN_USEFULNESS = 0.3;
|
|
69
|
+
private readonly MAX_AGE_DAYS = 90;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
db: Database.Database,
|
|
73
|
+
tier2: Tier2Storage,
|
|
74
|
+
embeddingGenerator: EmbeddingGenerator
|
|
75
|
+
) {
|
|
76
|
+
this.db = db;
|
|
77
|
+
this.tier2 = tier2;
|
|
78
|
+
this.embeddingGenerator = embeddingGenerator;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Find similar past problems, solutions, and fixes
|
|
83
|
+
*/
|
|
84
|
+
async findSimilar(query: string, limit: number = 3): Promise<DejaVuMatch[]> {
|
|
85
|
+
const matches: DejaVuMatch[] = [];
|
|
86
|
+
|
|
87
|
+
// Run searches in parallel
|
|
88
|
+
const [pastQueries, pastSolutions, pastFixes] = await Promise.all([
|
|
89
|
+
this.searchPastQueries(query),
|
|
90
|
+
this.searchPastSolutions(query),
|
|
91
|
+
this.searchPastFixes(query),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
matches.push(...pastQueries, ...pastSolutions, ...pastFixes);
|
|
95
|
+
|
|
96
|
+
// Sort by similarity and recency
|
|
97
|
+
return matches
|
|
98
|
+
.sort((a, b) => {
|
|
99
|
+
// Weight similarity higher, but give bonus to more recent
|
|
100
|
+
const recencyA = this.getRecencyScore(a.when);
|
|
101
|
+
const recencyB = this.getRecencyScore(b.when);
|
|
102
|
+
const scoreA = a.similarity * 0.7 + recencyA * 0.3;
|
|
103
|
+
const scoreB = b.similarity * 0.7 + recencyB * 0.3;
|
|
104
|
+
return scoreB - scoreA;
|
|
105
|
+
})
|
|
106
|
+
.slice(0, limit);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Search for similar past queries with high usefulness
|
|
111
|
+
*/
|
|
112
|
+
async searchPastQueries(query: string): Promise<DejaVuMatch[]> {
|
|
113
|
+
const matches: DejaVuMatch[] = [];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Get query embedding
|
|
117
|
+
const embedding = await this.embeddingGenerator.embed(query);
|
|
118
|
+
|
|
119
|
+
// Get recent useful queries
|
|
120
|
+
const stmt = this.db.prepare(`
|
|
121
|
+
SELECT query_hash, query_text, result_files, avg_usefulness, last_used
|
|
122
|
+
FROM query_patterns
|
|
123
|
+
WHERE avg_usefulness >= ?
|
|
124
|
+
AND last_used > unixepoch() - ? * 86400
|
|
125
|
+
ORDER BY avg_usefulness DESC, last_used DESC
|
|
126
|
+
LIMIT 50
|
|
127
|
+
`);
|
|
128
|
+
|
|
129
|
+
const rows = stmt.all(this.MIN_USEFULNESS, this.MAX_AGE_DAYS) as QueryPatternRow[];
|
|
130
|
+
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
// Calculate text similarity
|
|
133
|
+
const similarity = this.calculateTextSimilarity(query, row.query_text);
|
|
134
|
+
|
|
135
|
+
if (similarity >= this.SIMILARITY_THRESHOLD) {
|
|
136
|
+
const resultFiles = this.parseJsonArray(row.result_files);
|
|
137
|
+
const primaryFile = resultFiles[0] || 'unknown';
|
|
138
|
+
|
|
139
|
+
matches.push({
|
|
140
|
+
type: 'query',
|
|
141
|
+
similarity,
|
|
142
|
+
when: new Date(row.last_used * 1000),
|
|
143
|
+
file: primaryFile,
|
|
144
|
+
snippet: row.query_text.slice(0, 100),
|
|
145
|
+
message: formatTimeAgoWithContext(new Date(row.last_used * 1000), 'asked a similar question', primaryFile),
|
|
146
|
+
context: resultFiles.length > 1 ? `Also involved: ${resultFiles.slice(1, 4).join(', ')}` : undefined,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
console.error('Error searching past queries:', error);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return matches;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Search for past solutions to similar problems
|
|
159
|
+
*/
|
|
160
|
+
async searchPastSolutions(query: string): Promise<DejaVuMatch[]> {
|
|
161
|
+
const matches: DejaVuMatch[] = [];
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
// Search codebase for semantically similar content
|
|
165
|
+
const embedding = await this.embeddingGenerator.embed(query);
|
|
166
|
+
const searchResults = this.tier2.search(embedding, 10);
|
|
167
|
+
|
|
168
|
+
// Find results that were previously part of useful context
|
|
169
|
+
for (const result of searchResults) {
|
|
170
|
+
if (result.similarity < this.SIMILARITY_THRESHOLD) continue;
|
|
171
|
+
|
|
172
|
+
// Check if this file was used in a useful query before
|
|
173
|
+
const usageStmt = this.db.prepare(`
|
|
174
|
+
SELECT ue.query, ue.timestamp, qp.avg_usefulness
|
|
175
|
+
FROM usage_events ue
|
|
176
|
+
LEFT JOIN query_patterns qp ON ue.query = qp.query_text
|
|
177
|
+
WHERE ue.file_path = ?
|
|
178
|
+
AND ue.event_type = 'context_used'
|
|
179
|
+
AND ue.timestamp > unixepoch() - ? * 86400
|
|
180
|
+
AND (qp.avg_usefulness IS NULL OR qp.avg_usefulness >= ?)
|
|
181
|
+
ORDER BY ue.timestamp DESC
|
|
182
|
+
LIMIT 1
|
|
183
|
+
`);
|
|
184
|
+
|
|
185
|
+
const usage = usageStmt.get(result.file, this.MAX_AGE_DAYS, this.MIN_USEFULNESS) as {
|
|
186
|
+
query: string;
|
|
187
|
+
timestamp: number;
|
|
188
|
+
avg_usefulness: number | null;
|
|
189
|
+
} | undefined;
|
|
190
|
+
|
|
191
|
+
if (usage) {
|
|
192
|
+
matches.push({
|
|
193
|
+
type: 'solution',
|
|
194
|
+
similarity: result.similarity,
|
|
195
|
+
when: new Date(usage.timestamp * 1000),
|
|
196
|
+
file: result.file,
|
|
197
|
+
snippet: result.preview.slice(0, 150),
|
|
198
|
+
message: formatTimeAgoWithContext(new Date(usage.timestamp * 1000), 'worked on this', result.file),
|
|
199
|
+
context: usage.query ? `For: "${usage.query.slice(0, 50)}..."` : undefined,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error('Error searching past solutions:', error);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return matches;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Search for past bug fixes with similar error patterns
|
|
212
|
+
*/
|
|
213
|
+
async searchPastFixes(query: string): Promise<DejaVuMatch[]> {
|
|
214
|
+
const matches: DejaVuMatch[] = [];
|
|
215
|
+
|
|
216
|
+
// Check if query looks like an error
|
|
217
|
+
if (!this.looksLikeError(query)) {
|
|
218
|
+
return matches;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
// Search for similar error queries
|
|
223
|
+
const stmt = this.db.prepare(`
|
|
224
|
+
SELECT ue.query, ue.file_path, ue.timestamp, qp.result_files
|
|
225
|
+
FROM usage_events ue
|
|
226
|
+
LEFT JOIN query_patterns qp ON ue.query = qp.query_text
|
|
227
|
+
WHERE ue.event_type = 'query'
|
|
228
|
+
AND (ue.query LIKE '%error%' OR ue.query LIKE '%fix%' OR ue.query LIKE '%bug%')
|
|
229
|
+
AND ue.timestamp > unixepoch() - ? * 86400
|
|
230
|
+
ORDER BY ue.timestamp DESC
|
|
231
|
+
LIMIT 100
|
|
232
|
+
`);
|
|
233
|
+
|
|
234
|
+
const rows = stmt.all(this.MAX_AGE_DAYS) as Array<{
|
|
235
|
+
query: string;
|
|
236
|
+
file_path: string | null;
|
|
237
|
+
timestamp: number;
|
|
238
|
+
result_files: string | null;
|
|
239
|
+
}>;
|
|
240
|
+
|
|
241
|
+
for (const row of rows) {
|
|
242
|
+
if (!row.query) continue;
|
|
243
|
+
|
|
244
|
+
const similarity = this.calculateTextSimilarity(query, row.query);
|
|
245
|
+
|
|
246
|
+
if (similarity >= this.SIMILARITY_THRESHOLD * 0.8) { // Slightly lower threshold for errors
|
|
247
|
+
const resultFiles = row.result_files ? this.parseJsonArray(row.result_files) : [];
|
|
248
|
+
const file = row.file_path || resultFiles[0] || 'unknown';
|
|
249
|
+
|
|
250
|
+
matches.push({
|
|
251
|
+
type: 'fix',
|
|
252
|
+
similarity,
|
|
253
|
+
when: new Date(row.timestamp * 1000),
|
|
254
|
+
file,
|
|
255
|
+
snippet: row.query.slice(0, 100),
|
|
256
|
+
message: formatTimeAgoWithContext(new Date(row.timestamp * 1000), 'encountered a similar issue', file),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error('Error searching past fixes:', error);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return matches;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Record that a query was made (for future déjà vu detection)
|
|
269
|
+
*/
|
|
270
|
+
recordQuery(query: string, files: string[], wasUseful?: boolean): void {
|
|
271
|
+
try {
|
|
272
|
+
const queryHash = this.hashQuery(query);
|
|
273
|
+
|
|
274
|
+
const stmt = this.db.prepare(`
|
|
275
|
+
INSERT INTO query_patterns (query_hash, query_text, result_files, hit_count, last_used)
|
|
276
|
+
VALUES (?, ?, ?, 1, unixepoch())
|
|
277
|
+
ON CONFLICT(query_hash) DO UPDATE SET
|
|
278
|
+
hit_count = hit_count + 1,
|
|
279
|
+
last_used = unixepoch()
|
|
280
|
+
`);
|
|
281
|
+
|
|
282
|
+
stmt.run(queryHash, query, JSON.stringify(files));
|
|
283
|
+
|
|
284
|
+
// Update usefulness if provided
|
|
285
|
+
if (wasUseful !== undefined) {
|
|
286
|
+
this.updateUsefulness(queryHash, wasUseful);
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
console.error('Error recording query:', error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Update usefulness score for a query
|
|
295
|
+
*/
|
|
296
|
+
updateUsefulness(queryHashOrText: string, wasUseful: boolean): void {
|
|
297
|
+
try {
|
|
298
|
+
const queryHash = queryHashOrText.length === 16
|
|
299
|
+
? queryHashOrText
|
|
300
|
+
: this.hashQuery(queryHashOrText);
|
|
301
|
+
|
|
302
|
+
// Exponential moving average
|
|
303
|
+
const alpha = 0.3;
|
|
304
|
+
const newScore = wasUseful ? 1.0 : 0.0;
|
|
305
|
+
|
|
306
|
+
const stmt = this.db.prepare(`
|
|
307
|
+
UPDATE query_patterns
|
|
308
|
+
SET avg_usefulness = avg_usefulness * (1 - ?) + ? * ?
|
|
309
|
+
WHERE query_hash = ?
|
|
310
|
+
`);
|
|
311
|
+
|
|
312
|
+
stmt.run(alpha, alpha, newScore, queryHash);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.error('Error updating usefulness:', error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get déjà vu statistics
|
|
320
|
+
*/
|
|
321
|
+
getStats(): { totalQueries: number; usefulQueries: number; avgUsefulness: number } {
|
|
322
|
+
try {
|
|
323
|
+
const stmt = this.db.prepare(`
|
|
324
|
+
SELECT
|
|
325
|
+
COUNT(*) as total,
|
|
326
|
+
SUM(CASE WHEN avg_usefulness >= ? THEN 1 ELSE 0 END) as useful,
|
|
327
|
+
AVG(avg_usefulness) as avg_useful
|
|
328
|
+
FROM query_patterns
|
|
329
|
+
WHERE last_used > unixepoch() - ? * 86400
|
|
330
|
+
`);
|
|
331
|
+
|
|
332
|
+
const result = stmt.get(this.MIN_USEFULNESS, this.MAX_AGE_DAYS) as {
|
|
333
|
+
total: number;
|
|
334
|
+
useful: number;
|
|
335
|
+
avg_useful: number;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
totalQueries: result.total || 0,
|
|
340
|
+
usefulQueries: result.useful || 0,
|
|
341
|
+
avgUsefulness: result.avg_useful || 0,
|
|
342
|
+
};
|
|
343
|
+
} catch {
|
|
344
|
+
return { totalQueries: 0, usefulQueries: 0, avgUsefulness: 0 };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ========== Private Methods ==========
|
|
349
|
+
|
|
350
|
+
private calculateTextSimilarity(a: string, b: string): number {
|
|
351
|
+
// Normalize texts
|
|
352
|
+
const normalizeText = (text: string) =>
|
|
353
|
+
text.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w.length > 2);
|
|
354
|
+
|
|
355
|
+
const wordsA = new Set(normalizeText(a));
|
|
356
|
+
const wordsB = new Set(normalizeText(b));
|
|
357
|
+
|
|
358
|
+
if (wordsA.size === 0 || wordsB.size === 0) {
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Jaccard similarity
|
|
363
|
+
let intersection = 0;
|
|
364
|
+
for (const word of wordsA) {
|
|
365
|
+
if (wordsB.has(word)) {
|
|
366
|
+
intersection++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
371
|
+
return intersection / union;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private looksLikeError(query: string): boolean {
|
|
375
|
+
const errorPatterns = [
|
|
376
|
+
/error/i,
|
|
377
|
+
/exception/i,
|
|
378
|
+
/failed/i,
|
|
379
|
+
/failing/i,
|
|
380
|
+
/broken/i,
|
|
381
|
+
/fix/i,
|
|
382
|
+
/bug/i,
|
|
383
|
+
/issue/i,
|
|
384
|
+
/not working/i,
|
|
385
|
+
/doesn't work/i,
|
|
386
|
+
/crash/i,
|
|
387
|
+
/undefined/i,
|
|
388
|
+
/null/i,
|
|
389
|
+
/NaN/i,
|
|
390
|
+
/TypeError/i,
|
|
391
|
+
/ReferenceError/i,
|
|
392
|
+
/SyntaxError/i,
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
return errorPatterns.some(pattern => pattern.test(query));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private getRecencyScore(date: Date): number {
|
|
399
|
+
const now = new Date();
|
|
400
|
+
const diffMs = now.getTime() - date.getTime();
|
|
401
|
+
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
402
|
+
|
|
403
|
+
// Exponential decay: score of 1 for today, approaching 0 at MAX_AGE_DAYS
|
|
404
|
+
return Math.exp(-diffDays / (this.MAX_AGE_DAYS / 3));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private parseJsonArray(json: string | null): string[] {
|
|
408
|
+
if (!json) return [];
|
|
409
|
+
try {
|
|
410
|
+
const parsed = JSON.parse(json);
|
|
411
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
412
|
+
} catch {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private hashQuery(query: string): string {
|
|
418
|
+
const normalized = query.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
419
|
+
return createHash('md5').update(normalized).digest('hex').slice(0, 16);
|
|
420
|
+
}
|
|
421
|
+
}
|