@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,309 @@
1
+ /**
2
+ * LLM Provider Module
3
+ *
4
+ * Factory functions and exports for LLM-based memory extraction.
5
+ * Provides a unified interface for multiple LLM providers.
6
+ */
7
+
8
+ import { getLogger } from '../../utils/logger.js'
9
+ import { config as appConfig } from '../../config/index.js'
10
+ import { isLLMFeatureEnabled } from '../../config/feature-flags.js'
11
+ import type {
12
+ LLMProvider,
13
+ LLMProviderType,
14
+ OpenAILLMConfig,
15
+ AnthropicLLMConfig,
16
+ MockLLMConfig,
17
+ CacheConfig,
18
+ } from './types.js'
19
+ import { createOpenAIProvider } from './openai.js'
20
+ import { createAnthropicProvider } from './anthropic.js'
21
+ import { createMockProvider } from './mock.js'
22
+
23
+ const logger = getLogger('LLMFactory')
24
+
25
+ // ============================================================================
26
+ // Re-exports
27
+ // ============================================================================
28
+
29
+ // Types
30
+ export * from './types.js'
31
+
32
+ // Base
33
+ export { BaseLLMProvider, LLMError, DEFAULT_LLM_CONFIG, DEFAULT_CACHE_CONFIG } from './base.js'
34
+
35
+ // Providers
36
+ export { OpenAILLMProvider, createOpenAIProvider } from './openai.js'
37
+ export { AnthropicLLMProvider, createAnthropicProvider } from './anthropic.js'
38
+ export { MockLLMProvider, createMockProvider } from './mock.js'
39
+
40
+ // Prompts (for testing/customization)
41
+ export {
42
+ MEMORY_EXTRACTION_SYSTEM_PROMPT,
43
+ MEMORY_EXTRACTION_EXAMPLES,
44
+ RELATIONSHIP_DETECTION_SYSTEM_PROMPT,
45
+ RELATIONSHIP_DETECTION_EXAMPLES,
46
+ generateExtractionPrompt,
47
+ generateRelationshipPrompt,
48
+ parseExtractionResponse,
49
+ parseRelationshipResponse,
50
+ } from './prompts.js'
51
+
52
+ // Specialized Services (for memory service TODOs)
53
+ export { MemoryClassifierService, getMemoryClassifier, resetMemoryClassifier } from './memory-classifier.service.js'
54
+ export type { ClassificationResult, ClassifierConfig } from './memory-classifier.service.js'
55
+
56
+ export {
57
+ ContradictionDetectorService,
58
+ getContradictionDetector,
59
+ resetContradictionDetector,
60
+ } from './contradiction-detector.service.js'
61
+ export type { ContradictionResult, DetectorConfig } from './contradiction-detector.service.js'
62
+
63
+ export {
64
+ MemoryExtensionDetectorService,
65
+ getMemoryExtensionDetector,
66
+ resetMemoryExtensionDetector,
67
+ } from './memory-extension-detector.service.js'
68
+ export type { ExtensionResult, ExtensionDetectorConfig } from './memory-extension-detector.service.js'
69
+
70
+ // ============================================================================
71
+ // Environment Variable Names
72
+ // ============================================================================
73
+
74
+ const ENV_VARS = {
75
+ LLM_PROVIDER: 'LLM_PROVIDER',
76
+ OPENAI_API_KEY: 'OPENAI_API_KEY',
77
+ OPENAI_MODEL: 'OPENAI_MODEL',
78
+ OPENAI_BASE_URL: 'OPENAI_BASE_URL',
79
+ ANTHROPIC_API_KEY: 'ANTHROPIC_API_KEY',
80
+ ANTHROPIC_MODEL: 'ANTHROPIC_MODEL',
81
+ LLM_CACHE_ENABLED: 'LLM_CACHE_ENABLED',
82
+ LLM_CACHE_TTL_MS: 'LLM_CACHE_TTL_MS',
83
+ } as const
84
+
85
+ const PLACEHOLDER_API_KEYS = new Set(['sk-your-openai-api-key-here', 'anthropic-your-api-key-here'])
86
+
87
+ function getConfiguredEnvValue(name: string): string | undefined {
88
+ const value = process.env[name]?.trim()
89
+ if (!value || PLACEHOLDER_API_KEYS.has(value)) {
90
+ return undefined
91
+ }
92
+ return value
93
+ }
94
+
95
+ function getConfiguredApiKey(...values: Array<string | undefined>): string {
96
+ for (const value of values) {
97
+ const trimmed = value?.trim()
98
+ if (trimmed && !PLACEHOLDER_API_KEYS.has(trimmed)) {
99
+ return trimmed
100
+ }
101
+ }
102
+ return ''
103
+ }
104
+
105
+ // ============================================================================
106
+ // Factory Configuration
107
+ // ============================================================================
108
+
109
+ export interface LLMFactoryConfig {
110
+ /** Preferred provider type */
111
+ provider?: LLMProviderType
112
+
113
+ /** OpenAI-specific config */
114
+ openai?: Partial<OpenAILLMConfig>
115
+
116
+ /** Anthropic-specific config */
117
+ anthropic?: Partial<AnthropicLLMConfig>
118
+
119
+ /** Mock provider config */
120
+ mock?: MockLLMConfig
121
+
122
+ /** Cache configuration */
123
+ cache?: Partial<CacheConfig>
124
+
125
+ /** Whether to fallback to regex if no LLM available */
126
+ fallbackToRegex?: boolean
127
+ }
128
+
129
+ // ============================================================================
130
+ // Factory Functions
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Create an LLM provider based on configuration
135
+ */
136
+ export function createLLMProvider(config: LLMFactoryConfig = {}): LLMProvider {
137
+ const providerType = config.provider ?? getDefaultProviderType()
138
+
139
+ logger.debug('Creating LLM provider', { type: providerType })
140
+
141
+ switch (providerType) {
142
+ case 'openai': {
143
+ const openaiConfig = getOpenAIConfig(config)
144
+ return createOpenAIProvider(openaiConfig)
145
+ }
146
+
147
+ case 'anthropic': {
148
+ const anthropicConfig = getAnthropicConfig(config)
149
+ return createAnthropicProvider(anthropicConfig)
150
+ }
151
+
152
+ case 'mock': {
153
+ return createMockProvider(config.mock ?? {})
154
+ }
155
+
156
+ default: {
157
+ logger.warn(`Unknown provider type: ${providerType}, falling back to mock`)
158
+ return createMockProvider({})
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Get the default provider type based on available API keys
165
+ *
166
+ * NOTE: Only checks process.env for runtime detection.
167
+ */
168
+ export function getDefaultProviderType(): LLMProviderType {
169
+ // Check environment variable first
170
+ const envProvider = process.env[ENV_VARS.LLM_PROVIDER]?.toLowerCase()
171
+ if (envProvider === 'mock') {
172
+ return envProvider
173
+ }
174
+
175
+ if (envProvider === 'openai' && getConfiguredEnvValue(ENV_VARS.OPENAI_API_KEY)) {
176
+ return envProvider
177
+ }
178
+
179
+ if (envProvider === 'anthropic' && getConfiguredEnvValue(ENV_VARS.ANTHROPIC_API_KEY)) {
180
+ return envProvider
181
+ }
182
+
183
+ // Check for API keys in process.env only
184
+ const hasOpenAI = !!getConfiguredEnvValue(ENV_VARS.OPENAI_API_KEY)
185
+ const hasAnthropic = !!getConfiguredEnvValue(ENV_VARS.ANTHROPIC_API_KEY)
186
+
187
+ if (hasOpenAI) {
188
+ return 'openai'
189
+ }
190
+
191
+ if (hasAnthropic) {
192
+ return 'anthropic'
193
+ }
194
+
195
+ // No API keys - return mock for graceful degradation
196
+ logger.info('No LLM API keys found, using mock provider')
197
+ return 'mock'
198
+ }
199
+
200
+ /**
201
+ * Get OpenAI configuration from environment and provided config
202
+ */
203
+ function getOpenAIConfig(factoryConfig: LLMFactoryConfig): OpenAILLMConfig {
204
+ const apiKey = getConfiguredApiKey(factoryConfig.openai?.apiKey, process.env[ENV_VARS.OPENAI_API_KEY], appConfig.openaiApiKey)
205
+
206
+ return {
207
+ apiKey,
208
+ model: factoryConfig.openai?.model ?? process.env[ENV_VARS.OPENAI_MODEL] ?? 'gpt-4o-mini',
209
+ baseUrl: factoryConfig.openai?.baseUrl ?? process.env[ENV_VARS.OPENAI_BASE_URL],
210
+ maxTokens: factoryConfig.openai?.maxTokens ?? 2000,
211
+ temperature: factoryConfig.openai?.temperature ?? 0.1,
212
+ timeoutMs: factoryConfig.openai?.timeoutMs ?? 30000,
213
+ maxRetries: factoryConfig.openai?.maxRetries ?? 3,
214
+ retryDelayMs: factoryConfig.openai?.retryDelayMs ?? 1000,
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get Anthropic configuration from environment and provided config
220
+ */
221
+ function getAnthropicConfig(factoryConfig: LLMFactoryConfig): AnthropicLLMConfig {
222
+ const apiKey = getConfiguredApiKey(factoryConfig.anthropic?.apiKey, process.env[ENV_VARS.ANTHROPIC_API_KEY])
223
+
224
+ return {
225
+ apiKey,
226
+ model: factoryConfig.anthropic?.model ?? process.env[ENV_VARS.ANTHROPIC_MODEL] ?? 'claude-3-haiku-20240307',
227
+ maxTokens: factoryConfig.anthropic?.maxTokens ?? 2000,
228
+ temperature: factoryConfig.anthropic?.temperature ?? 0.1,
229
+ timeoutMs: factoryConfig.anthropic?.timeoutMs ?? 30000,
230
+ maxRetries: factoryConfig.anthropic?.maxRetries ?? 3,
231
+ retryDelayMs: factoryConfig.anthropic?.retryDelayMs ?? 1000,
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Check if any LLM provider is available
237
+ *
238
+ * NOTE: Only checks process.env for runtime availability detection.
239
+ * This allows tests to dynamically disable LLM by clearing env vars.
240
+ */
241
+ export function isLLMAvailable(): boolean {
242
+ if (!isLLMFeatureEnabled()) {
243
+ return false
244
+ }
245
+ const hasOpenAI = !!getConfiguredEnvValue(ENV_VARS.OPENAI_API_KEY)
246
+ const hasAnthropic = !!getConfiguredEnvValue(ENV_VARS.ANTHROPIC_API_KEY)
247
+ return hasOpenAI || hasAnthropic
248
+ }
249
+
250
+ /**
251
+ * Get list of available provider types
252
+ *
253
+ * NOTE: Only checks process.env for runtime availability detection.
254
+ */
255
+ export function getAvailableProviders(): LLMProviderType[] {
256
+ if (!isLLMFeatureEnabled()) {
257
+ return ['mock']
258
+ }
259
+ const providers: LLMProviderType[] = ['mock']
260
+
261
+ if (getConfiguredEnvValue(ENV_VARS.OPENAI_API_KEY)) {
262
+ providers.push('openai')
263
+ }
264
+
265
+ if (getConfiguredEnvValue(ENV_VARS.ANTHROPIC_API_KEY)) {
266
+ providers.push('anthropic')
267
+ }
268
+
269
+ return providers
270
+ }
271
+
272
+ // ============================================================================
273
+ // Singleton Instance
274
+ // ============================================================================
275
+
276
+ let _llmProviderInstance: LLMProvider | null = null
277
+
278
+ /**
279
+ * Get the singleton LLM provider instance
280
+ */
281
+ export function getLLMProvider(config?: LLMFactoryConfig): LLMProvider {
282
+ if (!_llmProviderInstance) {
283
+ _llmProviderInstance = createLLMProvider(config)
284
+ }
285
+ return _llmProviderInstance
286
+ }
287
+
288
+ /**
289
+ * Reset the singleton instance (useful for testing)
290
+ */
291
+ export function resetLLMProvider(): void {
292
+ _llmProviderInstance = null
293
+ }
294
+
295
+ /**
296
+ * Set a custom LLM provider instance (useful for testing)
297
+ */
298
+ export function setLLMProvider(provider: LLMProvider): void {
299
+ _llmProviderInstance = provider
300
+ }
301
+
302
+ /**
303
+ * Proxy-based lazy singleton for backwards compatibility
304
+ */
305
+ export const llmProvider = new Proxy({} as LLMProvider, {
306
+ get(_, prop) {
307
+ return getLLMProvider()[prop as keyof LLMProvider]
308
+ },
309
+ })
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Memory Type Classifier Service
3
+ *
4
+ * LLM-based semantic memory type classification with caching and fallback.
5
+ * Replaces pattern matching for TODO-001 in memory.service.ts
6
+ *
7
+ * Cost optimization:
8
+ * - Prompt caching to reduce API calls
9
+ * - In-memory cache with TTL
10
+ * - Batch classification when possible
11
+ * - Fallback to pattern matching on API errors
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 { MemoryType } from '../../types/index.js'
19
+ import { getLLMProvider, isLLMAvailable } from './index.js'
20
+ import { LLMError } from './base.js'
21
+ import { classifyMemoryTypeHeuristically, calculateHeuristicConfidence } from './heuristics.js'
22
+
23
+ const logger = getLogger('MemoryClassifier')
24
+
25
+ // ============================================================================
26
+ // Prompt Templates
27
+ // ============================================================================
28
+
29
+ export const MEMORY_CLASSIFIER_SYSTEM_PROMPT = `You are a memory classification expert. Classify the given content into ONE of these types:
30
+
31
+ - fact: Objective information, statements of truth, definitions
32
+ - event: Time-bound occurrences, meetings, experiences
33
+ - preference: Personal likes, dislikes, preferences, opinions
34
+ - skill: Abilities, capabilities, expertise, knowledge areas
35
+ - relationship: Interpersonal connections, social bonds
36
+ - context: Current situations, states, or ongoing activities
37
+ - note: General notes, reminders, todos
38
+
39
+ Respond with ONLY a JSON object:
40
+ {
41
+ "type": "one of the types above",
42
+ "confidence": 0.0-1.0,
43
+ "reasoning": "brief explanation"
44
+ }`
45
+
46
+ export function buildMemoryClassifierUserPrompt(content: string): string {
47
+ return `Classify this content:\n\n"${content}"\n\nRespond with JSON only.`
48
+ }
49
+
50
+ // ============================================================================
51
+ // Types
52
+ // ============================================================================
53
+
54
+ export interface ClassificationResult {
55
+ type: MemoryType
56
+ confidence: number
57
+ reasoning?: string
58
+ cached: boolean
59
+ usedLLM: boolean
60
+ }
61
+
62
+ export interface ClassifierConfig {
63
+ /** Minimum confidence for LLM classification (0-1) */
64
+ minConfidence?: number
65
+ /** Whether to enable caching */
66
+ enableCache?: boolean
67
+ /** Cache TTL in milliseconds */
68
+ cacheTTLMs?: number
69
+ /** Maximum cache size */
70
+ maxCacheSize?: number
71
+ /** Whether to fallback to pattern matching on errors */
72
+ fallbackToPatterns?: boolean
73
+ }
74
+
75
+ interface CacheEntry {
76
+ type: MemoryType
77
+ confidence: number
78
+ reasoning?: string
79
+ timestamp: number
80
+ }
81
+
82
+ // ============================================================================
83
+ // Memory Type Classifier
84
+ // ============================================================================
85
+
86
+ export class MemoryClassifierService {
87
+ private config: Required<ClassifierConfig>
88
+ private cache: Map<string, CacheEntry> = new Map()
89
+ private stats = {
90
+ totalClassifications: 0,
91
+ llmClassifications: 0,
92
+ patternClassifications: 0,
93
+ cacheHits: 0,
94
+ errors: 0,
95
+ totalCost: 0,
96
+ }
97
+
98
+ constructor(config: ClassifierConfig = {}) {
99
+ this.config = {
100
+ minConfidence: config.minConfidence ?? 0.6,
101
+ enableCache: config.enableCache ?? true,
102
+ cacheTTLMs: config.cacheTTLMs ?? 15 * 60 * 1000, // 15 minutes
103
+ maxCacheSize: config.maxCacheSize ?? 1000,
104
+ fallbackToPatterns: config.fallbackToPatterns ?? true,
105
+ }
106
+
107
+ logger.info('Memory classifier initialized', {
108
+ cacheEnabled: this.config.enableCache,
109
+ fallbackEnabled: this.config.fallbackToPatterns,
110
+ })
111
+ }
112
+
113
+ // ============================================================================
114
+ // Public API
115
+ // ============================================================================
116
+
117
+ /**
118
+ * Classify memory content into a type using LLM or pattern matching
119
+ *
120
+ * @param content - The content to classify
121
+ * @returns Classification result with type, confidence, and metadata
122
+ */
123
+ async classify(content: string): Promise<ClassificationResult> {
124
+ this.stats.totalClassifications++
125
+
126
+ // Check cache first
127
+ if (this.config.enableCache) {
128
+ const cached = this.getCached(content)
129
+ if (cached) {
130
+ this.stats.cacheHits++
131
+ logger.debug('Cache hit for classification', { contentPreview: content.substring(0, 50) })
132
+ return {
133
+ type: cached.type,
134
+ confidence: cached.confidence,
135
+ reasoning: cached.reasoning,
136
+ cached: true,
137
+ usedLLM: false,
138
+ }
139
+ }
140
+ }
141
+
142
+ // Try LLM classification if available
143
+ if (isLLMAvailable()) {
144
+ try {
145
+ const result = await this.classifyWithLLM(content)
146
+ this.stats.llmClassifications++
147
+
148
+ // Cache the result
149
+ if (this.config.enableCache && result.confidence >= this.config.minConfidence) {
150
+ this.setCached(content, {
151
+ type: result.type,
152
+ confidence: result.confidence,
153
+ reasoning: result.reasoning,
154
+ timestamp: Date.now(),
155
+ })
156
+ }
157
+
158
+ return {
159
+ ...result,
160
+ cached: false,
161
+ usedLLM: true,
162
+ }
163
+ } catch (error) {
164
+ this.stats.errors++
165
+ logger.warn('LLM classification failed, falling back to patterns', {
166
+ error: error instanceof Error ? error.message : String(error),
167
+ })
168
+
169
+ if (!this.config.fallbackToPatterns) {
170
+ throw error
171
+ }
172
+ }
173
+ }
174
+
175
+ // Fallback to pattern matching
176
+ const patternResult = this.classifyWithPatterns(content)
177
+ this.stats.patternClassifications++
178
+
179
+ return {
180
+ type: patternResult.type,
181
+ confidence: patternResult.confidence,
182
+ cached: false,
183
+ usedLLM: false,
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Get classification statistics
189
+ */
190
+ getStats() {
191
+ const cacheHitRate =
192
+ this.stats.totalClassifications > 0 ? (this.stats.cacheHits / this.stats.totalClassifications) * 100 : 0
193
+
194
+ return {
195
+ ...this.stats,
196
+ cacheHitRate: parseFloat(cacheHitRate.toFixed(2)),
197
+ cacheSize: this.cache.size,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Clear the cache
203
+ */
204
+ clearCache(): void {
205
+ this.cache.clear()
206
+ logger.info('Classification cache cleared')
207
+ }
208
+
209
+ // ============================================================================
210
+ // LLM Classification
211
+ // ============================================================================
212
+
213
+ private async classifyWithLLM(content: string): Promise<{
214
+ type: MemoryType
215
+ confidence: number
216
+ reasoning?: string
217
+ }> {
218
+ const provider = getLLMProvider()
219
+
220
+ try {
221
+ const response = await provider.generateJson(
222
+ MEMORY_CLASSIFIER_SYSTEM_PROMPT,
223
+ buildMemoryClassifierUserPrompt(content)
224
+ )
225
+
226
+ const parsed = this.parseJsonResponse(response.rawResponse, response.provider)
227
+
228
+ // Estimate cost (Haiku: ~$0.25 per million input tokens, ~$1.25 per million output)
229
+ const inputCost = ((response.tokensUsed?.prompt ?? 0) / 1000000) * 0.25
230
+ const outputCost = ((response.tokensUsed?.completion ?? 0) / 1000000) * 1.25
231
+ this.stats.totalCost += inputCost + outputCost
232
+
233
+ logger.debug('LLM classification successful', {
234
+ type: parsed.type,
235
+ confidence: parsed.confidence,
236
+ tokensUsed: response.tokensUsed?.total ?? 0,
237
+ cost: inputCost + outputCost,
238
+ })
239
+
240
+ return parsed
241
+ } catch (error) {
242
+ if (error instanceof LLMError) {
243
+ throw error
244
+ }
245
+ throw new Error(`LLM classification failed: ${error instanceof Error ? error.message : String(error)}`)
246
+ }
247
+ }
248
+
249
+ // ============================================================================
250
+ // Pattern Matching Fallback
251
+ // ============================================================================
252
+
253
+ private classifyWithPatterns(content: string): {
254
+ type: MemoryType
255
+ confidence: number
256
+ } {
257
+ const heuristic = classifyMemoryTypeHeuristically(content)
258
+ const type = heuristic.type
259
+ const confidence = calculateHeuristicConfidence(heuristic.matchCount, {
260
+ base: 0.5,
261
+ perMatch: 0.1,
262
+ max: 0.9,
263
+ defaultConfidence: 0.3,
264
+ })
265
+
266
+ logger.debug('Pattern classification', {
267
+ type,
268
+ confidence,
269
+ matchCount: heuristic.matchCount,
270
+ })
271
+
272
+ return { type, confidence }
273
+ }
274
+
275
+ private parseJsonResponse(
276
+ rawResponse: string,
277
+ provider: 'openai' | 'anthropic' | 'mock'
278
+ ): {
279
+ type: MemoryType
280
+ confidence: number
281
+ reasoning?: string
282
+ } {
283
+ const trimmed = rawResponse.trim()
284
+ const jsonMatch = trimmed.startsWith('{') ? trimmed : trimmed.match(/\{[\s\S]*\}/)?.[0]
285
+ if (!jsonMatch) {
286
+ throw LLMError.invalidResponse(provider, 'No JSON object found in response')
287
+ }
288
+
289
+ let parsed: unknown
290
+ try {
291
+ parsed = JSON.parse(jsonMatch)
292
+ } catch {
293
+ throw LLMError.invalidResponse(provider, 'Invalid JSON response')
294
+ }
295
+
296
+ if (!parsed || typeof parsed !== 'object' || !('type' in parsed) || !('confidence' in parsed)) {
297
+ throw LLMError.invalidResponse(provider, 'Missing required fields in JSON response')
298
+ }
299
+
300
+ const type = (parsed as { type: MemoryType }).type
301
+ const confidence = (parsed as { confidence: number }).confidence
302
+ const reasoning = (parsed as { reasoning?: string }).reasoning
303
+
304
+ const validTypes: MemoryType[] = ['fact', 'event', 'preference', 'skill', 'relationship', 'context', 'note']
305
+ if (!validTypes.includes(type)) {
306
+ throw LLMError.invalidResponse(provider, 'Invalid memory type in response')
307
+ }
308
+ if (typeof confidence !== 'number' || Number.isNaN(confidence)) {
309
+ throw LLMError.invalidResponse(provider, 'Invalid confidence in response')
310
+ }
311
+
312
+ return { type, confidence, reasoning }
313
+ }
314
+
315
+ // ============================================================================
316
+ // Caching
317
+ // ============================================================================
318
+
319
+ private getCacheKey(content: string): string {
320
+ // Use first 500 chars for cache key to avoid huge keys
321
+ const normalized = content.substring(0, 500).trim().toLowerCase()
322
+ return createHash('sha256').update(normalized).digest('hex')
323
+ }
324
+
325
+ private getCached(content: string): CacheEntry | null {
326
+ const key = this.getCacheKey(content)
327
+ const entry = this.cache.get(key)
328
+
329
+ if (!entry) {
330
+ return null
331
+ }
332
+
333
+ // Check if expired
334
+ const age = Date.now() - entry.timestamp
335
+ if (age > this.config.cacheTTLMs) {
336
+ this.cache.delete(key)
337
+ return null
338
+ }
339
+
340
+ return entry
341
+ }
342
+
343
+ private setCached(content: string, entry: CacheEntry): void {
344
+ // Enforce cache size limit
345
+ if (this.cache.size >= this.config.maxCacheSize) {
346
+ // Remove oldest 10% of entries
347
+ const entries = Array.from(this.cache.entries())
348
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
349
+ const excess = this.cache.size - this.config.maxCacheSize + 1
350
+ const minimumToRemove = Math.max(1, Math.ceil(this.config.maxCacheSize * 0.1))
351
+ const toRemove = entries.slice(0, Math.max(excess, minimumToRemove))
352
+ for (const [key] of toRemove) {
353
+ this.cache.delete(key)
354
+ }
355
+ }
356
+
357
+ const key = this.getCacheKey(content)
358
+ this.cache.set(key, entry)
359
+ }
360
+ }
361
+
362
+ // ============================================================================
363
+ // Singleton Instance
364
+ // ============================================================================
365
+
366
+ let _instance: MemoryClassifierService | null = null
367
+
368
+ /**
369
+ * Get the singleton instance
370
+ */
371
+ export function getMemoryClassifier(config?: ClassifierConfig): MemoryClassifierService {
372
+ if (!_instance) {
373
+ _instance = new MemoryClassifierService(config)
374
+ }
375
+ return _instance
376
+ }
377
+
378
+ /**
379
+ * Reset the singleton (for testing)
380
+ */
381
+ export function resetMemoryClassifier(): void {
382
+ _instance = null
383
+ }