claude-memory-layer 1.0.11 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +60 -0
  2. package/README.md +166 -2
  3. package/bootstrap-kb/decisions/decisions.md +244 -0
  4. package/bootstrap-kb/glossary/glossary.md +46 -0
  5. package/bootstrap-kb/modules/.claude-plugin.md +22 -0
  6. package/bootstrap-kb/modules/agents.md.md +15 -0
  7. package/bootstrap-kb/modules/claude.md.md +15 -0
  8. package/bootstrap-kb/modules/context.md.md +15 -0
  9. package/bootstrap-kb/modules/docs.md +18 -0
  10. package/bootstrap-kb/modules/handoff.md.md +15 -0
  11. package/bootstrap-kb/modules/package-lock.json.md +15 -0
  12. package/bootstrap-kb/modules/package.json.md +15 -0
  13. package/bootstrap-kb/modules/plan.md.md +15 -0
  14. package/bootstrap-kb/modules/readme.md.md +15 -0
  15. package/bootstrap-kb/modules/scripts.md +26 -0
  16. package/bootstrap-kb/modules/spec.md.md +15 -0
  17. package/bootstrap-kb/modules/specs.md +20 -0
  18. package/bootstrap-kb/modules/src.md +51 -0
  19. package/bootstrap-kb/modules/tests.md +42 -0
  20. package/bootstrap-kb/modules/tsconfig.json.md +15 -0
  21. package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
  22. package/bootstrap-kb/overview/overview.md +40 -0
  23. package/bootstrap-kb/sources/manifest.json +950 -0
  24. package/bootstrap-kb/sources/manifest.md +227 -0
  25. package/bootstrap-kb/timeline/timeline.md +57 -0
  26. package/d.sh +3 -0
  27. package/deploy.sh +3 -0
  28. package/dist/cli/index.js +2389 -286
  29. package/dist/cli/index.js.map +4 -4
  30. package/dist/core/index.js +1017 -132
  31. package/dist/core/index.js.map +4 -4
  32. package/dist/hooks/post-tool-use.js +1347 -202
  33. package/dist/hooks/post-tool-use.js.map +4 -4
  34. package/dist/hooks/session-end.js +1339 -194
  35. package/dist/hooks/session-end.js.map +4 -4
  36. package/dist/hooks/session-start.js +1343 -198
  37. package/dist/hooks/session-start.js.map +4 -4
  38. package/dist/hooks/stop.js +1351 -206
  39. package/dist/hooks/stop.js.map +4 -4
  40. package/dist/hooks/user-prompt-submit.js +1347 -202
  41. package/dist/hooks/user-prompt-submit.js.map +4 -4
  42. package/dist/server/api/index.js +1436 -211
  43. package/dist/server/api/index.js.map +4 -4
  44. package/dist/server/index.js +1445 -220
  45. package/dist/server/index.js.map +4 -4
  46. package/dist/services/memory-service.js +1345 -199
  47. package/dist/services/memory-service.js.map +4 -4
  48. package/dist/ui/app.js +69 -2
  49. package/dist/ui/index.html +8 -0
  50. package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
  51. package/docs/MEMU_ADOPTION.md +40 -0
  52. package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
  53. package/memory/_index.md +405 -0
  54. package/memory/default/uncategorized/2026-02-25.md +4839 -0
  55. package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
  56. package/memory/specs/citations-system/2026-02-25.md +1121 -0
  57. package/memory/specs/endless-mode/2026-02-25.md +1392 -0
  58. package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
  59. package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
  60. package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
  61. package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
  62. package/memory/specs/private-tags/2026-02-25.md +1057 -0
  63. package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
  64. package/memory/specs/task-entity-system/2026-02-25.md +924 -0
  65. package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
  66. package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
  67. package/package.json +2 -1
  68. package/scripts/build.ts +6 -0
  69. package/src/cli/index.ts +281 -2
  70. package/src/core/consolidated-store.ts +63 -1
  71. package/src/core/consolidation-worker.ts +115 -6
  72. package/src/core/event-store.ts +14 -0
  73. package/src/core/index.ts +1 -0
  74. package/src/core/ingest-interceptor.ts +80 -0
  75. package/src/core/markdown-mirror.ts +70 -0
  76. package/src/core/md-mirror.ts +92 -0
  77. package/src/core/mongo-sync-config.ts +165 -0
  78. package/src/core/mongo-sync-worker.ts +381 -0
  79. package/src/core/retriever.ts +540 -150
  80. package/src/core/sqlite-event-store.ts +350 -1
  81. package/src/core/tag-taxonomy.ts +51 -0
  82. package/src/core/types.ts +28 -0
  83. package/src/server/api/health.ts +53 -0
  84. package/src/server/api/index.ts +3 -1
  85. package/src/server/api/stats.ts +46 -1
  86. package/src/services/bootstrap-organizer.ts +443 -0
  87. package/src/services/codex-session-history-importer.ts +474 -0
  88. package/src/services/memory-service.ts +373 -68
  89. package/src/ui/app.js +69 -2
  90. package/src/ui/index.html +8 -0
  91. package/tests/bootstrap-organizer.test.ts +111 -0
  92. package/tests/consolidation-worker.test.ts +75 -0
  93. package/tests/ingest-interceptor.test.ts +38 -0
  94. package/tests/markdown-mirror.test.ts +85 -0
  95. package/tests/md-mirror.test.ts +50 -0
  96. package/tests/retriever-fallback-chain.test.ts +223 -0
  97. package/tests/retriever-strategy-scope.test.ts +97 -0
  98. package/tests/retriever.memu-adoption.test.ts +122 -0
  99. package/tests/sqlite-event-store-replication.test.ts +92 -0
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Memory Retriever - Unified retrieval interface
3
- * Combines vector search, event store lookups, and matching
3
+ * Combines vector search, keyword search, scoped filtering, and matching
4
4
  */
