@imayuur/contexthub-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ "use strict";
2
+ /**
3
+ * MemoryStorage — Secure, encrypted file-based storage for ContextHub
4
+ *
5
+ * Security features:
6
+ * - AES-256-GCM encryption at rest for all JSON data files
7
+ * - Atomic writes (write to .tmp, then rename) to prevent corruption
8
+ * - In-process mutex to prevent race conditions on concurrent access
9
+ * - File size limits to prevent OOM/DoS
10
+ * - Entry count caps with automatic archival
11
+ * - Secure file permissions (0600 for files, 0700 for directories)
12
+ * - Automatic migration from plaintext to encrypted format
13
+ */
14
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ var desc = Object.getOwnPropertyDescriptor(m, k);
17
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
18
+ desc = { enumerable: true, get: function() { return m[k]; } };
19
+ }
20
+ Object.defineProperty(o, k2, desc);
21
+ }) : (function(o, m, k, k2) {
22
+ if (k2 === undefined) k2 = k;
23
+ o[k2] = m[k];
24
+ }));
25
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
26
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
27
+ }) : function(o, v) {
28
+ o["default"] = v;
29
+ });
30
+ var __importStar = (this && this.__importStar) || (function () {
31
+ var ownKeys = function(o) {
32
+ ownKeys = Object.getOwnPropertyNames || function (o) {
33
+ var ar = [];
34
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
35
+ return ar;
36
+ };
37
+ return ownKeys(o);
38
+ };
39
+ return function (mod) {
40
+ if (mod && mod.__esModule) return mod;
41
+ var result = {};
42
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
43
+ __setModuleDefault(result, mod);
44
+ return result;
45
+ };
46
+ })();
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.MemoryStorage = void 0;
49
+ const crypto = __importStar(require("crypto"));
50
+ const path = __importStar(require("path"));
51
+ const fs = __importStar(require("fs"));
52
+ const security_1 = require("./security");
53
+ // Simple in-process async mutex for file locking
54
+ class Mutex {
55
+ constructor() {
56
+ this.queue = [];
57
+ this.locked = false;
58
+ }
59
+ async acquire() {
60
+ return new Promise((resolve) => {
61
+ const tryAcquire = () => {
62
+ if (!this.locked) {
63
+ this.locked = true;
64
+ resolve(() => this.release());
65
+ }
66
+ else {
67
+ this.queue.push(tryAcquire);
68
+ }
69
+ };
70
+ tryAcquire();
71
+ });
72
+ }
73
+ release() {
74
+ this.locked = false;
75
+ const next = this.queue.shift();
76
+ if (next)
77
+ next();
78
+ }
79
+ }
80
+ class MemoryStorage {
81
+ constructor(repoPath) {
82
+ this.repoPath = repoPath;
83
+ this.contexthubPath = path.join(repoPath, '.contexthub');
84
+ this.sessionsPath = path.join(this.contexthubPath, 'sessions.json');
85
+ this.memoriesPath = path.join(this.contexthubPath, 'memories.json');
86
+ this.projectMetadataPath = path.join(this.contexthubPath, 'project-metadata.json');
87
+ this.security = new security_1.SecurityManager(repoPath);
88
+ this.mutex = new Mutex();
89
+ // Create .contexthub directory if it doesn't exist (with secure permissions)
90
+ if (!fs.existsSync(this.contexthubPath)) {
91
+ fs.mkdirSync(this.contexthubPath, { recursive: true });
92
+ this.security.setSecurePermissions(this.contexthubPath, true);
93
+ }
94
+ // Initialize files if they don't exist
95
+ this.initFile(this.sessionsPath, []);
96
+ this.initFile(this.memoriesPath, []);
97
+ this.initFile(this.projectMetadataPath, null);
98
+ }
99
+ // ── File I/O (Encrypted + Atomic) ──────────────────────────────────────
100
+ initFile(filePath, defaultContent) {
101
+ if (!fs.existsSync(filePath)) {
102
+ this.writeJSONFileSync(filePath, defaultContent);
103
+ }
104
+ }
105
+ /**
106
+ * Read and decrypt a JSON file from disk.
107
+ * Handles both encrypted and plaintext formats (for migration).
108
+ */
109
+ readJSONFile(filePath) {
110
+ // Check file size before reading
111
+ this.security.checkFileSize(filePath);
112
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
113
+ // Empty file — return default
114
+ if (raw.length === 0) {
115
+ return [];
116
+ }
117
+ // Try parsing as plaintext JSON first (for migration / backwards compat)
118
+ if (raw.startsWith('[') || raw.startsWith('{') || raw === 'null') {
119
+ try {
120
+ const parsed = JSON.parse(raw);
121
+ // Auto-migrate: re-write as encrypted
122
+ this.writeJSONFileSync(filePath, parsed);
123
+ return parsed;
124
+ }
125
+ catch {
126
+ // Not valid JSON — try decrypting
127
+ }
128
+ }
129
+ // Decrypt
130
+ try {
131
+ const decrypted = this.security.decrypt(raw);
132
+ return JSON.parse(decrypted);
133
+ }
134
+ catch (e) {
135
+ throw new Error(`Failed to read data file (may be corrupted): ${path.basename(filePath)}`);
136
+ }
137
+ }
138
+ /**
139
+ * Encrypt and write JSON data to disk using atomic write.
140
+ * Writes to a .tmp file first, then renames — prevents corruption on crash.
141
+ */
142
+ writeJSONFileSync(filePath, data) {
143
+ const jsonStr = JSON.stringify(data, null, 2);
144
+ const encrypted = this.security.encrypt(jsonStr);
145
+ const tmpPath = filePath + `.tmp.${crypto.randomBytes(4).toString('hex')}`;
146
+ try {
147
+ fs.writeFileSync(tmpPath, encrypted, { mode: 0o600 });
148
+ fs.renameSync(tmpPath, filePath);
149
+ }
150
+ catch (e) {
151
+ // Clean up temp file on error
152
+ try {
153
+ fs.unlinkSync(tmpPath);
154
+ }
155
+ catch { /* ignore */ }
156
+ throw e;
157
+ }
158
+ }
159
+ // ── Session Management ─────────────────────────────────────────────────
160
+ async createSession(agent, metadata = {}) {
161
+ const release = await this.mutex.acquire();
162
+ try {
163
+ const id = crypto.randomUUID();
164
+ const startTime = Date.now();
165
+ // Sanitize inputs
166
+ const sanitizedAgent = this.security.sanitizeInput(agent, 100);
167
+ const session = {
168
+ id,
169
+ repoPath: this.repoPath,
170
+ startTime,
171
+ agent: sanitizedAgent,
172
+ metadata
173
+ };
174
+ const sessions = this.readJSONFile(this.sessionsPath);
175
+ sessions.push(session);
176
+ // Cap sessions at max entries
177
+ const capped = sessions.length > this.security.maxMemoryEntries
178
+ ? sessions.slice(-this.security.maxMemoryEntries)
179
+ : sessions;
180
+ this.writeJSONFileSync(this.sessionsPath, capped);
181
+ return id;
182
+ }
183
+ finally {
184
+ release();
185
+ }
186
+ }
187
+ async endSession(sessionId) {
188
+ const release = await this.mutex.acquire();
189
+ try {
190
+ const sessions = this.readJSONFile(this.sessionsPath);
191
+ const sessionIndex = sessions.findIndex(s => s.id === sessionId);
192
+ if (sessionIndex !== -1) {
193
+ sessions[sessionIndex].endTime = Date.now();
194
+ this.writeJSONFileSync(this.sessionsPath, sessions);
195
+ }
196
+ }
197
+ finally {
198
+ release();
199
+ }
200
+ }
201
+ async getSession(sessionId) {
202
+ const sessions = this.readJSONFile(this.sessionsPath);
203
+ return sessions.find(s => s.id === sessionId) || null;
204
+ }
205
+ async getSessions(limit) {
206
+ let sessions = this.readJSONFile(this.sessionsPath);
207
+ sessions.sort((a, b) => b.startTime - a.startTime); // descending by startTime
208
+ if (limit !== undefined) {
209
+ const safeLimit = this.security.validateLimit(limit);
210
+ sessions = sessions.slice(0, safeLimit);
211
+ }
212
+ return sessions;
213
+ }
214
+ // ── Memory Management ──────────────────────────────────────────────────
215
+ calculateJaccard(str1, str2) {
216
+ const words1 = str1.toLowerCase().split(/\W+/).filter(w => w.length > 2);
217
+ const words2 = str2.toLowerCase().split(/\W+/).filter(w => w.length > 2);
218
+ if (words1.length === 0 && words2.length === 0)
219
+ return 1;
220
+ if (words1.length === 0 || words2.length === 0)
221
+ return 0;
222
+ const set1 = new Set(words1);
223
+ const set2 = new Set(words2);
224
+ const intersection = new Set([...set1].filter(x => set2.has(x)));
225
+ const union = new Set([...set1, ...set2]);
226
+ return intersection.size / union.size;
227
+ }
228
+ async saveMemory(memory) {
229
+ const release = await this.mutex.acquire();
230
+ try {
231
+ const id = crypto.randomUUID();
232
+ // Sanitize content — redact sensitive data
233
+ let content = this.security.sanitizeInput(memory.content);
234
+ if (this.security.isSensitive(content)) {
235
+ content = this.security.redactSensitive(content);
236
+ }
237
+ // Validate type
238
+ const validType = this.security.validateMemoryType(memory.type || 'manual');
239
+ // Sanitize tags
240
+ const sanitizedTags = (memory.tags || [])
241
+ .map(tag => this.security.sanitizeInput(tag, 100))
242
+ .filter(tag => tag.length > 0)
243
+ .slice(0, 20); // Max 20 tags
244
+ const memoryWithId = {
245
+ ...memory,
246
+ id,
247
+ content,
248
+ type: validType,
249
+ tags: sanitizedTags,
250
+ relatedPaths: memory.relatedPaths ? this.security.validateRelatedPaths(memory.relatedPaths) : undefined,
251
+ relatedSymbols: memory.relatedSymbols ? this.security.validateRelatedSymbols(memory.relatedSymbols) : undefined,
252
+ commitHash: memory.commitHash ? this.security.validateCommitHash(memory.commitHash) : undefined,
253
+ branch: memory.branch ? this.security.sanitizeInput(String(memory.branch), 100) : undefined,
254
+ };
255
+ let memories = this.readJSONFile(this.memoriesPath);
256
+ // Duplicate detection
257
+ if (memory.sessionId) {
258
+ const sessionMemories = memories.filter(m => m.sessionId === memory.sessionId).slice(-20);
259
+ for (const sm of sessionMemories) {
260
+ const jaccard = this.calculateJaccard(sm.content, content);
261
+ if (jaccard > 0.9) {
262
+ return sm.id; // Return existing ID if it's a near duplicate
263
+ }
264
+ }
265
+ }
266
+ memories.push(memoryWithId);
267
+ // Cap at max entries — archive old ones
268
+ const capped = memories.length > this.security.maxMemoryEntries
269
+ ? memories.slice(-this.security.maxMemoryEntries)
270
+ : memories;
271
+ this.writeJSONFileSync(this.memoriesPath, capped);
272
+ return id;
273
+ }
274
+ finally {
275
+ release();
276
+ }
277
+ }
278
+ async getMemory(id) {
279
+ const memories = this.readJSONFile(this.memoriesPath);
280
+ return memories.find(mem => mem.id === id) || null;
281
+ }
282
+ async searchMemories(options) {
283
+ let memories = this.readJSONFile(this.memoriesPath);
284
+ if (options.sessionId) {
285
+ memories = memories.filter(mem => mem.sessionId === options.sessionId);
286
+ }
287
+ if (options.type) {
288
+ memories = memories.filter(mem => mem.type === options.type);
289
+ }
290
+ if (options.tags && options.tags.length > 0) {
291
+ const tags = options.tags;
292
+ memories = memories.filter(mem => tags.some(tag => mem.tags.includes(tag)));
293
+ }
294
+ memories.sort((a, b) => b.timestamp - a.timestamp); // descending by timestamp
295
+ if (options.offset !== undefined) {
296
+ const safeOffset = Math.max(0, Math.floor(options.offset));
297
+ memories = memories.slice(safeOffset);
298
+ }
299
+ if (options.limit !== undefined) {
300
+ const safeLimit = this.security.validateLimit(options.limit);
301
+ memories = memories.slice(0, safeLimit);
302
+ }
303
+ return memories;
304
+ }
305
+ // ── Project Metadata ───────────────────────────────────────────────────
306
+ async saveProjectMetadata(metadata) {
307
+ const release = await this.mutex.acquire();
308
+ try {
309
+ this.writeJSONFileSync(this.projectMetadataPath, metadata);
310
+ }
311
+ finally {
312
+ release();
313
+ }
314
+ }
315
+ async getProjectMetadata() {
316
+ try {
317
+ return this.readJSONFile(this.projectMetadataPath);
318
+ }
319
+ catch (e) {
320
+ return null;
321
+ }
322
+ }
323
+ // ── Maintenance & Compaction ───────────────────────────────────────────
324
+ async archiveOldMemories(maxAgeDays) {
325
+ const release = await this.mutex.acquire();
326
+ try {
327
+ const memories = this.readJSONFile(this.memoriesPath);
328
+ const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
329
+ const toKeep = [];
330
+ const toArchive = [];
331
+ for (const m of memories) {
332
+ if (m.timestamp < cutoff && !m.tags.includes('pinned')) {
333
+ toArchive.push(m);
334
+ }
335
+ else {
336
+ toKeep.push(m);
337
+ }
338
+ }
339
+ if (toArchive.length > 0) {
340
+ this.writeJSONFileSync(this.memoriesPath, toKeep);
341
+ // Write to archive file
342
+ const archiveDir = path.join(this.contexthubPath, 'archive');
343
+ if (!fs.existsSync(archiveDir)) {
344
+ fs.mkdirSync(archiveDir, { recursive: true });
345
+ this.security.setSecurePermissions(archiveDir, true);
346
+ }
347
+ const archivePath = path.join(archiveDir, `memories-${Date.now()}.json`);
348
+ this.writeJSONFileSync(archivePath, toArchive);
349
+ }
350
+ return toArchive.length;
351
+ }
352
+ finally {
353
+ release();
354
+ }
355
+ }
356
+ async compactMemories() {
357
+ const release = await this.mutex.acquire();
358
+ try {
359
+ let memories = this.readJSONFile(this.memoriesPath);
360
+ let mergedCount = 0;
361
+ // We will group by sessionId and then look for adjacent prompt -> response
362
+ const sessionMap = new Map();
363
+ for (const m of memories) {
364
+ if (!sessionMap.has(m.sessionId))
365
+ sessionMap.set(m.sessionId, []);
366
+ sessionMap.get(m.sessionId).push(m);
367
+ }
368
+ const newMemories = [];
369
+ for (const [sessionId, sessionMems] of sessionMap.entries()) {
370
+ // Sort chronologically to find adjacent pairs easily
371
+ sessionMems.sort((a, b) => a.timestamp - b.timestamp);
372
+ let i = 0;
373
+ while (i < sessionMems.length) {
374
+ const m1 = sessionMems[i];
375
+ const m2 = i + 1 < sessionMems.length ? sessionMems[i + 1] : null;
376
+ if (m2 &&
377
+ m1.type === 'prompt' &&
378
+ m2.type === 'response' &&
379
+ !m1.tags.includes('pinned') &&
380
+ !m2.tags.includes('pinned')) {
381
+ // Merge them
382
+ const mergedContent = `Prompt: ${m1.content}\n\nResponse: ${m2.content}`;
383
+ const mergedTags = Array.from(new Set([...m1.tags, ...m2.tags]));
384
+ const mergedPaths = Array.from(new Set([...(m1.relatedPaths || []), ...(m2.relatedPaths || [])])).slice(0, 20);
385
+ newMemories.push({
386
+ id: crypto.randomUUID(),
387
+ sessionId: sessionId,
388
+ type: 'summary',
389
+ content: mergedContent,
390
+ timestamp: m2.timestamp,
391
+ tags: mergedTags,
392
+ relatedPaths: mergedPaths.length > 0 ? mergedPaths : undefined,
393
+ commitHash: m2.commitHash || m1.commitHash,
394
+ branch: m2.branch || m1.branch
395
+ });
396
+ mergedCount++;
397
+ i += 2; // skip both
398
+ }
399
+ else {
400
+ newMemories.push(m1);
401
+ i++;
402
+ }
403
+ }
404
+ }
405
+ if (mergedCount > 0) {
406
+ this.writeJSONFileSync(this.memoriesPath, newMemories);
407
+ }
408
+ return mergedCount;
409
+ }
410
+ finally {
411
+ release();
412
+ }
413
+ }
414
+ // ── Cleanup ────────────────────────────────────────────────────────────
415
+ async close() {
416
+ // Nothing to clean up
417
+ }
418
+ }
419
+ exports.MemoryStorage = MemoryStorage;
@@ -0,0 +1,20 @@
1
+ import type { ContextHubCore } from './index';
2
+ import type { MemoryEntry } from '@imayuur/contexthub-shared-types';
3
+ export interface UnifiedQueryResult {
4
+ answerSummary: string;
5
+ memories: MemoryEntry[];
6
+ codeHits: Array<{
7
+ path: string;
8
+ symbol?: string;
9
+ reason: string;
10
+ }>;
11
+ gitHits?: any[];
12
+ trace?: {
13
+ hops: Array<{
14
+ type: string;
15
+ id: string;
16
+ label: string;
17
+ }>;
18
+ };
19
+ }
20
+ export declare function runUnifiedQuery(query: string, limit: number, core: ContextHubCore, vectorEngine: any, graphManager: any, gitIntegration: any): Promise<UnifiedQueryResult>;
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runUnifiedQuery = runUnifiedQuery;
37
+ const path = __importStar(require("path"));
38
+ async function runUnifiedQuery(query, limit, core, vectorEngine, graphManager, gitIntegration) {
39
+ const security = core['storage']['security']; // Access security manager safely
40
+ const sanitizedQuery = security.sanitizeQuery(query);
41
+ const safeLimit = security.validateLimit(limit, 1, 100);
42
+ const queryLower = sanitizedQuery.toLowerCase();
43
+ function rrfMerge(lists, k = 60) {
44
+ const scores = new Map();
45
+ for (const list of lists) {
46
+ list.forEach((item, i) => {
47
+ scores.set(item.id, (scores.get(item.id) || 0) + 1 / (k + i + 1));
48
+ });
49
+ }
50
+ return scores;
51
+ }
52
+ // 1. Code Graph Traversal
53
+ const codeHits = [];
54
+ let trace = undefined;
55
+ try {
56
+ if (graphManager) {
57
+ const graph = await graphManager.loadGraph();
58
+ const referencedNodes = [];
59
+ for (const node of graph.nodes) {
60
+ if (node.kind === 'file') {
61
+ const basename = path.basename(node.path).toLowerCase();
62
+ if (queryLower.includes(basename)) {
63
+ referencedNodes.push(node);
64
+ }
65
+ }
66
+ else if (node.kind === 'symbol' && node.name) {
67
+ const symLower = node.name.toLowerCase();
68
+ const regex = new RegExp('\\b' + symLower + '\\b');
69
+ if (regex.test(queryLower)) {
70
+ referencedNodes.push(node);
71
+ }
72
+ }
73
+ }
74
+ for (const node of referencedNodes) {
75
+ codeHits.push({
76
+ path: node.path,
77
+ symbol: node.kind === 'symbol' ? node.name : undefined,
78
+ reason: `Directly mentioned in query`
79
+ });
80
+ const related = await graphManager.getRelatedSymbols(node.id, 5);
81
+ for (const rel of related) {
82
+ if (!codeHits.some(h => h.path === rel.path && h.symbol === rel.name)) {
83
+ codeHits.push({
84
+ path: rel.path,
85
+ symbol: rel.name,
86
+ reason: `Related symbol to '${node.name || path.basename(node.path)}' in graph`
87
+ });
88
+ }
89
+ }
90
+ }
91
+ if (referencedNodes.length >= 2) {
92
+ const fromNode = referencedNodes[0];
93
+ const toNode = referencedNodes[1];
94
+ const pathHops = await graphManager.tracePath(fromNode.id, toNode.id, 5);
95
+ if (pathHops) {
96
+ trace = { hops: pathHops };
97
+ }
98
+ }
99
+ }
100
+ }
101
+ catch (e) {
102
+ // Ignore
103
+ }
104
+ // 2. Git Recent Changes
105
+ let gitHits = undefined;
106
+ const historyKeywords = ['recent', 'change', 'git', 'commit', 'log', 'history', 'branch', 'timeline'];
107
+ const wantsHistory = historyKeywords.some(kw => queryLower.includes(kw));
108
+ if (wantsHistory && gitIntegration) {
109
+ try {
110
+ const summary = await gitIntegration.getGitSummary();
111
+ gitHits = summary.recentCommits.slice(0, 5).map((c) => ({
112
+ hash: c.hash.substring(0, 7),
113
+ message: c.message,
114
+ author: c.author,
115
+ date: c.date
116
+ }));
117
+ }
118
+ catch (e) {
119
+ // Ignore
120
+ }
121
+ }
122
+ // 3. Load Memories
123
+ let allMemories = [];
124
+ try {
125
+ allMemories = await core.searchMemories({ limit: 1000 });
126
+ }
127
+ catch (e) { }
128
+ // 4. Semantic Search
129
+ let semanticResults = [];
130
+ try {
131
+ if (vectorEngine && allMemories.length > 0) {
132
+ semanticResults = await vectorEngine.searchSimilarText(sanitizedQuery, allMemories, 50);
133
+ }
134
+ }
135
+ catch (e) { }
136
+ // 5. Keyword Search
137
+ const keywordResults = allMemories.filter(mem => mem.content.toLowerCase().includes(queryLower) ||
138
+ mem.tags.some(tag => tag.toLowerCase().includes(queryLower))).slice(0, 50);
139
+ // 6. Graph Pseudo-hits
140
+ const graphMemories = allMemories.filter(mem => {
141
+ const matchPath = mem.relatedPaths?.some(p => codeHits.some(c => c.path === p));
142
+ const matchSym = mem.relatedSymbols?.some(s => codeHits.some(c => c.symbol === s));
143
+ return matchPath || matchSym;
144
+ }).slice(0, 50);
145
+ // 7. Git Pseudo-hits
146
+ const gitMemories = allMemories.filter(mem => {
147
+ if (!mem.commitHash || !gitHits)
148
+ return false;
149
+ return gitHits.some(g => mem.commitHash.startsWith(g.hash));
150
+ }).slice(0, 50);
151
+ // 8. RRF Merge
152
+ const rrfScores = rrfMerge([
153
+ semanticResults,
154
+ keywordResults,
155
+ graphMemories,
156
+ gitMemories
157
+ ]);
158
+ const sortedMemories = Array.from(rrfScores.entries())
159
+ .sort((a, b) => b[1] - a[1])
160
+ .map(([id]) => allMemories.find(m => m.id === id))
161
+ .filter(Boolean)
162
+ .slice(0, safeLimit);
163
+ // 9. Deterministic Stitched Answer Summary
164
+ let answerSummary = `### ContextHub Query Results for "${sanitizedQuery}"\n\n`;
165
+ answerSummary += `* Found ${sortedMemories.length} relevant memory entries.\n`;
166
+ if (codeHits.length > 0) {
167
+ answerSummary += `* Identified ${codeHits.length} related symbols/files in the local knowledge graph.\n`;
168
+ }
169
+ if (gitHits && gitHits.length > 0) {
170
+ answerSummary += `* Retrieved ${gitHits.length} recent commits from git repository history.\n`;
171
+ }
172
+ if (trace) {
173
+ answerSummary += `* Traced dependency connection path: ${trace.hops.map(h => `\`${h.label}\` (${h.type})`).join(' → ')}\n`;
174
+ }
175
+ return {
176
+ answerSummary,
177
+ memories: sortedMemories,
178
+ codeHits: codeHits.slice(0, safeLimit),
179
+ gitHits,
180
+ trace
181
+ };
182
+ }