@twelvehart/supermemory-runtime 1.0.0-next.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.
Files changed (156) hide show
  1. package/.env.example +57 -0
  2. package/README.md +374 -0
  3. package/dist/index.js +189 -0
  4. package/dist/mcp/index.js +1132 -0
  5. package/docker-compose.prod.yml +91 -0
  6. package/docker-compose.yml +358 -0
  7. package/drizzle/0000_dapper_the_professor.sql +159 -0
  8. package/drizzle/0001_api_keys.sql +51 -0
  9. package/drizzle/meta/0000_snapshot.json +1532 -0
  10. package/drizzle/meta/_journal.json +13 -0
  11. package/drizzle.config.ts +20 -0
  12. package/package.json +114 -0
  13. package/scripts/add-extraction-job.ts +122 -0
  14. package/scripts/benchmark-pgvector.ts +122 -0
  15. package/scripts/bootstrap.sh +209 -0
  16. package/scripts/check-runtime-pack.ts +111 -0
  17. package/scripts/claude-mcp-config.ts +336 -0
  18. package/scripts/docker-entrypoint.sh +183 -0
  19. package/scripts/doctor.ts +377 -0
  20. package/scripts/init-db.sql +33 -0
  21. package/scripts/install.sh +1110 -0
  22. package/scripts/mcp-setup.ts +271 -0
  23. package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
  24. package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
  25. package/scripts/migrations/003_create_hnsw_index.sql +94 -0
  26. package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
  27. package/scripts/migrations/005_create_chunks_table.sql +95 -0
  28. package/scripts/migrations/006_create_processing_queue.sql +45 -0
  29. package/scripts/migrations/generate_test_data.sql +42 -0
  30. package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
  31. package/scripts/migrations/run_migrations.sh +286 -0
  32. package/scripts/migrations/test_hnsw_index.sql +255 -0
  33. package/scripts/pre-commit-secrets +282 -0
  34. package/scripts/run-extraction-worker.ts +46 -0
  35. package/scripts/run-phase1-tests.sh +291 -0
  36. package/scripts/setup.ts +222 -0
  37. package/scripts/smoke-install.sh +12 -0
  38. package/scripts/test-health-endpoint.sh +328 -0
  39. package/src/api/index.ts +2 -0
  40. package/src/api/middleware/auth.ts +80 -0
  41. package/src/api/middleware/csrf.ts +308 -0
  42. package/src/api/middleware/errorHandler.ts +166 -0
  43. package/src/api/middleware/rateLimit.ts +360 -0
  44. package/src/api/middleware/validation.ts +514 -0
  45. package/src/api/routes/documents.ts +286 -0
  46. package/src/api/routes/profiles.ts +237 -0
  47. package/src/api/routes/search.ts +71 -0
  48. package/src/api/stores/index.ts +58 -0
  49. package/src/config/bootstrap-env.ts +3 -0
  50. package/src/config/env.ts +71 -0
  51. package/src/config/feature-flags.ts +25 -0
  52. package/src/config/index.ts +140 -0
  53. package/src/config/secrets.config.ts +291 -0
  54. package/src/db/client.ts +92 -0
  55. package/src/db/index.ts +73 -0
  56. package/src/db/postgres.ts +72 -0
  57. package/src/db/schema/chunks.schema.ts +31 -0
  58. package/src/db/schema/containers.schema.ts +46 -0
  59. package/src/db/schema/documents.schema.ts +49 -0
  60. package/src/db/schema/embeddings.schema.ts +32 -0
  61. package/src/db/schema/index.ts +11 -0
  62. package/src/db/schema/memories.schema.ts +72 -0
  63. package/src/db/schema/profiles.schema.ts +34 -0
  64. package/src/db/schema/queue.schema.ts +59 -0
  65. package/src/db/schema/relationships.schema.ts +42 -0
  66. package/src/db/schema.ts +223 -0
  67. package/src/db/worker-connection.ts +47 -0
  68. package/src/index.ts +235 -0
  69. package/src/mcp/CLAUDE.md +1 -0
  70. package/src/mcp/index.ts +1380 -0
  71. package/src/mcp/legacyState.ts +22 -0
  72. package/src/mcp/rateLimit.ts +358 -0
  73. package/src/mcp/resources.ts +309 -0
  74. package/src/mcp/results.ts +104 -0
  75. package/src/mcp/tools.ts +401 -0
  76. package/src/queues/config.ts +119 -0
  77. package/src/queues/index.ts +289 -0
  78. package/src/sdk/client.ts +225 -0
  79. package/src/sdk/errors.ts +266 -0
  80. package/src/sdk/http.ts +560 -0
  81. package/src/sdk/index.ts +244 -0
  82. package/src/sdk/resources/base.ts +65 -0
  83. package/src/sdk/resources/connections.ts +204 -0
  84. package/src/sdk/resources/documents.ts +163 -0
  85. package/src/sdk/resources/index.ts +10 -0
  86. package/src/sdk/resources/memories.ts +150 -0
  87. package/src/sdk/resources/search.ts +60 -0
  88. package/src/sdk/resources/settings.ts +36 -0
  89. package/src/sdk/types.ts +674 -0
  90. package/src/services/chunking/index.ts +451 -0
  91. package/src/services/chunking.service.ts +650 -0
  92. package/src/services/csrf.service.ts +252 -0
  93. package/src/services/documents.repository.ts +219 -0
  94. package/src/services/documents.service.ts +191 -0
  95. package/src/services/embedding.service.ts +404 -0
  96. package/src/services/extraction.service.ts +300 -0
  97. package/src/services/extractors/code.extractor.ts +451 -0
  98. package/src/services/extractors/index.ts +9 -0
  99. package/src/services/extractors/markdown.extractor.ts +461 -0
  100. package/src/services/extractors/pdf.extractor.ts +315 -0
  101. package/src/services/extractors/text.extractor.ts +118 -0
  102. package/src/services/extractors/url.extractor.ts +243 -0
  103. package/src/services/index.ts +235 -0
  104. package/src/services/ingestion.service.ts +177 -0
  105. package/src/services/llm/anthropic.ts +400 -0
  106. package/src/services/llm/base.ts +460 -0
  107. package/src/services/llm/contradiction-detector.service.ts +526 -0
  108. package/src/services/llm/heuristics.ts +148 -0
  109. package/src/services/llm/index.ts +309 -0
  110. package/src/services/llm/memory-classifier.service.ts +383 -0
  111. package/src/services/llm/memory-extension-detector.service.ts +523 -0
  112. package/src/services/llm/mock.ts +470 -0
  113. package/src/services/llm/openai.ts +398 -0
  114. package/src/services/llm/prompts.ts +438 -0
  115. package/src/services/llm/types.ts +373 -0
  116. package/src/services/memory.repository.ts +1769 -0
  117. package/src/services/memory.service.ts +1338 -0
  118. package/src/services/memory.types.ts +234 -0
  119. package/src/services/persistence/index.ts +295 -0
  120. package/src/services/pipeline.service.ts +509 -0
  121. package/src/services/profile.repository.ts +436 -0
  122. package/src/services/profile.service.ts +560 -0
  123. package/src/services/profile.types.ts +270 -0
  124. package/src/services/relationships/detector.ts +1128 -0
  125. package/src/services/relationships/index.ts +268 -0
  126. package/src/services/relationships/memory-integration.ts +459 -0
  127. package/src/services/relationships/strategies.ts +132 -0
  128. package/src/services/relationships/types.ts +370 -0
  129. package/src/services/search.service.ts +761 -0
  130. package/src/services/search.types.ts +220 -0
  131. package/src/services/secrets.service.ts +384 -0
  132. package/src/services/vectorstore/base.ts +327 -0
  133. package/src/services/vectorstore/index.ts +444 -0
  134. package/src/services/vectorstore/memory.ts +286 -0
  135. package/src/services/vectorstore/migration.ts +295 -0
  136. package/src/services/vectorstore/mock.ts +403 -0
  137. package/src/services/vectorstore/pgvector.ts +695 -0
  138. package/src/services/vectorstore/types.ts +247 -0
  139. package/src/startup.ts +389 -0
  140. package/src/types/api.types.ts +193 -0
  141. package/src/types/document.types.ts +103 -0
  142. package/src/types/index.ts +241 -0
  143. package/src/types/profile.base.ts +133 -0
  144. package/src/utils/errors.ts +447 -0
  145. package/src/utils/id.ts +15 -0
  146. package/src/utils/index.ts +101 -0
  147. package/src/utils/logger.ts +313 -0
  148. package/src/utils/sanitization.ts +501 -0
  149. package/src/utils/secret-validation.ts +273 -0
  150. package/src/utils/synonyms.ts +188 -0
  151. package/src/utils/validation.ts +581 -0
  152. package/src/workers/chunking.worker.ts +242 -0
  153. package/src/workers/embedding.worker.ts +358 -0
  154. package/src/workers/extraction.worker.ts +346 -0
  155. package/src/workers/indexing.worker.ts +505 -0
  156. package/tsconfig.json +38 -0
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Contradiction Detector Service
3
+ *
4
+ * LLM-based semantic contradiction detection between memory pairs.
5
+ * Replaces heuristic matching for TODO-002 in memory.service.ts
6
+ *
7
+ * Cost optimization:
8
+ * - HNSW similarity search to reduce comparison pairs
9
+ * - Prompt caching for repeated patterns
10
+ * - Batch contradiction detection
11
+ * - Fallback to heuristic matching
12
+ *
13
+ * Target: <$0.60/month with typical usage
14
+ */
15
+
16
+ import { getLogger } from '../../utils/logger.js'
17
+ import { createHash } from 'crypto'
18
+ import type { Memory } from '../../types/index.js'
19
+ import { getLLMProvider, isLLMAvailable } from './index.js'
20
+ import { LLMError } from './base.js'
21
+
22
+ const logger = getLogger('ContradictionDetector')
23
+
24
+ // ============================================================================
25
+ // Prompt Templates
26
+ // ============================================================================
27
+
28
+ export const CONTRADICTION_DETECTOR_SYSTEM_PROMPT = `You are an expert at detecting contradictions and updates between statements.
29
+
30
+ Compare two statements and determine:
31
+ 1. Do they contradict each other?
32
+ 2. Does the NEW statement update or supersede the OLD statement?
33
+ 3. What is your confidence (0.0-1.0)?
34
+
35
+ Types of relationships:
36
+ - CONTRADICTION: Statements directly conflict (both may be valid from different times)
37
+ - UPDATE: NEW corrects or modifies OLD (making OLD outdated)
38
+ - SUPERSEDE: NEW completely replaces OLD (OLD should be archived)
39
+ - COMPATIBLE: No contradiction (related or compatible information)
40
+
41
+ Respond with ONLY a JSON object:
42
+ {
43
+ "isContradiction": boolean,
44
+ "confidence": 0.0-1.0,
45
+ "reason": "brief explanation",
46
+ "shouldSupersede": boolean
47
+ }`
48
+
49
+ export function buildContradictionUserPrompt(newContent: string, existingContent: string): string {
50
+ return `Compare these statements:\n\nOLD: "${existingContent}"\nNEW: "${newContent}"\n\nRespond with JSON only.`
51
+ }
52
+
53
+ // ============================================================================
54
+ // Types
55
+ // ============================================================================
56
+
57
+ export interface ContradictionResult {
58
+ isContradiction: boolean
59
+ confidence: number
60
+ reason: string
61
+ shouldSupersede: boolean
62
+ cached: boolean
63
+ usedLLM: boolean
64
+ }
65
+
66
+ export interface DetectorConfig {
67
+ /** Minimum confidence for contradiction (0-1) */
68
+ minConfidence?: number
69
+ /** Whether to enable caching */
70
+ enableCache?: boolean
71
+ /** Cache TTL in milliseconds */
72
+ cacheTTLMs?: number
73
+ /** Maximum cache size */
74
+ maxCacheSize?: number
75
+ /** Whether to fallback to heuristics on errors */
76
+ fallbackToHeuristics?: boolean
77
+ /** Minimum word overlap ratio to even check (0-1) */
78
+ minOverlapForCheck?: number
79
+ }
80
+
81
+ interface CacheEntry {
82
+ isContradiction: boolean
83
+ confidence: number
84
+ reason: string
85
+ shouldSupersede: boolean
86
+ timestamp: number
87
+ }
88
+
89
+ // ============================================================================
90
+ // Heuristic Patterns
91
+ // ============================================================================
92
+
93
+ const RELATIONSHIP_INDICATORS = {
94
+ updates: [
95
+ /\b(now|currently|as of|updated to|changed to|modified to)\b/i,
96
+ /\b(no longer|not anymore|stopped|quit|left)\b/i,
97
+ ],
98
+ contradicts: [
99
+ /\b(but|however|actually|instead|rather|on the contrary)\b/i,
100
+ /\b(never|not|don't|doesn't|didn't|won't|can't)\b/i,
101
+ ],
102
+ supersedes: [/\b(replaced|superseded|obsolete|deprecated|archived)\b/i, /\b(new version|latest|updated|revised)\b/i],
103
+ }
104
+
105
+ // ============================================================================
106
+ // Contradiction Detector Service
107
+ // ============================================================================
108
+
109
+ export class ContradictionDetectorService {
110
+ private config: Required<DetectorConfig>
111
+ private cache: Map<string, CacheEntry> = new Map()
112
+ private stats = {
113
+ totalChecks: 0,
114
+ llmChecks: 0,
115
+ heuristicChecks: 0,
116
+ cacheHits: 0,
117
+ contradictionsFound: 0,
118
+ errors: 0,
119
+ totalCost: 0,
120
+ }
121
+
122
+ constructor(config: DetectorConfig = {}) {
123
+ this.config = {
124
+ minConfidence: config.minConfidence ?? 0.7,
125
+ enableCache: config.enableCache ?? true,
126
+ cacheTTLMs: config.cacheTTLMs ?? 30 * 60 * 1000, // 30 minutes
127
+ maxCacheSize: config.maxCacheSize ?? 500,
128
+ fallbackToHeuristics: config.fallbackToHeuristics ?? true,
129
+ minOverlapForCheck: config.minOverlapForCheck ?? 0.2,
130
+ }
131
+
132
+ logger.info('Contradiction detector initialized', {
133
+ cacheEnabled: this.config.enableCache,
134
+ fallbackEnabled: this.config.fallbackToHeuristics,
135
+ })
136
+ }
137
+
138
+ // ============================================================================
139
+ // Public API
140
+ // ============================================================================
141
+
142
+ /**
143
+ * Check if a new memory contradicts or updates an existing memory
144
+ *
145
+ * @param newMemory - The new memory being added
146
+ * @param existingMemory - The existing memory to compare against
147
+ * @returns Contradiction detection result
148
+ */
149
+ async checkContradiction(newMemory: Memory, existingMemory: Memory): Promise<ContradictionResult> {
150
+ this.stats.totalChecks++
151
+
152
+ // Check cache first
153
+ if (this.config.enableCache) {
154
+ const cached = this.getCached(newMemory.content, existingMemory.content)
155
+ if (cached) {
156
+ this.stats.cacheHits++
157
+ logger.debug('Cache hit for contradiction check')
158
+ return {
159
+ ...cached,
160
+ cached: true,
161
+ usedLLM: false,
162
+ }
163
+ }
164
+ }
165
+
166
+ // NOTE: We don't skip LLM based on word overlap anymore.
167
+ // The LLM should handle semantic analysis - "I live in New York" vs "I moved to San Francisco"
168
+ // have 0% word overlap but ARE semantically related and need LLM analysis.
169
+ // Only skip for truly empty content.
170
+
171
+ // Try LLM detection if available (semantic analysis with minimal overlap filter)
172
+ if (isLLMAvailable()) {
173
+ try {
174
+ const result = await this.detectWithLLM(newMemory, existingMemory)
175
+ this.stats.llmChecks++
176
+
177
+ if (result.isContradiction) {
178
+ this.stats.contradictionsFound++
179
+ }
180
+
181
+ // Cache the result
182
+ if (this.config.enableCache && result.confidence >= this.config.minConfidence) {
183
+ this.setCached(newMemory.content, existingMemory.content, {
184
+ isContradiction: result.isContradiction,
185
+ confidence: result.confidence,
186
+ reason: result.reason,
187
+ shouldSupersede: result.shouldSupersede,
188
+ timestamp: Date.now(),
189
+ })
190
+ }
191
+
192
+ return {
193
+ ...result,
194
+ cached: false,
195
+ usedLLM: true,
196
+ }
197
+ } catch (error) {
198
+ this.stats.errors++
199
+ logger.warn('LLM contradiction detection failed, falling back to heuristics', {
200
+ error: error instanceof Error ? error.message : String(error),
201
+ })
202
+
203
+ if (!this.config.fallbackToHeuristics) {
204
+ throw error
205
+ }
206
+ }
207
+ }
208
+
209
+ // Fallback to heuristics
210
+ // Only apply overlap filter for heuristics (semantic analysis not available)
211
+ const overlap = this.calculateWordOverlap(newMemory.content, existingMemory.content)
212
+ if (overlap < this.config.minOverlapForCheck) {
213
+ logger.debug('Skipping heuristic check due to low overlap', { overlap })
214
+ return {
215
+ isContradiction: false,
216
+ confidence: 0,
217
+ reason: 'Insufficient content overlap for heuristic analysis',
218
+ shouldSupersede: false,
219
+ cached: false,
220
+ usedLLM: false,
221
+ }
222
+ }
223
+
224
+ const heuristicResult = this.detectWithHeuristics(newMemory, existingMemory)
225
+ this.stats.heuristicChecks++
226
+
227
+ if (heuristicResult.isContradiction) {
228
+ this.stats.contradictionsFound++
229
+ }
230
+
231
+ return {
232
+ ...heuristicResult,
233
+ cached: false,
234
+ usedLLM: false,
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get detection statistics
240
+ */
241
+ getStats() {
242
+ const cacheHitRate = this.stats.totalChecks > 0 ? (this.stats.cacheHits / this.stats.totalChecks) * 100 : 0
243
+
244
+ const contradictionRate =
245
+ this.stats.totalChecks > 0 ? (this.stats.contradictionsFound / this.stats.totalChecks) * 100 : 0
246
+
247
+ return {
248
+ ...this.stats,
249
+ cacheHitRate: parseFloat(cacheHitRate.toFixed(2)),
250
+ contradictionRate: parseFloat(contradictionRate.toFixed(2)),
251
+ cacheSize: this.cache.size,
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Clear the cache
257
+ */
258
+ clearCache(): void {
259
+ this.cache.clear()
260
+ logger.info('Contradiction cache cleared')
261
+ }
262
+
263
+ // ============================================================================
264
+ // LLM Detection
265
+ // ============================================================================
266
+
267
+ private async detectWithLLM(
268
+ newMemory: Memory,
269
+ existingMemory: Memory
270
+ ): Promise<{
271
+ isContradiction: boolean
272
+ confidence: number
273
+ reason: string
274
+ shouldSupersede: boolean
275
+ }> {
276
+ const provider = getLLMProvider()
277
+
278
+ try {
279
+ const response = await provider.generateJson(
280
+ CONTRADICTION_DETECTOR_SYSTEM_PROMPT,
281
+ buildContradictionUserPrompt(newMemory.content, existingMemory.content)
282
+ )
283
+
284
+ const parsed = this.parseJsonResponse(response.rawResponse, response.provider)
285
+
286
+ // Estimate cost
287
+ const inputCost = ((response.tokensUsed?.prompt ?? 0) / 1000000) * 0.25
288
+ const outputCost = ((response.tokensUsed?.completion ?? 0) / 1000000) * 1.25
289
+ this.stats.totalCost += inputCost + outputCost
290
+
291
+ logger.debug('LLM contradiction detection successful', {
292
+ isContradiction: parsed.isContradiction,
293
+ confidence: parsed.confidence,
294
+ tokensUsed: response.tokensUsed?.total ?? 0,
295
+ cost: inputCost + outputCost,
296
+ })
297
+
298
+ return parsed
299
+ } catch (error) {
300
+ if (error instanceof LLMError) {
301
+ throw error
302
+ }
303
+ throw new Error(`LLM contradiction detection failed: ${error instanceof Error ? error.message : String(error)}`)
304
+ }
305
+ }
306
+
307
+ private parseJsonResponse(
308
+ rawResponse: string,
309
+ provider: 'openai' | 'anthropic' | 'mock'
310
+ ): {
311
+ isContradiction: boolean
312
+ confidence: number
313
+ reason: string
314
+ shouldSupersede: boolean
315
+ } {
316
+ const trimmed = rawResponse.trim()
317
+ const jsonMatch = trimmed.startsWith('{') ? trimmed : trimmed.match(/\{[\s\S]*\}/)?.[0]
318
+ if (!jsonMatch) {
319
+ throw LLMError.invalidResponse(provider, 'No JSON object found in response')
320
+ }
321
+
322
+ let parsed: unknown
323
+ try {
324
+ parsed = JSON.parse(jsonMatch)
325
+ } catch {
326
+ throw LLMError.invalidResponse(provider, 'Invalid JSON response')
327
+ }
328
+
329
+ if (
330
+ !parsed ||
331
+ typeof parsed !== 'object' ||
332
+ !('isContradiction' in parsed) ||
333
+ !('confidence' in parsed) ||
334
+ !('reason' in parsed) ||
335
+ !('shouldSupersede' in parsed)
336
+ ) {
337
+ throw LLMError.invalidResponse(provider, 'Missing required fields in JSON response')
338
+ }
339
+
340
+ const isContradiction = (parsed as { isContradiction: boolean }).isContradiction
341
+ const confidence = (parsed as { confidence: number }).confidence
342
+ const reason = (parsed as { reason: string }).reason
343
+ const shouldSupersede = (parsed as { shouldSupersede: boolean }).shouldSupersede
344
+
345
+ if (typeof isContradiction !== 'boolean' || typeof shouldSupersede !== 'boolean') {
346
+ throw LLMError.invalidResponse(provider, 'Invalid boolean fields in response')
347
+ }
348
+ if (typeof confidence !== 'number' || Number.isNaN(confidence)) {
349
+ throw LLMError.invalidResponse(provider, 'Invalid confidence in response')
350
+ }
351
+ if (typeof reason !== 'string') {
352
+ throw LLMError.invalidResponse(provider, 'Invalid reason in response')
353
+ }
354
+
355
+ return { isContradiction, confidence, reason, shouldSupersede }
356
+ }
357
+
358
+ // ============================================================================
359
+ // Heuristic Detection
360
+ // ============================================================================
361
+
362
+ private detectWithHeuristics(
363
+ newMemory: Memory,
364
+ existingMemory: Memory
365
+ ): {
366
+ isContradiction: boolean
367
+ confidence: number
368
+ reason: string
369
+ shouldSupersede: boolean
370
+ } {
371
+ const newLower = newMemory.content.toLowerCase()
372
+ const existingLower = existingMemory.content.toLowerCase()
373
+
374
+ // Calculate word overlap
375
+ const overlap = this.calculateWordOverlap(newLower, existingLower)
376
+
377
+ // Check for update indicators
378
+ let hasUpdateIndicator = false
379
+ for (const pattern of RELATIONSHIP_INDICATORS.updates) {
380
+ if (pattern.test(newLower)) {
381
+ hasUpdateIndicator = true
382
+ break
383
+ }
384
+ }
385
+
386
+ // Check for contradiction indicators
387
+ let hasContradiction = false
388
+ for (const pattern of RELATIONSHIP_INDICATORS.contradicts) {
389
+ if (pattern.test(newLower) && overlap > 0.3) {
390
+ hasContradiction = true
391
+ break
392
+ }
393
+ }
394
+
395
+ // Check for superseding indicators
396
+ let hasSuperseding = false
397
+ for (const pattern of RELATIONSHIP_INDICATORS.supersedes) {
398
+ if (pattern.test(newLower) && overlap > 0.4) {
399
+ hasSuperseding = true
400
+ break
401
+ }
402
+ }
403
+
404
+ const isContradiction = (hasUpdateIndicator || hasContradiction || hasSuperseding) && overlap > 0.3
405
+ const confidence = isContradiction ? Math.min(0.6, overlap + 0.2) : 0.3
406
+ const shouldSupersede = hasSuperseding || (hasUpdateIndicator && overlap > 0.5)
407
+
408
+ let reason = 'No contradiction detected via heuristics'
409
+ if (isContradiction) {
410
+ if (hasSuperseding) {
411
+ reason = 'New memory supersedes existing (via pattern matching)'
412
+ } else if (hasUpdateIndicator) {
413
+ reason = 'New memory updates existing (via pattern matching)'
414
+ } else {
415
+ reason = 'Contradiction detected (via pattern matching)'
416
+ }
417
+ }
418
+
419
+ logger.debug('Heuristic contradiction detection', {
420
+ isContradiction,
421
+ confidence,
422
+ overlap,
423
+ shouldSupersede,
424
+ })
425
+
426
+ return {
427
+ isContradiction,
428
+ confidence,
429
+ reason,
430
+ shouldSupersede,
431
+ }
432
+ }
433
+
434
+ // ============================================================================
435
+ // Helpers
436
+ // ============================================================================
437
+
438
+ private calculateWordOverlap(text1: string, text2: string): number {
439
+ const words1 = new Set(
440
+ text1
441
+ .toLowerCase()
442
+ .split(/\s+/)
443
+ .filter((w) => w.length > 3)
444
+ )
445
+ const words2 = new Set(
446
+ text2
447
+ .toLowerCase()
448
+ .split(/\s+/)
449
+ .filter((w) => w.length > 3)
450
+ )
451
+
452
+ const intersection = new Set([...words1].filter((x) => words2.has(x)))
453
+ const union = new Set([...words1, ...words2])
454
+
455
+ return union.size > 0 ? intersection.size / union.size : 0
456
+ }
457
+
458
+ // ============================================================================
459
+ // Caching
460
+ // ============================================================================
461
+
462
+ private getCacheKey(content1: string, content2: string): string {
463
+ // Normalize and create deterministic key regardless of order
464
+ const normalized = [content1, content2]
465
+ .map((c) => c.substring(0, 200).trim().toLowerCase())
466
+ .sort()
467
+ .join('|||')
468
+ return createHash('sha256').update(normalized).digest('hex')
469
+ }
470
+
471
+ private getCached(content1: string, content2: string): CacheEntry | null {
472
+ const key = this.getCacheKey(content1, content2)
473
+ const entry = this.cache.get(key)
474
+
475
+ if (!entry) {
476
+ return null
477
+ }
478
+
479
+ // Check if expired
480
+ const age = Date.now() - entry.timestamp
481
+ if (age > this.config.cacheTTLMs) {
482
+ this.cache.delete(key)
483
+ return null
484
+ }
485
+
486
+ return entry
487
+ }
488
+
489
+ private setCached(content1: string, content2: string, entry: CacheEntry): void {
490
+ // Enforce cache size limit
491
+ if (this.cache.size >= this.config.maxCacheSize) {
492
+ const entries = Array.from(this.cache.entries())
493
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
494
+ const toRemove = entries.slice(0, Math.floor(this.config.maxCacheSize * 0.1))
495
+ for (const [key] of toRemove) {
496
+ this.cache.delete(key)
497
+ }
498
+ }
499
+
500
+ const key = this.getCacheKey(content1, content2)
501
+ this.cache.set(key, entry)
502
+ }
503
+ }
504
+
505
+ // ============================================================================
506
+ // Singleton Instance
507
+ // ============================================================================
508
+
509
+ let _instance: ContradictionDetectorService | null = null
510
+
511
+ /**
512
+ * Get the singleton instance
513
+ */
514
+ export function getContradictionDetector(config?: DetectorConfig): ContradictionDetectorService {
515
+ if (!_instance) {
516
+ _instance = new ContradictionDetectorService(config)
517
+ }
518
+ return _instance
519
+ }
520
+
521
+ /**
522
+ * Reset the singleton (for testing)
523
+ */
524
+ export function resetContradictionDetector(): void {
525
+ _instance = null
526
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Shared Heuristic Classification Utilities
3
+ *
4
+ * Provides a single source of truth for memory type pattern matching.
5
+ */
6
+
7
+ import type { MemoryType } from '../../types/index.js'
8
+
9
+ // ============================================================================
10
+ // Memory Type Classification Patterns
11
+ // ============================================================================
12
+
13
+ const FACT_PATTERNS: readonly RegExp[] = [
14
+ /\b(?:is|are|was|were|has|have|had)\b/i,
15
+ /\b(?:born|died|founded|created|invented)\b/i,
16
+ /\b(?:located|situated|found)\s+(?:in|at|on)\b/i,
17
+ /\b(?:equals|means|represents)\b/i,
18
+ ]
19
+
20
+ const EVENT_PATTERNS: readonly RegExp[] = [
21
+ /\b(?:happened|occurred|took place)\b/i,
22
+ /\b(?:yesterday|today|tomorrow|last|next)\s+(?:week|month|year|day)\b/i,
23
+ /\b(?:on|at)\s+\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b/i,
24
+ /\b(?:meeting|conference|event|party|celebration)\b/i,
25
+ ]
26
+
27
+ const PREFERENCE_PATTERNS: readonly RegExp[] = [
28
+ /\b(?:prefer|like|love|enjoy|hate|dislike)\b/i,
29
+ /\b(?:favorite|favourite|best|worst)\b/i,
30
+ /\b(?:want|wish|hope|desire)\b/i,
31
+ /\b(?:always|never|usually|often)\s+(?:use|choose|pick|select)\b/i,
32
+ ]
33
+
34
+ const SKILL_PATTERNS: readonly RegExp[] = [
35
+ /\b(?:know|learn|understand|master)\s+(?:how to|to)\b/i,
36
+ /\b(?:can|able to|capable of)\b/i,
37
+ /\b(?:expert|proficient|skilled|experienced)\s+(?:in|at|with)\b/i,
38
+ /\b(?:programming|coding|developing|designing)\b/i,
39
+ ]
40
+
41
+ const RELATIONSHIP_PATTERNS: readonly RegExp[] = [
42
+ /\b(?:married|engaged|dating|friends with)\b/i,
43
+ /\b(?:works|worked)\s+(?:for|with|at)\b/i,
44
+ /\b(?:brother|sister|mother|father|parent|child|spouse)\b/i,
45
+ /\b(?:colleague|teammate|partner|boss|manager)\b/i,
46
+ ]
47
+
48
+ const CONTEXT_PATTERNS: readonly RegExp[] = [
49
+ /\b(?:currently|right now|at the moment)\b/i,
50
+ /\b(?:working on|thinking about|planning)\b/i,
51
+ /\b(?:in the context of|regarding|about)\b/i,
52
+ /\b(?:situation|scenario|case)\b/i,
53
+ ]
54
+
55
+ const NOTE_PATTERNS: readonly RegExp[] = [
56
+ /^(?:note|reminder|todo|remember)\s*:/i,
57
+ /\b(?:don't forget|keep in mind|note that)\b/i,
58
+ /^#|^\*|^-\s/m,
59
+ /\b(?:important|key|critical)\s+(?:point|note|fact)\b/i,
60
+ ]
61
+
62
+ export const MEMORY_TYPE_PATTERNS: Record<MemoryType, readonly RegExp[]> = {
63
+ fact: FACT_PATTERNS,
64
+ event: EVENT_PATTERNS,
65
+ preference: PREFERENCE_PATTERNS,
66
+ skill: SKILL_PATTERNS,
67
+ relationship: RELATIONSHIP_PATTERNS,
68
+ context: CONTEXT_PATTERNS,
69
+ note: NOTE_PATTERNS,
70
+ }
71
+
72
+ // ============================================================================
73
+ // Heuristic Helpers
74
+ // ============================================================================
75
+
76
+ export function getMemoryTypeScores(content: string): Record<MemoryType, number> {
77
+ const scores: Record<MemoryType, number> = {
78
+ fact: 0,
79
+ event: 0,
80
+ preference: 0,
81
+ skill: 0,
82
+ relationship: 0,
83
+ context: 0,
84
+ note: 0,
85
+ }
86
+
87
+ for (const [type, patterns] of Object.entries(MEMORY_TYPE_PATTERNS)) {
88
+ for (const pattern of patterns) {
89
+ if (pattern.test(content)) {
90
+ scores[type as MemoryType] += 1
91
+ }
92
+ }
93
+ }
94
+
95
+ return scores
96
+ }
97
+
98
+ export function classifyMemoryTypeHeuristically(content: string): {
99
+ type: MemoryType
100
+ matchCount: number
101
+ scores: Record<MemoryType, number>
102
+ } {
103
+ const scores = getMemoryTypeScores(content)
104
+ const maxScore = Math.max(...Object.values(scores))
105
+
106
+ if (maxScore === 0) {
107
+ return { type: 'note', matchCount: 0, scores }
108
+ }
109
+
110
+ const matchedType = Object.entries(scores).find(([_, score]) => score === maxScore)
111
+ return {
112
+ type: (matchedType?.[0] as MemoryType) || 'note',
113
+ matchCount: maxScore,
114
+ scores,
115
+ }
116
+ }
117
+
118
+ export function countMemoryTypeMatches(content: string, type: MemoryType): number {
119
+ const patterns = MEMORY_TYPE_PATTERNS[type] || []
120
+ let matchCount = 0
121
+ for (const pattern of patterns) {
122
+ if (pattern.test(content)) {
123
+ matchCount += 1
124
+ }
125
+ }
126
+ return matchCount
127
+ }
128
+
129
+ export function calculateHeuristicConfidence(
130
+ matchCount: number,
131
+ options: {
132
+ base?: number
133
+ perMatch?: number
134
+ max?: number
135
+ defaultConfidence?: number
136
+ } = {}
137
+ ): number {
138
+ const base = options.base ?? 0.5
139
+ const perMatch = options.perMatch ?? 0.1
140
+ const max = options.max ?? 0.9
141
+ const defaultConfidence = options.defaultConfidence ?? 0.3
142
+
143
+ if (matchCount <= 0) {
144
+ return defaultConfidence
145
+ }
146
+
147
+ return Math.min(base + matchCount * perMatch, max)
148
+ }