@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.
- package/.env.example +57 -0
- package/README.md +374 -0
- package/dist/index.js +189 -0
- package/dist/mcp/index.js +1132 -0
- package/docker-compose.prod.yml +91 -0
- package/docker-compose.yml +358 -0
- package/drizzle/0000_dapper_the_professor.sql +159 -0
- package/drizzle/0001_api_keys.sql +51 -0
- package/drizzle/meta/0000_snapshot.json +1532 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +20 -0
- package/package.json +114 -0
- package/scripts/add-extraction-job.ts +122 -0
- package/scripts/benchmark-pgvector.ts +122 -0
- package/scripts/bootstrap.sh +209 -0
- package/scripts/check-runtime-pack.ts +111 -0
- package/scripts/claude-mcp-config.ts +336 -0
- package/scripts/docker-entrypoint.sh +183 -0
- package/scripts/doctor.ts +377 -0
- package/scripts/init-db.sql +33 -0
- package/scripts/install.sh +1110 -0
- package/scripts/mcp-setup.ts +271 -0
- package/scripts/migrations/001_create_pgvector_extension.sql +31 -0
- package/scripts/migrations/002_create_memory_embeddings_table.sql +75 -0
- package/scripts/migrations/003_create_hnsw_index.sql +94 -0
- package/scripts/migrations/004_create_memory_embeddings_standalone.sql +70 -0
- package/scripts/migrations/005_create_chunks_table.sql +95 -0
- package/scripts/migrations/006_create_processing_queue.sql +45 -0
- package/scripts/migrations/generate_test_data.sql +42 -0
- package/scripts/migrations/phase1_comprehensive_test.sql +204 -0
- package/scripts/migrations/run_migrations.sh +286 -0
- package/scripts/migrations/test_hnsw_index.sql +255 -0
- package/scripts/pre-commit-secrets +282 -0
- package/scripts/run-extraction-worker.ts +46 -0
- package/scripts/run-phase1-tests.sh +291 -0
- package/scripts/setup.ts +222 -0
- package/scripts/smoke-install.sh +12 -0
- package/scripts/test-health-endpoint.sh +328 -0
- package/src/api/index.ts +2 -0
- package/src/api/middleware/auth.ts +80 -0
- package/src/api/middleware/csrf.ts +308 -0
- package/src/api/middleware/errorHandler.ts +166 -0
- package/src/api/middleware/rateLimit.ts +360 -0
- package/src/api/middleware/validation.ts +514 -0
- package/src/api/routes/documents.ts +286 -0
- package/src/api/routes/profiles.ts +237 -0
- package/src/api/routes/search.ts +71 -0
- package/src/api/stores/index.ts +58 -0
- package/src/config/bootstrap-env.ts +3 -0
- package/src/config/env.ts +71 -0
- package/src/config/feature-flags.ts +25 -0
- package/src/config/index.ts +140 -0
- package/src/config/secrets.config.ts +291 -0
- package/src/db/client.ts +92 -0
- package/src/db/index.ts +73 -0
- package/src/db/postgres.ts +72 -0
- package/src/db/schema/chunks.schema.ts +31 -0
- package/src/db/schema/containers.schema.ts +46 -0
- package/src/db/schema/documents.schema.ts +49 -0
- package/src/db/schema/embeddings.schema.ts +32 -0
- package/src/db/schema/index.ts +11 -0
- package/src/db/schema/memories.schema.ts +72 -0
- package/src/db/schema/profiles.schema.ts +34 -0
- package/src/db/schema/queue.schema.ts +59 -0
- package/src/db/schema/relationships.schema.ts +42 -0
- package/src/db/schema.ts +223 -0
- package/src/db/worker-connection.ts +47 -0
- package/src/index.ts +235 -0
- package/src/mcp/CLAUDE.md +1 -0
- package/src/mcp/index.ts +1380 -0
- package/src/mcp/legacyState.ts +22 -0
- package/src/mcp/rateLimit.ts +358 -0
- package/src/mcp/resources.ts +309 -0
- package/src/mcp/results.ts +104 -0
- package/src/mcp/tools.ts +401 -0
- package/src/queues/config.ts +119 -0
- package/src/queues/index.ts +289 -0
- package/src/sdk/client.ts +225 -0
- package/src/sdk/errors.ts +266 -0
- package/src/sdk/http.ts +560 -0
- package/src/sdk/index.ts +244 -0
- package/src/sdk/resources/base.ts +65 -0
- package/src/sdk/resources/connections.ts +204 -0
- package/src/sdk/resources/documents.ts +163 -0
- package/src/sdk/resources/index.ts +10 -0
- package/src/sdk/resources/memories.ts +150 -0
- package/src/sdk/resources/search.ts +60 -0
- package/src/sdk/resources/settings.ts +36 -0
- package/src/sdk/types.ts +674 -0
- package/src/services/chunking/index.ts +451 -0
- package/src/services/chunking.service.ts +650 -0
- package/src/services/csrf.service.ts +252 -0
- package/src/services/documents.repository.ts +219 -0
- package/src/services/documents.service.ts +191 -0
- package/src/services/embedding.service.ts +404 -0
- package/src/services/extraction.service.ts +300 -0
- package/src/services/extractors/code.extractor.ts +451 -0
- package/src/services/extractors/index.ts +9 -0
- package/src/services/extractors/markdown.extractor.ts +461 -0
- package/src/services/extractors/pdf.extractor.ts +315 -0
- package/src/services/extractors/text.extractor.ts +118 -0
- package/src/services/extractors/url.extractor.ts +243 -0
- package/src/services/index.ts +235 -0
- package/src/services/ingestion.service.ts +177 -0
- package/src/services/llm/anthropic.ts +400 -0
- package/src/services/llm/base.ts +460 -0
- package/src/services/llm/contradiction-detector.service.ts +526 -0
- package/src/services/llm/heuristics.ts +148 -0
- package/src/services/llm/index.ts +309 -0
- package/src/services/llm/memory-classifier.service.ts +383 -0
- package/src/services/llm/memory-extension-detector.service.ts +523 -0
- package/src/services/llm/mock.ts +470 -0
- package/src/services/llm/openai.ts +398 -0
- package/src/services/llm/prompts.ts +438 -0
- package/src/services/llm/types.ts +373 -0
- package/src/services/memory.repository.ts +1769 -0
- package/src/services/memory.service.ts +1338 -0
- package/src/services/memory.types.ts +234 -0
- package/src/services/persistence/index.ts +295 -0
- package/src/services/pipeline.service.ts +509 -0
- package/src/services/profile.repository.ts +436 -0
- package/src/services/profile.service.ts +560 -0
- package/src/services/profile.types.ts +270 -0
- package/src/services/relationships/detector.ts +1128 -0
- package/src/services/relationships/index.ts +268 -0
- package/src/services/relationships/memory-integration.ts +459 -0
- package/src/services/relationships/strategies.ts +132 -0
- package/src/services/relationships/types.ts +370 -0
- package/src/services/search.service.ts +761 -0
- package/src/services/search.types.ts +220 -0
- package/src/services/secrets.service.ts +384 -0
- package/src/services/vectorstore/base.ts +327 -0
- package/src/services/vectorstore/index.ts +444 -0
- package/src/services/vectorstore/memory.ts +286 -0
- package/src/services/vectorstore/migration.ts +295 -0
- package/src/services/vectorstore/mock.ts +403 -0
- package/src/services/vectorstore/pgvector.ts +695 -0
- package/src/services/vectorstore/types.ts +247 -0
- package/src/startup.ts +389 -0
- package/src/types/api.types.ts +193 -0
- package/src/types/document.types.ts +103 -0
- package/src/types/index.ts +241 -0
- package/src/types/profile.base.ts +133 -0
- package/src/utils/errors.ts +447 -0
- package/src/utils/id.ts +15 -0
- package/src/utils/index.ts +101 -0
- package/src/utils/logger.ts +313 -0
- package/src/utils/sanitization.ts +501 -0
- package/src/utils/secret-validation.ts +273 -0
- package/src/utils/synonyms.ts +188 -0
- package/src/utils/validation.ts +581 -0
- package/src/workers/chunking.worker.ts +242 -0
- package/src/workers/embedding.worker.ts +358 -0
- package/src/workers/extraction.worker.ts +346 -0
- package/src/workers/indexing.worker.ts +505 -0
- 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()
|