5
5
 
6
6
  import { EventStore } from './event-store.js';
@@ -10,7 +10,19 @@ import { Matcher } from './matcher.js';
10
10
  import { SharedStore } from './shared-store.js';
11
11
  import { SharedVectorStore } from './shared-vector-store.js';
12
12
  import { GraduationPipeline } from './graduation.js';
13
- import type { MemoryEvent, MatchResult, Config, SharedTroubleshootingEntry } from './types.js';
13
+ import type { MemoryEvent, MatchResult, SharedTroubleshootingEntry } from './types.js';
14
+
15
+ export interface RetrievalScope {
16
+ sessionId?: string;
17
+ eventTypes?: MemoryEvent['eventType'][];
18
+ metadata?: Record<string, unknown>;
19
+ canonicalKeyPrefix?: string;
20
+ sessionIdPrefix?: string;
21
+ contentIncludes?: string[];
22
+ }
23
+
24
+ export type RetrievalStrategy = 'auto' | 'fast' | 'deep';
25
+ export type ProjectScopeMode = 'strict' | 'prefer' | 'global';
14
26
 
15
27
  export interface RetrievalOptions {
16
28
  topK: number;
@@ -18,6 +30,28 @@ export interface RetrievalOptions {
18
30
  sessionId?: string;
19
31
  maxTokens: number;
20
32
  includeSessionContext: boolean;
33
+ scope?: RetrievalScope;
34
+ strategy?: RetrievalStrategy;
35
+ rerankWithKeyword?: boolean;
36
+ rerankWeights?: {
37
+ semantic?: number;
38
+ lexical?: number;
39
+ recency?: number;
40
+ };
41
+ decayPolicy?: {
42
+ enabled?: boolean;
43
+ windowDays?: number;
44
+ maxPenalty?: number;
45
+ };
46
+ intentRewrite?: boolean;
47
+ graphHop?: {
48
+ enabled?: boolean;
49
+ maxHops?: number;
50
+ hopPenalty?: number;
51
+ };
52
+ projectScopeMode?: ProjectScopeMode;
53
+ projectHash?: string;
54
+ allowedProjectHashes?: string[];
21
55
  }
22
56
 
23
57
  export interface RetrievalResult {
@@ -25,6 +59,21 @@ export interface RetrievalResult {
25
59
  matchResult: MatchResult;
26
60
  totalTokens: number;
27
61
  context: string;
62
+ fallbackTrace?: string[];
63
+ selectedDebug?: Array<{
64
+ eventId: string;
65
+ score: number;
66
+ semanticScore?: number;
67
+ lexicalScore?: number;
68
+ recencyScore?: number;
69
+ }>;
70
+ candidateDebug?: Array<{
71
+ eventId: string;
72
+ score: number;
73
+ semanticScore?: number;
74
+ lexicalScore?: number;
75
+ recencyScore?: number;
76
+ }>;
28
77
  }
29
78
 
30
79
  export interface MemoryWithContext {
@@ -46,7 +95,20 @@ const DEFAULT_OPTIONS: RetrievalOptions = {
46
95
  topK: 5,
47
96
  minScore: 0.7,
48
97
  maxTokens: 2000,
49
- includeSessionContext: true
98
+ includeSessionContext: true,
99
+ strategy: 'auto',
100
+ rerankWithKeyword: true,
101
+ decayPolicy: {
102
+ enabled: true,
103
+ windowDays: 30,
104
+ maxPenalty: 0.15
105
+ },
106
+ graphHop: {
107
+ enabled: true,
108
+ maxHops: 1,
109
+ hopPenalty: 0.08
110
+ },
111
+ projectScopeMode: 'global'
50
112
  };
51
113
 
52
114
  export interface SharedStoreOptions {
@@ -54,14 +116,19 @@ export interface SharedStoreOptions {
54
116
  sharedVectorStore?: SharedVectorStore;
55
117
  }
56
118
 
119
+ type EventStoreLike = EventStore & {
120
+ keywordSearch?: (query: string, limit?: number) => Promise<Array<{ event: MemoryEvent; rank: number }>>;
121
+ };
122
+
57
123
  export class Retriever {
58
- private readonly eventStore: EventStore;
124
+ private readonly eventStore: EventStoreLike;
59
125
  private readonly vectorStore: VectorStore;
60
126
  private readonly embedder: Embedder;
61
127
  private readonly matcher: Matcher;
62
128
  private sharedStore?: SharedStore;
63
129
  private sharedVectorStore?: SharedVectorStore;
64
130
  private graduation?: GraduationPipeline;
131
+ private queryRewriter?: (query: string) => Promise<string | null>;
65
132
 
66
133
  constructor(
67
134
  eventStore: EventStore,
@@ -70,7 +137,7 @@ export class Retriever {
70
137
  matcher: Matcher,
71
138
  sharedOptions?: SharedStoreOptions
72
139
  ) {
73
- this.eventStore = eventStore;
140
+ this.eventStore = eventStore as EventStoreLike;
74
141
  this.vectorStore = vectorStore;
75
142
  this.embedder = embedder;
76
143
  this.matcher = matcher;
@@ -78,106 +145,152 @@ export class Retriever {
78
145
  this.sharedVectorStore = sharedOptions?.sharedVectorStore;
79
146
  }
80
147
 
81
- /**
82
- * Set graduation pipeline for access tracking
83
- */
84
148
  setGraduationPipeline(graduation: GraduationPipeline): void {
85
149
  this.graduation = graduation;
86
150
  }
87
151
 
88
- /**
89
- * Set shared stores after construction
90
- */
91
152
  setSharedStores(sharedStore: SharedStore, sharedVectorStore: SharedVectorStore): void {
92
153
  this.sharedStore = sharedStore;
93
154
  this.sharedVectorStore = sharedVectorStore;
94
155
  }
95
156
 
96
- /**
97
- * Retrieve relevant memories for a query
98
- */
157
+ setQueryRewriter(rewriter: (query: string) => Promise<string | null>): void {
158
+ this.queryRewriter = rewriter;
159
+ }
160
+
99
161
  async retrieve(
100
162
  query: string,
101
163
  options: Partial<RetrievalOptions> = {}
102
164
  ): Promise<RetrievalResult> {
103
165
  const opts = { ...DEFAULT_OPTIONS, ...options };
166
+ const sessionFilter = opts.scope?.sessionId ?? opts.sessionId;
167
+ const fallbackTrace: string[] = [];
104
168
 
105
- // Generate query embedding
106
- const queryEmbedding = await this.embedder.embed(query);
169
+ const fallbackEnabled = (opts.strategy ?? 'auto') === 'auto';
107
170
 
108
- // Search vector store
109
- const searchResults = await this.vectorStore.search(queryEmbedding.vector, {
110
- limit: opts.topK * 2, // Get extra for filtering
171
+ // Stage 1: primary retrieval
172
+ const primaryStrategy: RetrievalStrategy = opts.strategy === 'auto' ? 'fast' : (opts.strategy || 'fast');
173
+ let current = await this.runStage(query, {
174
+ strategy: primaryStrategy,
175
+ topK: opts.topK,
111
176
  minScore: opts.minScore,
112
- sessionId: opts.sessionId
177
+ sessionId: sessionFilter,
178
+ scope: opts.scope,
179
+ rerankWithKeyword: opts.rerankWithKeyword !== false,
180
+ rerankWeights: opts.rerankWeights,
181
+ decayPolicy: opts.decayPolicy,
182
+ intentRewrite: opts.intentRewrite === true,
183
+ graphHop: opts.graphHop,
184
+ projectScopeMode: opts.projectScopeMode,
185
+ projectHash: opts.projectHash,
186
+ allowedProjectHashes: opts.allowedProjectHashes
113
187
  });
188
+ fallbackTrace.push(`stage:primary:${primaryStrategy}`);
189
+
190
+ // Stage 2: deep fallback
191
+ if (fallbackEnabled && this.shouldFallback(current.matchResult, current.results) && primaryStrategy !== 'deep') {
192
+ current = await this.runStage(query, {
193
+ strategy: 'deep',
194
+ topK: opts.topK,
195
+ minScore: opts.minScore,
196
+ sessionId: sessionFilter,
197
+ scope: opts.scope,
198
+ rerankWithKeyword: opts.rerankWithKeyword !== false,
199
+ rerankWeights: opts.rerankWeights,
200
+ decayPolicy: opts.decayPolicy,
201
+ graphHop: opts.graphHop,
202
+ projectScopeMode: opts.projectScopeMode,
203
+ projectHash: opts.projectHash,
204
+ allowedProjectHashes: opts.allowedProjectHashes
205
+ });
206
+ fallbackTrace.push('fallback:deep');
207
+ }
114
208
 
115
- // Get match result using AXIOMMIND matcher
116
- const matchResult = this.matcher.matchSearchResults(
117
- searchResults,
118
- (eventId) => this.getEventAgeDays(eventId)
119
- );
209
+ // Stage 3: scope-expanded deep fallback
210
+ if (fallbackEnabled && this.shouldFallback(current.matchResult, current.results)) {
211
+ current = await this.runStage(query, {
212
+ strategy: 'deep',
213
+ topK: opts.topK,
214
+ minScore: Math.max(0.5, opts.minScore - 0.15),
215
+ sessionId: undefined,
216
+ scope: undefined,
217
+ rerankWithKeyword: true,
218
+ rerankWeights: opts.rerankWeights,
219
+ decayPolicy: opts.decayPolicy,
220
+ graphHop: opts.graphHop,
221
+ projectScopeMode: opts.projectScopeMode,
222
+ projectHash: opts.projectHash,
223
+ allowedProjectHashes: opts.allowedProjectHashes
224
+ });
225
+ fallbackTrace.push('fallback:scope-expanded');
226
+ }
120
227
 
121
- // Enrich results with full event data and session context
122
- const memories = await this.enrichResults(searchResults.slice(0, opts.topK), opts);
228
+ // Stage 4: summary fallback
229
+ if (fallbackEnabled && this.shouldFallback(current.matchResult, current.results)) {
230
+ const summary = await this.buildSummaryFallback(query, opts.topK);
231
+ current = {
232
+ results: summary,
233
+ candidateResults: summary,
234
+ matchResult: this.matcher.matchSearchResults(summary, () => 0)
235
+ };
236
+ fallbackTrace.push('fallback:summary');
237
+ }
123
238
 
124
- // Build context string
239
+ const memories = await this.enrichResults(current.results.slice(0, opts.topK), opts as RetrievalOptions);
125
240
  const context = this.buildContext(memories, opts.maxTokens);
126
241
 
127
242
  return {
128
243
  memories,
129
- matchResult,
244
+ matchResult: current.matchResult,
130
245
  totalTokens: this.estimateTokens(context),
131
- context
246
+ context,
247
+ fallbackTrace,
248
+ selectedDebug: current.results.slice(0, opts.topK).map((r: SearchResult & { semanticScore?: number; lexicalScore?: number; recencyScore?: number }) => ({
249
+ eventId: r.eventId,
250
+ score: r.score,
251
+ semanticScore: r.semanticScore,
252
+ lexicalScore: r.lexicalScore,
253
+ recencyScore: r.recencyScore,
254
+ })),
255
+ candidateDebug: (current.candidateResults || []).slice(0, Math.max(opts.topK * 3, 20)).map((r: SearchResult & { semanticScore?: number; lexicalScore?: number; recencyScore?: number }) => ({
256
+ eventId: r.eventId,
257
+ score: r.score,
258
+ semanticScore: r.semanticScore,
259
+ lexicalScore: r.lexicalScore,
260
+ recencyScore: r.recencyScore,
261
+ }))
132
262
  };
133
263
  }
134
264
 
135
- /**
136
- * Retrieve with unified search (project + shared)
137
- */
138
265
  async retrieveUnified(
139
266
  query: string,
140
267
  options: Partial<UnifiedRetrievalOptions> = {}
141
268
  ): Promise<UnifiedRetrievalResult> {
142
- // Get project-local results first
143
269
  const projectResult = await this.retrieve(query, options);
144
270
 
145
- // If shared search is not requested or stores not available, return project results only
146
271
  if (!options.includeShared || !this.sharedStore || !this.sharedVectorStore) {
147
272
  return projectResult;
148
273
  }
149
274
 
150
275
  try {
151
- // Generate query embedding (reuse if possible)
152
276
  const queryEmbedding = await this.embedder.embed(query);
277
+ const sharedVectorResults = await this.sharedVectorStore.search(queryEmbedding.vector, {
278
+ limit: options.topK || 5,
279
+ minScore: options.minScore || 0.7,
280
+ excludeProjectHash: options.projectHash
281
+ });
153
282
 
154
- // Vector search in shared store
155
- const sharedVectorResults = await this.sharedVectorStore.search(
156
- queryEmbedding.vector,
157
- {
158
- limit: options.topK || 5,
159
- minScore: options.minScore || 0.7,
160
- excludeProjectHash: options.projectHash
161
- }
162
- );
163
-
164
- // Get full entries from shared store
165
283
  const sharedMemories: SharedTroubleshootingEntry[] = [];
166
284
  for (const result of sharedVectorResults) {
167
285
  const entry = await this.sharedStore.get(result.entryId);
168
- if (entry) {
169
- // Exclude entries from current project if specified
170
- if (!options.projectHash || entry.sourceProjectHash !== options.projectHash) {
171
- sharedMemories.push(entry);
172
- // Record usage for ranking
173
- await this.sharedStore.recordUsage(entry.entryId);
174
- }
286
+ if (!entry) continue;
287
+ if (!options.projectHash || entry.sourceProjectHash !== options.projectHash) {
288
+ sharedMemories.push(entry);
289
+ await this.sharedStore.recordUsage(entry.entryId);
175
290
  }
176
291
  }
177
292
 
178
- // Build unified context
179
293
  const unifiedContext = this.buildUnifiedContext(projectResult, sharedMemories);
180
-
181
294
  return {
182
295
  ...projectResult,
183
296
  context: unifiedContext,
@@ -185,74 +298,351 @@ export class Retriever {
185
298
  sharedMemories
186
299
  };
187
300
  } catch (error) {
188
- // If shared search fails, return project results only
189
301
  console.error('Shared search failed:', error);
190
302
  return projectResult;
191
303
  }
192
304
  }
193
305
 
194
- /**
195
- * Build unified context combining project and shared memories
196
- */
197
- private buildUnifiedContext(
198
- projectResult: RetrievalResult,
199
- sharedMemories: SharedTroubleshootingEntry[]
200
- ): string {
201
- let context = projectResult.context;
306
+ private async runStage(
307
+ query: string,
308
+ input: {
309
+ strategy: RetrievalStrategy;
310
+ topK: number;
311
+ minScore: number;
312
+ sessionId?: string;
313
+ scope?: RetrievalScope;
314
+ rerankWithKeyword: boolean;
315
+ rerankWeights?: {
316
+ semantic?: number;
317
+ lexical?: number;
318
+ recency?: number;
319
+ };
320
+ decayPolicy?: {
321
+ enabled?: boolean;
322
+ windowDays?: number;
323
+ maxPenalty?: number;
324
+ };
325
+ intentRewrite?: boolean;
326
+ graphHop?: {
327
+ enabled?: boolean;
328
+ maxHops?: number;
329
+ hopPenalty?: number;
330
+ };
331
+ projectScopeMode?: ProjectScopeMode;
332
+ projectHash?: string;
333
+ allowedProjectHashes?: string[];
334
+ }
335
+ ): Promise<{ results: SearchResult[]; matchResult: MatchResult }> {
336
+ let initialResults = await this.searchByStrategy(query, {
337
+ strategy: input.strategy,
338
+ topK: input.topK,
339
+ minScore: input.minScore,
340
+ sessionId: input.sessionId
341
+ });
202
342
 
203
- if (sharedMemories.length > 0) {
204
- context += '\n\n## Cross-Project Knowledge\n\n';
205
- for (const memory of sharedMemories.slice(0, 3)) {
206
- context += `### ${memory.title}\n`;
207
- if (memory.symptoms.length > 0) {
208
- context += `**Symptoms:** ${memory.symptoms.join(', ')}\n`;
343
+ if (input.intentRewrite && input.strategy === 'deep' && this.queryRewriter) {
344
+ const rewritten = (await this.queryRewriter(query))?.trim();
345
+ if (rewritten && rewritten !== query) {
346
+ const rewrittenResults = await this.searchByStrategy(rewritten, {
347
+ strategy: 'deep',
348
+ topK: input.topK,
349
+ minScore: Math.max(0.5, input.minScore - 0.1),
350
+ sessionId: input.sessionId
351
+ });
352
+ initialResults = this.mergeResults(initialResults, rewrittenResults, input.topK * 3);
353
+ }
354
+ }
355
+
356
+ const expandedResults = input.graphHop?.enabled === false
357
+ ? initialResults
358
+ : await this.expandGraphHops(initialResults, {
359
+ maxHops: Math.max(1, input.graphHop?.maxHops ?? 1),
360
+ hopPenalty: Math.max(0, input.graphHop?.hopPenalty ?? 0.08),
361
+ limit: input.topK * 4,
362
+ });
363
+
364
+ const rerankedResults = input.rerankWithKeyword
365
+ ? this.rerankByKeywordOverlap(expandedResults, query, input.rerankWeights, input.decayPolicy)
366
+ : expandedResults;
367
+
368
+ const filtered = await this.applyScopeFilters(rerankedResults, {
369
+ scope: input.scope,
370
+ projectScopeMode: input.projectScopeMode,
371
+ projectHash: input.projectHash,
372
+ allowedProjectHashes: input.allowedProjectHashes
373
+ });
374
+ const top = filtered.slice(0, input.topK);
375
+ const matchResult = this.matcher.matchSearchResults(top, () => 0);
376
+
377
+ return { results: top, candidateResults: filtered, matchResult };
378
+ }
379
+
380
+ private mergeResults(primary: SearchResult[], secondary: SearchResult[], limit: number): SearchResult[] {
381
+ const byId = new Map<string, SearchResult>();
382
+ for (const row of primary) byId.set(row.eventId, row);
383
+ for (const row of secondary) {
384
+ const prev = byId.get(row.eventId);
385
+ if (!prev || row.score > prev.score) {
386
+ byId.set(row.eventId, row);
387
+ }
388
+ }
389
+ return [...byId.values()].sort((a, b) => b.score - a.score).slice(0, limit);
390
+ }
391
+
392
+ private async expandGraphHops(
393
+ seeds: SearchResult[],
394
+ opts: { maxHops: number; hopPenalty: number; limit: number }
395
+ ): Promise<SearchResult[]> {
396
+ const byId = new Map<string, SearchResult>();
397
+ for (const s of seeds) byId.set(s.eventId, s);
398
+
399
+ let frontier = seeds.map((s) => ({ row: s, hop: 0 }));
400
+
401
+ for (let hop = 1; hop <= opts.maxHops; hop += 1) {
402
+ const next: Array<{ row: SearchResult; hop: number }> = [];
403
+
404
+ for (const f of frontier) {
405
+ const ev = await this.eventStore.getEvent(f.row.eventId);
406
+ if (!ev) continue;
407
+ const rel = ((ev.metadata as Record<string, unknown> | undefined)?.relatedEventIds ?? []) as unknown;
408
+ const relatedIds = Array.isArray(rel)
409
+ ? rel.filter((x): x is string => typeof x === 'string')
410
+ : [];
411
+
412
+ for (const rid of relatedIds) {
413
+ if (byId.has(rid)) continue;
414
+ const target = await this.eventStore.getEvent(rid);
415
+ if (!target) continue;
416
+
417
+ const score = Math.max(0, f.row.score - opts.hopPenalty * hop);
418
+ const row: SearchResult = {
419
+ id: `hop-${hop}-${rid}`,
420
+ eventId: target.id,
421
+ content: target.content,
422
+ score,
423
+ sessionId: target.sessionId,
424
+ eventType: target.eventType,
425
+ timestamp: target.timestamp.toISOString(),
426
+ };
427
+
428
+ byId.set(row.eventId, row);
429
+ next.push({ row, hop });
430
+ if (byId.size >= opts.limit) break;
209
431
  }
210
- context += `**Root Cause:** ${memory.rootCause}\n`;
211
- context += `**Solution:** ${memory.solution}\n`;
212
- if (memory.technologies && memory.technologies.length > 0) {
213
- context += `**Technologies:** ${memory.technologies.join(', ')}\n`;
432
+ if (byId.size >= opts.limit) break;
433
+ }
434
+
435
+ frontier = next;
436
+ if (frontier.length === 0 || byId.size >= opts.limit) break;
437
+ }
438
+
439
+ return [...byId.values()].sort((a, b) => b.score - a.score).slice(0, opts.limit);
440
+ }
441
+
442
+ private shouldFallback(matchResult: MatchResult, results: SearchResult[]): boolean {
443
+ if (results.length === 0) return true;
444
+ if (matchResult.confidence === 'none') return true;
445
+ return false;
446
+ }
447
+
448
+ private async buildSummaryFallback(query: string, topK: number): Promise<SearchResult[]> {
449
+ const recent = await this.eventStore.getRecentEvents(Math.max(topK * 6, 20));
450
+ const q = this.tokenize(query);
451
+
452
+ const ranked = recent
453
+ .map((e) => ({ e, overlap: this.keywordOverlap(q, this.tokenize(e.content)) }))
454
+ .filter((r) => r.overlap > 0)
455
+ .sort((a, b) => b.overlap - a.overlap)
456
+ .slice(0, topK)
457
+ .map((row, idx) => ({
458
+ id: `summary-${row.e.id}`,
459
+ eventId: row.e.id,
460
+ content: row.e.content,
461
+ score: Math.max(0.25, 0.6 - idx * 0.05),
462
+ sessionId: row.e.sessionId,
463
+ eventType: row.e.eventType,
464
+ timestamp: row.e.timestamp.toISOString()
465
+ }));
466
+
467
+ return ranked;
468
+ }
469
+
470
+ private async searchByStrategy(
471
+ query: string,
472
+ input: { strategy: RetrievalStrategy; topK: number; minScore: number; sessionId?: string }
473
+ ): Promise<SearchResult[]> {
474
+ const strategy = input.strategy === 'auto' ? 'deep' : input.strategy;
475
+
476
+ if (strategy === 'fast') {
477
+ const keyword = await this.searchByKeyword(query, {
478
+ limit: Math.max(5, input.topK * 3),
479
+ sessionId: input.sessionId
480
+ });
481
+ return keyword;
482
+ }
483
+
484
+ const queryEmbedding = await this.embedder.embed(query);
485
+ return this.vectorStore.search(queryEmbedding.vector, {
486
+ limit: Math.max(5, input.topK * 3),
487
+ minScore: input.minScore,
488
+ sessionId: input.sessionId
489
+ });
490
+ }
491
+
492
+ private async searchByKeyword(
493
+ query: string,
494
+ input: { limit: number; sessionId?: string }
495
+ ): Promise<SearchResult[]> {
496
+ if (this.eventStore.keywordSearch) {
497
+ const rows = await this.eventStore.keywordSearch(query, input.limit);
498
+ const filtered = input.sessionId ? rows.filter((r) => r.event.sessionId === input.sessionId) : rows;
499
+ return filtered.map((row, idx) => ({
500
+ id: `kw-${row.event.id}`,
501
+ eventId: row.event.id,
502
+ content: row.event.content,
503
+ score: Math.max(0.4, 1 - idx * 0.04),
504
+ sessionId: row.event.sessionId,
505
+ eventType: row.event.eventType,
506
+ timestamp: row.event.timestamp.toISOString()
507
+ }));
508
+ }
509
+
510
+ const recent = await this.eventStore.getRecentEvents(input.limit * 4);
511
+ const tokens = this.tokenize(query);
512
+ const filtered = recent
513
+ .filter((e) => (input.sessionId ? e.sessionId === input.sessionId : true))
514
+ .map((e) => ({ e, overlap: this.keywordOverlap(tokens, this.tokenize(e.content)) }))
515
+ .filter((r) => r.overlap > 0)
516
+ .sort((a, b) => b.overlap - a.overlap)
517
+ .slice(0, input.limit);
518
+
519
+ return filtered.map((row, idx) => ({
520
+ id: `kw-fallback-${row.e.id}`,
521
+ eventId: row.e.id,
522
+ content: row.e.content,
523
+ score: Math.max(0.3, 0.9 - idx * 0.05),
524
+ sessionId: row.e.sessionId,
525
+ eventType: row.e.eventType,
526
+ timestamp: row.e.timestamp.toISOString()
527
+ }));
528
+ }
529
+
530
+ private rerankByKeywordOverlap(
531
+ results: SearchResult[],
532
+ query: string,
533
+ weights?: { semantic?: number; lexical?: number; recency?: number },
534
+ decayPolicy?: { enabled?: boolean; windowDays?: number; maxPenalty?: number }
535
+ ): SearchResult[] {
536
+ const q = this.tokenize(query);
537
+ const now = Date.now();
538
+
539
+ const sw = Math.max(0, weights?.semantic ?? 0.7);
540
+ const lw = Math.max(0, weights?.lexical ?? 0.2);
541
+ const rw = Math.max(0, weights?.recency ?? 0.1);
542
+ const total = sw + lw + rw || 1;
543
+
544
+ const decayEnabled = decayPolicy?.enabled !== false;
545
+ const decayWindow = Math.max(1, decayPolicy?.windowDays ?? 30);
546
+ const decayMaxPenalty = Math.max(0, decayPolicy?.maxPenalty ?? 0.15);
547
+
548
+ return [...results]
549
+ .map((r) => {
550
+ const overlap = this.keywordOverlap(q, this.tokenize(r.content));
551
+ const recencyDays = Math.max(0, (now - new Date(r.timestamp).getTime()) / (1000 * 60 * 60 * 24));
552
+ const recency = Math.max(0, 1 - recencyDays / decayWindow);
553
+ let blended = (r.score * sw + overlap * lw + recency * rw) / total;
554
+
555
+ if (decayEnabled && recencyDays > decayWindow && overlap < 0.5) {
556
+ const ageFactor = Math.min(1, (recencyDays - decayWindow) / decayWindow);
557
+ blended -= decayMaxPenalty * ageFactor;
214
558
  }
215
- context += `_Confidence: ${(memory.confidence * 100).toFixed(0)}%_\n\n`;
559
+
560
+ return { ...r, score: Math.max(0, blended), semanticScore: r.score, lexicalScore: overlap, recencyScore: recency };
561
+ })
562
+ .sort((a, b) => b.score - a.score);
563
+ }
564
+
565
+ private async applyScopeFilters(
566
+ results: SearchResult[],
567
+ options?: {
568
+ scope?: RetrievalScope;
569
+ projectScopeMode?: ProjectScopeMode;
570
+ projectHash?: string;
571
+ allowedProjectHashes?: string[];
572
+ }
573
+ ): Promise<SearchResult[]> {
574
+ const scope = options?.scope;
575
+ const projectScopeMode = options?.projectScopeMode ?? 'global';
576
+ const allowedProjectHashes = new Set(
577
+ [options?.projectHash, ...(options?.allowedProjectHashes || [])].filter(
578
+ (value): value is string => typeof value === 'string' && value.length > 0
579
+ )
580
+ );
581
+
582
+ if (!scope && projectScopeMode === 'global') return results;
583
+
584
+ const normalizedIncludes = (scope?.contentIncludes || []).map((s) => s.toLowerCase());
585
+ const filtered: Array<{ result: SearchResult; projectHash?: string }> = [];
586
+
587
+ for (const result of results) {
588
+ if (scope?.sessionId && result.sessionId !== scope.sessionId) continue;
589
+ if (scope?.sessionIdPrefix && !result.sessionId.startsWith(scope.sessionIdPrefix)) continue;
590
+ if (scope?.eventTypes && scope.eventTypes.length > 0 && !scope.eventTypes.includes(result.eventType as MemoryEvent['eventType'])) continue;
591
+
592
+ const event = await this.eventStore.getEvent(result.eventId);
593
+ if (!event) continue;
594
+
595
+ if (scope?.canonicalKeyPrefix && !event.canonicalKey.startsWith(scope.canonicalKeyPrefix)) continue;
596
+ if (normalizedIncludes.length > 0) {
597
+ const lc = event.content.toLowerCase();
598
+ if (!normalizedIncludes.some((needle) => lc.includes(needle))) continue;
216
599
  }
600
+ if (scope?.metadata && !this.matchesMetadataScope(event.metadata, scope.metadata)) continue;
601
+
602
+ const projectHash = this.extractProjectHash(event.metadata);
603
+ filtered.push({ result, projectHash });
217
604
  }
218
605
 
219
- return context;
606
+ if (projectScopeMode === 'global' || allowedProjectHashes.size === 0) {
607
+ return filtered.map((x) => x.result);
608
+ }
609
+
610
+ const projectMatched = filtered.filter((x) => x.projectHash && allowedProjectHashes.has(x.projectHash));
611
+
612
+ if (projectScopeMode === 'strict') {
613
+ return projectMatched.map((x) => x.result);
614
+ }
615
+
616
+ return (projectMatched.length > 0 ? projectMatched : filtered).map((x) => x.result);
617
+ }
618
+
619
+ private extractProjectHash(metadata: Record<string, unknown> | undefined): string | undefined {
620
+ if (!metadata || typeof metadata !== 'object') return undefined;
621
+ const scope = metadata.scope;
622
+ if (!scope || typeof scope !== 'object') return undefined;
623
+ const project = (scope as Record<string, unknown>).project;
624
+ if (!project || typeof project !== 'object') return undefined;
625
+ const hash = (project as Record<string, unknown>).hash;
626
+ return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
220
627
  }
221
628
 
222
- /**
223
- * Retrieve memories from a specific session
224
- */
225
629
  async retrieveFromSession(sessionId: string): Promise<MemoryEvent[]> {
226
630
  return this.eventStore.getSessionEvents(sessionId);
227
631
  }
228
632
 
229
- /**
230
- * Get recent memories across all sessions
231
- */
232
633
  async retrieveRecent(limit: number = 100): Promise<MemoryEvent[]> {
233
634
  return this.eventStore.getRecentEvents(limit);
234
635
  }
235
636
 
236
- /**
237
- * Enrich search results with full event data
238
- */
239
- private async enrichResults(
240
- results: SearchResult[],
241
- options: RetrievalOptions
242
- ): Promise<MemoryWithContext[]> {
637
+ private async enrichResults(results: SearchResult[], options: RetrievalOptions): Promise<MemoryWithContext[]> {
243
638
  const memories: MemoryWithContext[] = [];
244
639
 
245
640
  for (const result of results) {
246
641
  const event = await this.eventStore.getEvent(result.eventId);
247
642
  if (!event) continue;
248
643
 
249
- // Record access for graduation scoring (keep this for graduation logic)
250
644
  if (this.graduation) {
251
- this.graduation.recordAccess(
252
- event.id,
253
- options.sessionId || 'unknown',
254
- result.score
255
- );
645
+ this.graduation.recordAccess(event.id, options.sessionId || 'unknown', result.score);
256
646
  }
257
647
 
258
648
  let sessionContext: string | undefined;
@@ -260,37 +650,20 @@ export class Retriever {
260
650
  sessionContext = await this.getSessionContext(event.sessionId, event.id);
261
651
  }
262
652
 
263
- memories.push({
264
- event,
265
- score: result.score,
266
- sessionContext
267
- });
653
+ memories.push({ event, score: result.score, sessionContext });
268
654
  }
269
655
 
270
- // Note: Access count is NOT incremented here anymore.
271
- // It should be incremented only when memories are actually used in prompts.
272
-
273
656
  return memories;
274
657
  }
275
658
 
276
- /**
277
- * Get surrounding context from the same session
278
- */
279
- private async getSessionContext(
280
- sessionId: string,
281
- eventId: string
282
- ): Promise<string | undefined> {
659
+ private async getSessionContext(sessionId: string, eventId: string): Promise<string | undefined> {
283
660
  const sessionEvents = await this.eventStore.getSessionEvents(sessionId);
284
-
285
- // Find the event index
286
661
  const eventIndex = sessionEvents.findIndex(e => e.id === eventId);
287
662
  if (eventIndex === -1) return undefined;
288
663
 
289
- // Get 1 event before and after for context
290
664
  const start = Math.max(0, eventIndex - 1);
291
665
  const end = Math.min(sessionEvents.length, eventIndex + 2);
292
666
  const contextEvents = sessionEvents.slice(start, end);
293
-
294
667
  if (contextEvents.length <= 1) return undefined;
295
668
 
296
669
  return contextEvents
@@ -299,9 +672,23 @@ export class Retriever {
299
672
  .join('\n');
300
673
  }
301
674
 
302
- /**
303
- * Build context string from memories (respecting token limit)
304
- */
675
+ private buildUnifiedContext(projectResult: RetrievalResult, sharedMemories: SharedTroubleshootingEntry[]): string {
676
+ let context = projectResult.context;
677
+ if (sharedMemories.length === 0) return context;
678
+
679
+ context += '\n\n## Cross-Project Knowledge\n\n';
680
+ for (const memory of sharedMemories.slice(0, 3)) {
681
+ context += `### ${memory.title}\n`;
682
+ if (memory.symptoms.length > 0) context += `**Symptoms:** ${memory.symptoms.join(', ')}\n`;
683
+ context += `**Root Cause:** ${memory.rootCause}\n`;
684
+ context += `**Solution:** ${memory.solution}\n`;
685
+ if (memory.technologies && memory.technologies.length > 0) context += `**Technologies:** ${memory.technologies.join(', ')}\n`;
686
+ context += `_Confidence: ${(memory.confidence * 100).toFixed(0)}%_\n\n`;
687
+ }
688
+
689
+ return context;
690
+ }
691
+
305
692
  private buildContext(memories: MemoryWithContext[], maxTokens: number): string {
306
693
  const parts: string[] = [];
307
694
  let currentTokens = 0;
@@ -309,59 +696,62 @@ export class Retriever {
309
696
  for (const memory of memories) {
310
697
  const memoryText = this.formatMemory(memory);
311
698
  const memoryTokens = this.estimateTokens(memoryText);
312
-
313
- if (currentTokens + memoryTokens > maxTokens) {
314
- break;
315
- }
316
-
699
+ if (currentTokens + memoryTokens > maxTokens) break;
317
700
  parts.push(memoryText);
318
701
  currentTokens += memoryTokens;
319
702
  }
320
703
 
321
- if (parts.length === 0) {
322
- return '';
323
- }
324
-
704
+ if (parts.length === 0) return '';
325
705
  return `## Relevant Memories\n\n${parts.join('\n\n---\n\n')}`;
326
706
  }
327
707
 
328
- /**
329
- * Format a single memory for context
330
- */
331
708
  private formatMemory(memory: MemoryWithContext): string {
332
709
  const { event, score, sessionContext } = memory;
333
710
  const date = event.timestamp.toISOString().split('T')[0];
334
711
 
335
712
  let text = `**${event.eventType}** (${date}, score: ${score.toFixed(2)})\n${event.content}`;
713
+ if (sessionContext) text += `\n\n_Context:_ ${sessionContext}`;
714
+ return text;
715
+ }
336
716
 
337
- if (sessionContext) {
338
- text += `\n\n_Context:_ ${sessionContext}`;
339
- }
717
+ private matchesMetadataScope(
718
+ metadata: Record<string, unknown> | undefined,
719
+ expected: Record<string, unknown>
720
+ ): boolean {
721
+ if (!metadata) return false;
340
722
 
341
- return text;
723
+ return Object.entries(expected).every(([path, value]) => {
724
+ const actual = path.split('.').reduce<unknown>((acc, key) => {
725
+ if (typeof acc !== 'object' || acc === null) return undefined;
726
+ return (acc as Record<string, unknown>)[key];
727
+ }, metadata);
728
+
729
+ return actual === value;
730
+ });
342
731
  }
343
732
 
344
- /**
345
- * Estimate token count (rough approximation)
346
- */
347
- private estimateTokens(text: string): number {
348
- // Rough estimate: ~4 characters per token
349
- return Math.ceil(text.length / 4);
733
+ private tokenize(text: string): string[] {
734
+ return text
735
+ .toLowerCase()
736
+ .replace(/[^\p{L}\p{N}\s]/gu, ' ')
737
+ .split(/\s+/)
738
+ .filter((t) => t.length >= 2)
739
+ .slice(0, 64);
350
740
  }
351
741
 
352
- /**
353
- * Get event age in days (for recency scoring)
354
- */
355
- private getEventAgeDays(eventId: string): number {
356
- // This would ideally cache event timestamps
357
- // For now, return 0 (assume recent)
358
- return 0;
742
+ private keywordOverlap(a: string[], b: string[]): number {
743
+ if (a.length === 0 || b.length === 0) return 0;
744
+ const bs = new Set(b);
745
+ let hit = 0;
746
+ for (const t of a) if (bs.has(t)) hit += 1;
747
+ return hit / a.length;
748
+ }
749
+
750
+ private estimateTokens(text: string): number {
751
+ return Math.ceil(text.length / 4);
359
752
  }
360
753
  }
361
754
 
362
- /**
363
- * Create a retriever with default components
364
- */
365
755
  export function createRetriever(
366
756
  eventStore: EventStore,
367
757
  vectorStore: VectorStore,