@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,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
|
+
}
|