@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,560 @@
1
+ /**
2
+ * Profile Service - User profile management for Supermemory Clone
3
+ *
4
+ * Manages user profiles with automatic fact extraction, classification,
5
+ * and lifecycle management. Profiles complement search by providing
6
+ * always-available context about users.
7
+ */
8
+
9
+ import { v4 as uuidv4 } from 'uuid'
10
+ import {
11
+ UserProfile,
12
+ ProfileFact,
13
+ FactClassification,
14
+ FactCategory,
15
+ ExtractionResult,
16
+ ProfileOptions,
17
+ PromotionCriteria,
18
+ PROFILE_DEFAULTS,
19
+ getStaticFactPatterns,
20
+ getDynamicFactPatterns,
21
+ } from './profile.types.js'
22
+ import { ProfileRepository, profileRepository } from './profile.repository.js'
23
+
24
+ /**
25
+ * Profile Service - Main class for profile operations
26
+ */
27
+ export class ProfileService {
28
+ private repository: ProfileRepository
29
+ private options: Required<Omit<ProfileOptions, 'staticFactPatterns' | 'dynamicFactPatterns'>>
30
+ private staticPatterns: RegExp[]
31
+ private dynamicPatterns: RegExp[]
32
+
33
+ constructor(repository?: ProfileRepository, options?: ProfileOptions) {
34
+ this.repository = repository ?? profileRepository
35
+ this.options = {
36
+ autoExtract: options?.autoExtract ?? true,
37
+ refreshDynamic: options?.refreshDynamic ?? true,
38
+ maxDynamicFacts: options?.maxDynamicFacts ?? PROFILE_DEFAULTS.maxDynamicFacts,
39
+ defaultDynamicExpirationHours:
40
+ options?.defaultDynamicExpirationHours ?? PROFILE_DEFAULTS.defaultDynamicExpirationHours,
41
+ }
42
+ // Initialize patterns from options or environment, falling back to defaults
43
+ this.staticPatterns = getStaticFactPatterns(options?.staticFactPatterns)
44
+ this.dynamicPatterns = getDynamicFactPatterns(options?.dynamicFactPatterns)
45
+ }
46
+
47
+ /**
48
+ * Get or create a profile for a container tag
49
+ */
50
+ async getProfile(containerTag: string): Promise<UserProfile> {
51
+ let profile = await this.repository.findByContainerTag(containerTag)
52
+
53
+ if (!profile) {
54
+ profile = await this.createEmptyProfile(containerTag)
55
+ }
56
+
57
+ // Optionally refresh dynamic facts
58
+ if (this.options.refreshDynamic) {
59
+ profile = (await this.refreshDynamicFacts(containerTag)) ?? profile
60
+ }
61
+
62
+ return profile
63
+ }
64
+
65
+ /**
66
+ * Update profile with new facts
67
+ */
68
+ async updateProfile(containerTag: string, facts: ProfileFact[]): Promise<UserProfile> {
69
+ const profile = await this.getProfile(containerTag)
70
+
71
+ const staticFacts = [...profile.staticFacts]
72
+ const dynamicFacts = [...profile.dynamicFacts]
73
+
74
+ for (const fact of facts) {
75
+ // Check for duplicates
76
+ const isDuplicate = this.isDuplicateFact(fact, [...staticFacts, ...dynamicFacts])
77
+ if (isDuplicate) {
78
+ // Reinforce existing fact instead of adding duplicate
79
+ await this.reinforceMatchingFact(containerTag, fact)
80
+ continue
81
+ }
82
+
83
+ if (fact.type === 'static') {
84
+ staticFacts.push(fact)
85
+ } else {
86
+ dynamicFacts.push(fact)
87
+ }
88
+ }
89
+
90
+ const updated = await this.repository.updateFacts(containerTag, staticFacts, this.enforceDynamicLimit(dynamicFacts))
91
+
92
+ return updated ?? profile
93
+ }
94
+
95
+ /**
96
+ * Extract profile facts from content
97
+ */
98
+ extractProfileFacts(content: string, sourceId?: string): ExtractionResult {
99
+ const startTime = Date.now()
100
+ const facts: ProfileFact[] = []
101
+
102
+ // Split content into sentences
103
+ const sentences = this.splitIntoSentences(content)
104
+
105
+ for (const sentence of sentences) {
106
+ const extractedFact = this.extractFactFromSentence(sentence, sourceId)
107
+ if (extractedFact) {
108
+ facts.push(extractedFact)
109
+ }
110
+ }
111
+
112
+ // Deduplicate facts
113
+ const uniqueFacts = this.deduplicateFacts(facts)
114
+
115
+ return {
116
+ facts: uniqueFacts,
117
+ rawContent: content,
118
+ extractedAt: new Date(),
119
+ processingTimeMs: Date.now() - startTime,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Classify a fact as static or dynamic.
125
+ *
126
+ * Uses configurable patterns that can be overridden via:
127
+ * - ProfileOptions.staticFactPatterns / ProfileOptions.dynamicFactPatterns
128
+ * - SUPERMEMORY_STATIC_PATTERNS / SUPERMEMORY_DYNAMIC_PATTERNS environment variables
129
+ */
130
+ classifyFact(factContent: string): FactClassification {
131
+ // Check for static patterns first
132
+ for (const pattern of this.staticPatterns) {
133
+ if (pattern.test(factContent)) {
134
+ return {
135
+ type: 'static',
136
+ confidence: 0.85,
137
+ reason: `Matches static pattern: ${pattern.source.slice(0, 30)}...`,
138
+ }
139
+ }
140
+ }
141
+
142
+ // Check for dynamic patterns
143
+ for (const pattern of this.dynamicPatterns) {
144
+ if (pattern.test(factContent)) {
145
+ const expirationHours = this.estimateExpirationHours(factContent)
146
+ return {
147
+ type: 'dynamic',
148
+ confidence: 0.8,
149
+ reason: `Matches dynamic pattern: ${pattern.source.slice(0, 30)}...`,
150
+ suggestedExpirationHours: expirationHours,
151
+ }
152
+ }
153
+ }
154
+
155
+ // Default to dynamic with lower confidence if no pattern matches
156
+ return {
157
+ type: 'dynamic',
158
+ confidence: 0.5,
159
+ reason: 'No strong pattern match, defaulting to dynamic',
160
+ suggestedExpirationHours: this.options.defaultDynamicExpirationHours,
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Refresh dynamic facts - remove expired and check for promotions
166
+ */
167
+ async refreshDynamicFacts(containerTag: string): Promise<UserProfile | null> {
168
+ // Remove expired facts
169
+ let profile = await this.repository.removeExpiredFacts(containerTag)
170
+ if (!profile) {
171
+ return null
172
+ }
173
+
174
+ // Check for facts that should be promoted to static
175
+ const promotionCandidates = this.findPromotionCandidates(profile.dynamicFacts)
176
+
177
+ for (const candidate of promotionCandidates) {
178
+ await this.promoteFact(containerTag, candidate.id)
179
+ }
180
+
181
+ // Re-fetch profile after promotions
182
+ if (promotionCandidates.length > 0) {
183
+ profile = (await this.repository.findByContainerTag(containerTag)) ?? profile
184
+ }
185
+
186
+ return profile
187
+ }
188
+
189
+ /**
190
+ * Ingest content and automatically extract/store facts
191
+ */
192
+ async ingestContent(containerTag: string, content: string, sourceId?: string): Promise<ExtractionResult> {
193
+ const result = this.extractProfileFacts(content, sourceId)
194
+
195
+ if (result.facts.length > 0 && this.options.autoExtract) {
196
+ await this.updateProfile(containerTag, result.facts)
197
+ }
198
+
199
+ return result
200
+ }
201
+
202
+ /**
203
+ * Get profile context for search augmentation
204
+ */
205
+ async getProfileContext(containerTag: string): Promise<string> {
206
+ const profile = await this.getProfile(containerTag)
207
+
208
+ const staticContext = profile.staticFacts.map((f: ProfileFact) => f.content).join('. ')
209
+
210
+ const dynamicContext = profile.dynamicFacts.map((f: ProfileFact) => f.content).join('. ')
211
+
212
+ const parts: string[] = []
213
+ if (staticContext) {
214
+ parts.push(`Background: ${staticContext}`)
215
+ }
216
+ if (dynamicContext) {
217
+ parts.push(`Current context: ${dynamicContext}`)
218
+ }
219
+
220
+ return parts.join('\n\n')
221
+ }
222
+
223
+ /**
224
+ * Manually promote a dynamic fact to static
225
+ */
226
+ async promoteFact(containerTag: string, factId: string): Promise<ProfileFact | null> {
227
+ return this.repository.promoteFact(containerTag, factId)
228
+ }
229
+
230
+ /**
231
+ * Get statistics about a profile
232
+ */
233
+ async getProfileStats(containerTag: string): Promise<ProfileStats> {
234
+ const profile = await this.getProfile(containerTag)
235
+ const now = new Date()
236
+
237
+ const expiringWithin24h = profile.dynamicFacts.filter((f: ProfileFact) => {
238
+ if (!f.expiresAt) return false
239
+ const hoursUntilExpiry = (f.expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60)
240
+ return hoursUntilExpiry > 0 && hoursUntilExpiry <= 24
241
+ }).length
242
+
243
+ const promotionCandidates = this.findPromotionCandidates(profile.dynamicFacts).length
244
+
245
+ const categoryBreakdown = this.getCategoryBreakdown([...profile.staticFacts, ...profile.dynamicFacts])
246
+
247
+ return {
248
+ totalFacts: profile.staticFacts.length + profile.dynamicFacts.length,
249
+ staticFacts: profile.staticFacts.length,
250
+ dynamicFacts: profile.dynamicFacts.length,
251
+ expiringWithin24h,
252
+ promotionCandidates,
253
+ categoryBreakdown,
254
+ lastUpdated: profile.updatedAt,
255
+ }
256
+ }
257
+
258
+ // ============ Private Helper Methods ============
259
+
260
+ /**
261
+ * Create an empty profile
262
+ */
263
+ private async createEmptyProfile(containerTag: string): Promise<UserProfile> {
264
+ const profile: UserProfile = {
265
+ containerTag,
266
+ staticFacts: [],
267
+ dynamicFacts: [],
268
+ createdAt: new Date(),
269
+ updatedAt: new Date(),
270
+ version: 1,
271
+ }
272
+ return this.repository.upsert(profile)
273
+ }
274
+
275
+ /**
276
+ * Split content into sentences for fact extraction
277
+ */
278
+ private splitIntoSentences(content: string): string[] {
279
+ // Split on sentence-ending punctuation, keeping the punctuation
280
+ const sentences = content
281
+ .split(/(?<=[.!?])\s+/)
282
+ .map((s) => s.trim())
283
+ .filter((s) => s.length > 10) // Filter out very short fragments
284
+
285
+ return sentences
286
+ }
287
+
288
+ /**
289
+ * Extract a fact from a single sentence
290
+ */
291
+ private extractFactFromSentence(sentence: string, sourceId?: string): ProfileFact | null {
292
+ // Check if sentence contains a fact-like structure
293
+ if (!this.containsPotentialFact(sentence)) {
294
+ return null
295
+ }
296
+
297
+ const classification = this.classifyFact(sentence)
298
+ const category = this.categorizeFactContent(sentence)
299
+ const now = new Date()
300
+
301
+ const fact: ProfileFact = {
302
+ id: uuidv4(),
303
+ content: sentence,
304
+ type: classification.type,
305
+ extractedAt: now,
306
+ confidence: classification.confidence,
307
+ category,
308
+ reinforcementCount: 0,
309
+ lastAccessedAt: now,
310
+ sourceId,
311
+ }
312
+
313
+ // Set expiration for dynamic facts
314
+ if (classification.type === 'dynamic') {
315
+ const expirationHours = classification.suggestedExpirationHours ?? this.options.defaultDynamicExpirationHours
316
+ fact.expiresAt = new Date(now.getTime() + expirationHours * 60 * 60 * 1000)
317
+ }
318
+
319
+ return fact
320
+ }
321
+
322
+ /**
323
+ * Check if a sentence potentially contains a fact
324
+ */
325
+ private containsPotentialFact(sentence: string): boolean {
326
+ // Must have a subject-verb structure (contains "is", "are", "has", "works", etc.)
327
+ const factIndicators = [
328
+ /\b(is|are|was|were)\b/i,
329
+ /\b(has|have|had)\b/i,
330
+ /\b(works|worked|working)\b/i,
331
+ /\b(prefers?|likes?|loves?|hates?)\b/i,
332
+ /\b(uses?|using)\b/i,
333
+ /\b(knows?|knowing)\b/i,
334
+ /\b(studies|studied|studying)\b/i,
335
+ /\b(lives?|living|based)\b/i,
336
+ /\b(speaks?|speaking)\b/i,
337
+ /\b(currently|right now|today)\b/i,
338
+ ]
339
+
340
+ return factIndicators.some((pattern) => pattern.test(sentence))
341
+ }
342
+
343
+ /**
344
+ * Categorize fact content
345
+ */
346
+ private categorizeFactContent(content: string): FactCategory {
347
+ const lower = content.toLowerCase()
348
+
349
+ if (/\b(engineer|developer|manager|designer|architect|analyst)\b/.test(lower)) {
350
+ return 'identity'
351
+ }
352
+ if (/\b(prefers?|likes?|loves?|hates?|favorite)\b/.test(lower)) {
353
+ return 'preference'
354
+ }
355
+ if (/\b(skills?|expertise|proficient|experienced in|knows?)\b/.test(lower)) {
356
+ return 'skill'
357
+ }
358
+ if (/\b(graduated|studied|degree|university|college|school)\b/.test(lower)) {
359
+ return 'background'
360
+ }
361
+ if (/\b(team|colleague|reports to|works with|manager)\b/.test(lower)) {
362
+ return 'relationship'
363
+ }
364
+ if (/\b(project|building|developing|working on)\b/.test(lower)) {
365
+ return 'project'
366
+ }
367
+ if (/\b(goals?|objectives?|wants to|plans? to|aims? to)\b/.test(lower)) {
368
+ return 'goal'
369
+ }
370
+ if (/\b(currently|right now|today|this week|at the moment)\b/.test(lower)) {
371
+ return 'context'
372
+ }
373
+
374
+ return 'other'
375
+ }
376
+
377
+ /**
378
+ * Estimate expiration hours based on content
379
+ */
380
+ private estimateExpirationHours(content: string): number {
381
+ const lower = content.toLowerCase()
382
+
383
+ // Very short-term indicators
384
+ if (/\b(right now|at the moment|today)\b/.test(lower)) {
385
+ return 24 // 1 day
386
+ }
387
+
388
+ // Short-term indicators
389
+ if (/\b(this week|currently)\b/.test(lower)) {
390
+ return 72 // 3 days
391
+ }
392
+
393
+ // Medium-term indicators
394
+ if (/\b(this month|recently|lately)\b/.test(lower)) {
395
+ return 168 // 1 week
396
+ }
397
+
398
+ // Project-related (longer duration)
399
+ if (/\b(working on|building|developing|project)\b/.test(lower)) {
400
+ return 336 // 2 weeks
401
+ }
402
+
403
+ return this.options.defaultDynamicExpirationHours
404
+ }
405
+
406
+ /**
407
+ * Check if a fact is a duplicate of existing facts
408
+ */
409
+ private isDuplicateFact(newFact: ProfileFact, existingFacts: ProfileFact[]): boolean {
410
+ const newContent = newFact.content.toLowerCase()
411
+
412
+ return existingFacts.some((existing) => {
413
+ const existingContent = existing.content.toLowerCase()
414
+
415
+ // Exact match
416
+ if (newContent === existingContent) {
417
+ return true
418
+ }
419
+
420
+ // High similarity (simple Jaccard-like check)
421
+ const similarity = this.calculateSimilarity(newContent, existingContent)
422
+ return similarity > 0.8
423
+ })
424
+ }
425
+
426
+ /**
427
+ * Calculate simple similarity between two strings
428
+ */
429
+ private calculateSimilarity(a: string, b: string): number {
430
+ const wordsA = new Set(a.split(/\s+/))
431
+ const wordsB = new Set(b.split(/\s+/))
432
+
433
+ const intersection = new Set(Array.from(wordsA).filter((w) => wordsB.has(w)))
434
+ const union = new Set([...Array.from(wordsA), ...Array.from(wordsB)])
435
+
436
+ return intersection.size / union.size
437
+ }
438
+
439
+ /**
440
+ * Reinforce a matching existing fact
441
+ */
442
+ private async reinforceMatchingFact(containerTag: string, newFact: ProfileFact): Promise<void> {
443
+ const profile = await this.repository.findByContainerTag(containerTag)
444
+ if (!profile) return
445
+
446
+ const allFacts = [...profile.staticFacts, ...profile.dynamicFacts]
447
+ const matching = allFacts.find(
448
+ (f) => this.calculateSimilarity(f.content.toLowerCase(), newFact.content.toLowerCase()) > 0.8
449
+ )
450
+
451
+ if (matching) {
452
+ await this.repository.reinforceFact(containerTag, matching.id)
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Deduplicate a list of facts
458
+ */
459
+ private deduplicateFacts(facts: ProfileFact[]): ProfileFact[] {
460
+ const unique: ProfileFact[] = []
461
+
462
+ for (const fact of facts) {
463
+ if (!this.isDuplicateFact(fact, unique)) {
464
+ unique.push(fact)
465
+ }
466
+ }
467
+
468
+ return unique
469
+ }
470
+
471
+ /**
472
+ * Enforce the maximum dynamic facts limit
473
+ */
474
+ private enforceDynamicLimit(facts: ProfileFact[]): ProfileFact[] {
475
+ if (facts.length <= this.options.maxDynamicFacts) {
476
+ return facts
477
+ }
478
+
479
+ // Sort by relevance (confidence + recency)
480
+ const scored = facts.map((fact) => ({
481
+ fact,
482
+ score: this.calculateFactScore(fact),
483
+ }))
484
+
485
+ scored.sort((a, b) => b.score - a.score)
486
+
487
+ return scored.slice(0, this.options.maxDynamicFacts).map((s) => s.fact)
488
+ }
489
+
490
+ /**
491
+ * Calculate a relevance score for a fact
492
+ */
493
+ private calculateFactScore(fact: ProfileFact): number {
494
+ const now = new Date()
495
+ const ageHours = (now.getTime() - fact.extractedAt.getTime()) / (1000 * 60 * 60)
496
+ const recencyScore = Math.exp(-ageHours / 72) // Decay with half-life of ~50 hours
497
+
498
+ return fact.confidence * 0.5 + recencyScore * 0.3 + (fact.reinforcementCount / 10) * 0.2
499
+ }
500
+
501
+ /**
502
+ * Find dynamic facts that are candidates for promotion to static
503
+ */
504
+ private findPromotionCandidates(
505
+ dynamicFacts: ProfileFact[],
506
+ criteria: PromotionCriteria = PROFILE_DEFAULTS.promotionCriteria
507
+ ): ProfileFact[] {
508
+ const now = new Date()
509
+
510
+ return dynamicFacts.filter((fact) => {
511
+ const ageDays = (now.getTime() - fact.extractedAt.getTime()) / (1000 * 60 * 60 * 24)
512
+
513
+ return (
514
+ fact.reinforcementCount >= criteria.minReinforcementCount &&
515
+ ageDays >= criteria.minAgeDays &&
516
+ fact.confidence >= criteria.minConfidence
517
+ )
518
+ })
519
+ }
520
+
521
+ /**
522
+ * Get category breakdown of facts
523
+ */
524
+ private getCategoryBreakdown(facts: ProfileFact[]): Record<FactCategory, number> {
525
+ const breakdown: Record<FactCategory, number> = {
526
+ identity: 0,
527
+ preference: 0,
528
+ skill: 0,
529
+ background: 0,
530
+ relationship: 0,
531
+ project: 0,
532
+ goal: 0,
533
+ context: 0,
534
+ other: 0,
535
+ }
536
+
537
+ for (const fact of facts) {
538
+ const category: FactCategory = fact.category ?? 'other'
539
+ breakdown[category] = (breakdown[category] ?? 0) + 1
540
+ }
541
+
542
+ return breakdown
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Profile statistics
548
+ */
549
+ export interface ProfileStats {
550
+ totalFacts: number
551
+ staticFacts: number
552
+ dynamicFacts: number
553
+ expiringWithin24h: number
554
+ promotionCandidates: number
555
+ categoryBreakdown: Record<FactCategory, number>
556
+ lastUpdated: Date
557
+ }
558
+
559
+ // Export singleton instance
560
+ export const profileService = new ProfileService()