synapse-gateway 2.0.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/README.md +385 -0
- package/bin/synapse.js +242 -0
- package/docs/PLAN.md +1723 -0
- package/docs/PRD.md +1799 -0
- package/drizzle.config.ts +12 -0
- package/next.config.ts +8 -0
- package/package.json +82 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/analytics/cost/route.ts +13 -0
- package/src/app/api/analytics/usage/route.ts +16 -0
- package/src/app/api/auth/login/route.ts +42 -0
- package/src/app/api/cache/route.ts +19 -0
- package/src/app/api/dashboard/route.ts +35 -0
- package/src/app/api/distill/route.ts +10 -0
- package/src/app/api/events/route.ts +54 -0
- package/src/app/api/health/route.ts +10 -0
- package/src/app/api/intelligence/forensics/route.ts +23 -0
- package/src/app/api/intelligence/neural-router/route.ts +23 -0
- package/src/app/api/keys/route.ts +34 -0
- package/src/app/api/mcp/route.ts +49 -0
- package/src/app/api/memory/route.ts +10 -0
- package/src/app/api/models/benchmark/route.ts +13 -0
- package/src/app/api/models/route.ts +39 -0
- package/src/app/api/namespace/route.ts +25 -0
- package/src/app/api/plugins/route.ts +41 -0
- package/src/app/api/providers/accounts/route.ts +91 -0
- package/src/app/api/providers/fetch-models/route.ts +52 -0
- package/src/app/api/providers/health/route.ts +10 -0
- package/src/app/api/providers/route.ts +46 -0
- package/src/app/api/routes/pipeline/route.ts +20 -0
- package/src/app/api/settings/route.ts +33 -0
- package/src/app/api/skills/route.ts +39 -0
- package/src/app/api/v1/chat/completions/route.ts +156 -0
- package/src/app/api/v1/models/route.ts +44 -0
- package/src/app/dashboard/intelligence/loading.tsx +14 -0
- package/src/app/dashboard/intelligence/page.tsx +125 -0
- package/src/app/dashboard/layout.tsx +143 -0
- package/src/app/dashboard/loading.tsx +17 -0
- package/src/app/dashboard/memory/loading.tsx +15 -0
- package/src/app/dashboard/memory/page.tsx +71 -0
- package/src/app/dashboard/models/loading.tsx +13 -0
- package/src/app/dashboard/models/page.tsx +107 -0
- package/src/app/dashboard/page.tsx +183 -0
- package/src/app/dashboard/playground/loading.tsx +17 -0
- package/src/app/dashboard/playground/page.tsx +212 -0
- package/src/app/dashboard/providers/loading.tsx +15 -0
- package/src/app/dashboard/providers/page.tsx +248 -0
- package/src/app/dashboard/routes/loading.tsx +15 -0
- package/src/app/dashboard/routes/page.tsx +72 -0
- package/src/app/dashboard/settings/loading.tsx +20 -0
- package/src/app/dashboard/settings/page.tsx +208 -0
- package/src/app/dashboard/skills/loading.tsx +26 -0
- package/src/app/dashboard/skills/page.tsx +137 -0
- package/src/app/dashboard/vault/loading.tsx +18 -0
- package/src/app/dashboard/vault/page.tsx +139 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +59 -0
- package/src/app/layout.tsx +32 -0
- package/src/app/login/page.tsx +87 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ui/badge.tsx +32 -0
- package/src/components/ui/button.tsx +38 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/error-boundary.tsx +47 -0
- package/src/components/ui/index.ts +11 -0
- package/src/components/ui/input.tsx +26 -0
- package/src/components/ui/select.tsx +24 -0
- package/src/components/ui/skeleton.tsx +53 -0
- package/src/components/ui/toast.tsx +51 -0
- package/src/instrumentation.ts +6 -0
- package/src/lib/__tests__/auth.test.ts +42 -0
- package/src/lib/__tests__/format.test.ts +94 -0
- package/src/lib/__tests__/namespace.test.ts +102 -0
- package/src/lib/__tests__/squeezer.test.ts +93 -0
- package/src/lib/__tests__/utils.test.ts +28 -0
- package/src/lib/analytics/index.ts +187 -0
- package/src/lib/auth/guard.tsx +71 -0
- package/src/lib/auth/index.ts +105 -0
- package/src/lib/auth/middleware.ts +64 -0
- package/src/lib/benchmark/index.ts +137 -0
- package/src/lib/bootstrap.ts +122 -0
- package/src/lib/cache/index.ts +1 -0
- package/src/lib/cache/semantic.ts +211 -0
- package/src/lib/config/defaults.ts +61 -0
- package/src/lib/config/index.ts +72 -0
- package/src/lib/config/schema.ts +63 -0
- package/src/lib/db/index.ts +22 -0
- package/src/lib/db/migrate.ts +327 -0
- package/src/lib/db/schema.ts +303 -0
- package/src/lib/distiller/index.ts +331 -0
- package/src/lib/fallback/index.ts +153 -0
- package/src/lib/forensics/index.ts +188 -0
- package/src/lib/format/anthropic.ts +139 -0
- package/src/lib/format/gemini.ts +130 -0
- package/src/lib/format/index.ts +3 -0
- package/src/lib/format/openai.ts +78 -0
- package/src/lib/health/index.ts +158 -0
- package/src/lib/mcp/builtin.ts +83 -0
- package/src/lib/mcp/index.ts +1 -0
- package/src/lib/mcp/registry.ts +49 -0
- package/src/lib/memory/index.ts +3 -0
- package/src/lib/memory/store.ts +215 -0
- package/src/lib/memory/types.ts +56 -0
- package/src/lib/namespace/index.ts +89 -0
- package/src/lib/neural/features.ts +74 -0
- package/src/lib/neural/index.ts +85 -0
- package/src/lib/neural/strategies.ts +124 -0
- package/src/lib/pipeline/index.ts +84 -0
- package/src/lib/pipeline/types.ts +77 -0
- package/src/lib/plugins/builtin.ts +79 -0
- package/src/lib/plugins/index.ts +65 -0
- package/src/lib/prediction/index.ts +113 -0
- package/src/lib/providers/api-key/anthropic.ts +96 -0
- package/src/lib/providers/api-key/deepseek.ts +108 -0
- package/src/lib/providers/api-key/gemini.ts +112 -0
- package/src/lib/providers/api-key/openai.ts +122 -0
- package/src/lib/providers/api-key/openrouter.ts +112 -0
- package/src/lib/providers/base-adapter.ts +122 -0
- package/src/lib/providers/registry.ts +46 -0
- package/src/lib/providers/types.ts +121 -0
- package/src/lib/router/index.ts +82 -0
- package/src/lib/skills/forge.ts +57 -0
- package/src/lib/skills/index.ts +3 -0
- package/src/lib/skills/registry.ts +195 -0
- package/src/lib/skills/types.ts +44 -0
- package/src/lib/squeezer/index.ts +158 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/logger.ts +16 -0
- package/src/middleware.ts +60 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { db } from '../db'
|
|
2
|
+
import { modelBenchmarks } from '../db/schema'
|
|
3
|
+
import { eq, desc, sql } from 'drizzle-orm'
|
|
4
|
+
import { logger } from '../utils/logger'
|
|
5
|
+
import { v4 as uuid } from 'uuid'
|
|
6
|
+
import type { NormalizedRequest, NormalizedResponse } from '../providers/types'
|
|
7
|
+
|
|
8
|
+
export interface BenchmarkResult {
|
|
9
|
+
id: string
|
|
10
|
+
model: string
|
|
11
|
+
taskType: string
|
|
12
|
+
latencyMs: number | null
|
|
13
|
+
tokensUsed: number | null
|
|
14
|
+
qualityScore: number | null
|
|
15
|
+
cost: number | null
|
|
16
|
+
providerId: string | null
|
|
17
|
+
isShadow: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ModelComparison {
|
|
21
|
+
model: string
|
|
22
|
+
avgLatencyMs: number
|
|
23
|
+
avgQualityScore: number
|
|
24
|
+
avgCost: number
|
|
25
|
+
totalBenchmarks: number
|
|
26
|
+
valueScore: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class BenchmarkEngine {
|
|
30
|
+
async runBenchmark(params: {
|
|
31
|
+
model: string
|
|
32
|
+
taskType: string
|
|
33
|
+
providerId?: string
|
|
34
|
+
isShadow?: boolean
|
|
35
|
+
}, request: NormalizedRequest, response: NormalizedResponse): Promise<BenchmarkResult> {
|
|
36
|
+
const latencyMs = response.latencyMs
|
|
37
|
+
const tokensUsed = response.usage.totalTokens
|
|
38
|
+
const cost = 0
|
|
39
|
+
const qualityScore = this.calculateQualityScore(request, response)
|
|
40
|
+
|
|
41
|
+
const result: BenchmarkResult = {
|
|
42
|
+
id: uuid(),
|
|
43
|
+
model: params.model,
|
|
44
|
+
taskType: params.taskType,
|
|
45
|
+
latencyMs,
|
|
46
|
+
tokensUsed,
|
|
47
|
+
qualityScore,
|
|
48
|
+
cost,
|
|
49
|
+
providerId: params.providerId || null,
|
|
50
|
+
isShadow: params.isShadow ?? false,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await db.insert(modelBenchmarks).values({
|
|
55
|
+
id: result.id,
|
|
56
|
+
model: result.model,
|
|
57
|
+
taskType: result.taskType,
|
|
58
|
+
latencyMs: result.latencyMs ?? null,
|
|
59
|
+
tokensUsed: result.tokensUsed ?? null,
|
|
60
|
+
qualityScore: result.qualityScore ?? null,
|
|
61
|
+
cost: result.cost ?? null,
|
|
62
|
+
providerId: result.providerId,
|
|
63
|
+
isShadow: result.isShadow,
|
|
64
|
+
})
|
|
65
|
+
} catch (err) {
|
|
66
|
+
logger.warn({ error: (err as Error).message }, 'Failed to save benchmark')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private calculateQualityScore(request: NormalizedRequest, response: NormalizedResponse): number {
|
|
73
|
+
let score = 0.5
|
|
74
|
+
const content = response.choices[0]?.message?.content || ''
|
|
75
|
+
|
|
76
|
+
if (content.length > 50) score += 0.1
|
|
77
|
+
if (content.length > 200) score += 0.1
|
|
78
|
+
if (!content.includes('I cannot') && !content.includes('I\'m unable')) score += 0.1
|
|
79
|
+
if (content.includes('```')) score += 0.05
|
|
80
|
+
if (response.usage.totalTokens < request.messages.reduce((s, m) => s + m.content.length, 0) / 2) score += 0.05
|
|
81
|
+
|
|
82
|
+
if (response.latencyMs < 1000) score += 0.05
|
|
83
|
+
if (response.latencyMs < 500) score += 0.05
|
|
84
|
+
|
|
85
|
+
return Math.min(score, 1.0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async compareModels(taskType?: string): Promise<ModelComparison[]> {
|
|
89
|
+
try {
|
|
90
|
+
const filter = taskType
|
|
91
|
+
? sql`${modelBenchmarks.taskType} = ${taskType}`
|
|
92
|
+
: sql`1=1`
|
|
93
|
+
|
|
94
|
+
const rows = await db.select({
|
|
95
|
+
model: modelBenchmarks.model,
|
|
96
|
+
avgLatency: sql<number>`coalesce(avg(${modelBenchmarks.latencyMs}), 0)`,
|
|
97
|
+
avgQuality: sql<number>`coalesce(avg(${modelBenchmarks.qualityScore}), 0)`,
|
|
98
|
+
avgCost: sql<number>`coalesce(avg(${modelBenchmarks.cost}), 0)`,
|
|
99
|
+
count: sql<number>`count(*)`,
|
|
100
|
+
}).from(modelBenchmarks).where(filter).groupBy(modelBenchmarks.model)
|
|
101
|
+
|
|
102
|
+
return rows.map((r) => ({
|
|
103
|
+
model: r.model,
|
|
104
|
+
avgLatencyMs: Math.round(Number(r.avgLatency)),
|
|
105
|
+
avgQualityScore: Number(Number(r.avgQuality).toFixed(2)),
|
|
106
|
+
avgCost: Number(Number(r.avgCost).toFixed(4)),
|
|
107
|
+
totalBenchmarks: r.count,
|
|
108
|
+
valueScore: r.count > 0 ? Number(r.avgQuality) / (Number(r.avgCost) + 0.001) : 0,
|
|
109
|
+
})).sort((a, b) => b.avgQualityScore - a.avgQualityScore)
|
|
110
|
+
} catch {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async getRecentBenchmarks(limit = 50): Promise<BenchmarkResult[]> {
|
|
116
|
+
try {
|
|
117
|
+
const rows = await db.select().from(modelBenchmarks)
|
|
118
|
+
.orderBy(desc(modelBenchmarks.createdAt))
|
|
119
|
+
.limit(limit)
|
|
120
|
+
return rows.map((r) => ({
|
|
121
|
+
id: r.id,
|
|
122
|
+
model: r.model,
|
|
123
|
+
taskType: r.taskType,
|
|
124
|
+
latencyMs: r.latencyMs,
|
|
125
|
+
tokensUsed: r.tokensUsed,
|
|
126
|
+
qualityScore: r.qualityScore ? Number(r.qualityScore) : null,
|
|
127
|
+
cost: r.cost ? Number(r.cost) : null,
|
|
128
|
+
providerId: r.providerId,
|
|
129
|
+
isShadow: !!r.isShadow,
|
|
130
|
+
}))
|
|
131
|
+
} catch {
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const benchmarkEngine = new BenchmarkEngine()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { registry } from './providers/registry'
|
|
2
|
+
import { OpenAIAdapter } from './providers/api-key/openai'
|
|
3
|
+
import { AnthropicAdapter } from './providers/api-key/anthropic'
|
|
4
|
+
import { DeepSeekAdapter } from './providers/api-key/deepseek'
|
|
5
|
+
import { OpenRouterAdapter } from './providers/api-key/openrouter'
|
|
6
|
+
import { GeminiAdapter } from './providers/api-key/gemini'
|
|
7
|
+
import { migrate } from './db/migrate'
|
|
8
|
+
import { experienceDistiller } from './distiller'
|
|
9
|
+
import { healthChecker } from './health'
|
|
10
|
+
import { logger } from './utils/logger'
|
|
11
|
+
import { registerTool, registerResource } from './mcp/registry'
|
|
12
|
+
import { pluginManager } from './plugins/index'
|
|
13
|
+
import { analytics } from './analytics'
|
|
14
|
+
import { memoryStore } from './memory'
|
|
15
|
+
import { semanticCacheStore } from './cache'
|
|
16
|
+
import type { Plugin, PluginContext } from './plugins/index'
|
|
17
|
+
|
|
18
|
+
function registerMCPTools() {
|
|
19
|
+
registerTool({
|
|
20
|
+
name: 'get_usage_stats',
|
|
21
|
+
description: 'Get usage statistics',
|
|
22
|
+
inputSchema: { type: 'object', properties: { days: { type: 'number' } } },
|
|
23
|
+
handler: async (args) => analytics.getUsageStats((args.days as number) || 7),
|
|
24
|
+
})
|
|
25
|
+
registerTool({
|
|
26
|
+
name: 'get_health_status',
|
|
27
|
+
description: 'Get provider health',
|
|
28
|
+
inputSchema: { type: 'object', properties: {} },
|
|
29
|
+
handler: async () => healthChecker.getProviderHealth(),
|
|
30
|
+
})
|
|
31
|
+
registerTool({
|
|
32
|
+
name: 'get_cache_stats',
|
|
33
|
+
description: 'Get cache statistics',
|
|
34
|
+
inputSchema: { type: 'object', properties: {} },
|
|
35
|
+
handler: async () => semanticCacheStore.getStats(),
|
|
36
|
+
})
|
|
37
|
+
registerTool({
|
|
38
|
+
name: 'search_memory',
|
|
39
|
+
description: 'Search memory',
|
|
40
|
+
inputSchema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
|
|
41
|
+
handler: async (args) => memoryStore.search(args.query as string),
|
|
42
|
+
})
|
|
43
|
+
registerTool({
|
|
44
|
+
name: 'clear_cache',
|
|
45
|
+
description: 'Clear cache',
|
|
46
|
+
inputSchema: { type: 'object', properties: {} },
|
|
47
|
+
handler: async () => { await semanticCacheStore.clear(); return { success: true } },
|
|
48
|
+
})
|
|
49
|
+
registerTool({
|
|
50
|
+
name: 'trigger_distillation',
|
|
51
|
+
description: 'Run distillation',
|
|
52
|
+
inputSchema: { type: 'object', properties: {} },
|
|
53
|
+
handler: async () => experienceDistiller.run(),
|
|
54
|
+
})
|
|
55
|
+
registerResource({
|
|
56
|
+
uri: 'synapse://config',
|
|
57
|
+
name: 'Configuration',
|
|
58
|
+
description: 'Current config',
|
|
59
|
+
mimeType: 'application/json',
|
|
60
|
+
read: async () => JSON.stringify({ version: '2.0.0' }),
|
|
61
|
+
})
|
|
62
|
+
registerResource({
|
|
63
|
+
uri: 'synapse://providers',
|
|
64
|
+
name: 'Providers',
|
|
65
|
+
description: 'Provider list',
|
|
66
|
+
mimeType: 'application/json',
|
|
67
|
+
read: async () => JSON.stringify(registry.list().map((a) => ({ id: a.info.id, name: a.info.name }))),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function registerPlugins() {
|
|
72
|
+
pluginManager.register({
|
|
73
|
+
name: 'pii-redactor',
|
|
74
|
+
description: 'Redacts PII (SSN, email, phone)',
|
|
75
|
+
phase: 'pre_request',
|
|
76
|
+
execute: async (ctx: PluginContext) => {
|
|
77
|
+
let content = ctx.content
|
|
78
|
+
content = content.replace(/\b\d{3}[-.]?\d{2}[-.]?\d{4}\b/g, '[REDACTED]')
|
|
79
|
+
content = content.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[REDACTED]')
|
|
80
|
+
content = content.replace(/\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g, '[REDACTED]')
|
|
81
|
+
return { modified: content !== ctx.content, content }
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
pluginManager.register({
|
|
85
|
+
name: 'response-validator',
|
|
86
|
+
description: 'Validates response quality',
|
|
87
|
+
phase: 'post_response',
|
|
88
|
+
execute: async (ctx: PluginContext) => {
|
|
89
|
+
const valid = ctx.content.trim().length > 0
|
|
90
|
+
return { modified: false, content: ctx.content, metadata: { validation: { valid } } }
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function bootstrap() {
|
|
96
|
+
logger.info('Bootstrapping Synapse...')
|
|
97
|
+
|
|
98
|
+
await migrate()
|
|
99
|
+
|
|
100
|
+
const builtInProviders = [
|
|
101
|
+
new OpenAIAdapter(),
|
|
102
|
+
new AnthropicAdapter(),
|
|
103
|
+
new GeminiAdapter(),
|
|
104
|
+
new DeepSeekAdapter(),
|
|
105
|
+
new OpenRouterAdapter(),
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
for (const adapter of builtInProviders) {
|
|
109
|
+
registry.register(adapter)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info({ providers: builtInProviders.length }, 'Providers registered')
|
|
113
|
+
|
|
114
|
+
registerMCPTools()
|
|
115
|
+
registerPlugins()
|
|
116
|
+
logger.info('MCP tools and plugins registered')
|
|
117
|
+
|
|
118
|
+
healthChecker.start(5 * 60 * 1000)
|
|
119
|
+
experienceDistiller.start()
|
|
120
|
+
|
|
121
|
+
logger.info('Synapse bootstrap complete')
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { semanticCacheStore, computeQueryHash } from './semantic'
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { db } from '../db'
|
|
2
|
+
import { semanticCache as semanticCacheTable } from '../db/schema'
|
|
3
|
+
import { eq, desc, sql, and, gt } from 'drizzle-orm'
|
|
4
|
+
import { logger } from '../utils/logger'
|
|
5
|
+
import { v4 as uuid } from 'uuid'
|
|
6
|
+
|
|
7
|
+
interface CacheEntry {
|
|
8
|
+
id: string
|
|
9
|
+
promptHash: string
|
|
10
|
+
promptText: string
|
|
11
|
+
responseText: string
|
|
12
|
+
model: string
|
|
13
|
+
inputTokens: number | null
|
|
14
|
+
outputTokens: number | null
|
|
15
|
+
similarityThreshold: number
|
|
16
|
+
hits: number
|
|
17
|
+
qualityScore: number | null
|
|
18
|
+
expiresAt: string
|
|
19
|
+
createdAt: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class SemanticCache {
|
|
23
|
+
private memoryCache = new Map<string, { entry: CacheEntry; expiresAt: number }>()
|
|
24
|
+
private maxMemoryEntries = 500
|
|
25
|
+
|
|
26
|
+
async get(promptHash: string): Promise<CacheEntry | null> {
|
|
27
|
+
const memEntry = this.memoryCache.get(promptHash)
|
|
28
|
+
if (memEntry) {
|
|
29
|
+
if (Date.now() > memEntry.expiresAt) {
|
|
30
|
+
this.memoryCache.delete(promptHash)
|
|
31
|
+
} else {
|
|
32
|
+
memEntry.entry.hits++
|
|
33
|
+
return memEntry.entry
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const rows = await db.select().from(semanticCacheTable)
|
|
39
|
+
.where(and(eq(semanticCacheTable.promptHash, promptHash), gt(semanticCacheTable.expiresAt, new Date().toISOString())))
|
|
40
|
+
.limit(1)
|
|
41
|
+
|
|
42
|
+
if (rows.length === 0) return null
|
|
43
|
+
|
|
44
|
+
const row = rows[0]
|
|
45
|
+
await db.update(semanticCacheTable).set({
|
|
46
|
+
hits: sql`${semanticCacheTable.hits} + 1`,
|
|
47
|
+
}).where(eq(semanticCacheTable.id, row.id))
|
|
48
|
+
|
|
49
|
+
const entry: CacheEntry = {
|
|
50
|
+
id: row.id,
|
|
51
|
+
promptHash: row.promptHash,
|
|
52
|
+
promptText: row.promptText,
|
|
53
|
+
responseText: row.responseText,
|
|
54
|
+
model: row.model,
|
|
55
|
+
inputTokens: row.inputTokens ?? null,
|
|
56
|
+
outputTokens: row.outputTokens ?? null,
|
|
57
|
+
similarityThreshold: row.similarityThreshold ?? 0.95,
|
|
58
|
+
hits: row.hits ?? 0,
|
|
59
|
+
qualityScore: row.qualityScore ?? null,
|
|
60
|
+
expiresAt: row.expiresAt,
|
|
61
|
+
createdAt: row.createdAt,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this.setMemoryCache(promptHash, entry)
|
|
65
|
+
return entry
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async set(params: {
|
|
72
|
+
promptHash: string
|
|
73
|
+
promptText: string
|
|
74
|
+
responseText: string
|
|
75
|
+
model: string
|
|
76
|
+
inputTokens?: number
|
|
77
|
+
outputTokens?: number
|
|
78
|
+
similarityThreshold?: number
|
|
79
|
+
qualityScore?: number
|
|
80
|
+
ttlHours?: number
|
|
81
|
+
}): Promise<void> {
|
|
82
|
+
const id = uuid()
|
|
83
|
+
const ttlMs = (params.ttlHours || 24) * 60 * 60 * 1000
|
|
84
|
+
const expiresAt = new Date(Date.now() + ttlMs).toISOString()
|
|
85
|
+
|
|
86
|
+
const entry: CacheEntry = {
|
|
87
|
+
id,
|
|
88
|
+
promptHash: params.promptHash,
|
|
89
|
+
promptText: params.promptText,
|
|
90
|
+
responseText: params.responseText,
|
|
91
|
+
model: params.model,
|
|
92
|
+
inputTokens: params.inputTokens ?? null,
|
|
93
|
+
outputTokens: params.outputTokens ?? null,
|
|
94
|
+
similarityThreshold: params.similarityThreshold ?? 0.95,
|
|
95
|
+
hits: 0,
|
|
96
|
+
qualityScore: params.qualityScore ?? null,
|
|
97
|
+
expiresAt,
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.setMemoryCache(params.promptHash, entry)
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await db.insert(semanticCacheTable).values({
|
|
105
|
+
id,
|
|
106
|
+
embedding: Buffer.alloc(0),
|
|
107
|
+
promptHash: params.promptHash,
|
|
108
|
+
promptText: params.promptText,
|
|
109
|
+
responseText: params.responseText,
|
|
110
|
+
model: params.model,
|
|
111
|
+
inputTokens: params.inputTokens ?? null,
|
|
112
|
+
outputTokens: params.outputTokens ?? null,
|
|
113
|
+
similarityThreshold: params.similarityThreshold ?? 0.95,
|
|
114
|
+
hits: 0,
|
|
115
|
+
qualityScore: params.qualityScore ?? null,
|
|
116
|
+
expiresAt,
|
|
117
|
+
})
|
|
118
|
+
} catch (err) {
|
|
119
|
+
logger.warn({ error: (err as Error).message }, 'Failed to cache response')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async invalidate(promptHash: string): Promise<void> {
|
|
124
|
+
this.memoryCache.delete(promptHash)
|
|
125
|
+
try {
|
|
126
|
+
await db.delete(semanticCacheTable).where(eq(semanticCacheTable.promptHash, promptHash))
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async clear(): Promise<void> {
|
|
133
|
+
this.memoryCache.clear()
|
|
134
|
+
try {
|
|
135
|
+
await db.delete(semanticCacheTable)
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async cleanup(): Promise<number> {
|
|
142
|
+
const now = new Date().toISOString()
|
|
143
|
+
let deleted = 0
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const expired = await db.select({ id: semanticCacheTable.id }).from(semanticCacheTable)
|
|
147
|
+
.where(sql`${semanticCacheTable.expiresAt} < ${now}`)
|
|
148
|
+
deleted = expired.length
|
|
149
|
+
|
|
150
|
+
if (deleted > 0) {
|
|
151
|
+
await db.delete(semanticCacheTable).where(sql`${semanticCacheTable.expiresAt} < ${now}`)
|
|
152
|
+
logger.info({ deleted }, 'Cleaned up expired cache entries')
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const [key, val] of this.memoryCache) {
|
|
159
|
+
if (Date.now() > val.expiresAt) {
|
|
160
|
+
this.memoryCache.delete(key)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return deleted
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getStats() {
|
|
168
|
+
try {
|
|
169
|
+
const [stats] = await db.select({
|
|
170
|
+
total: sql<number>`count(*)`,
|
|
171
|
+
hits: sql<number>`coalesce(sum(${semanticCacheTable.hits}), 0)`,
|
|
172
|
+
}).from(semanticCacheTable)
|
|
173
|
+
|
|
174
|
+
const total = stats?.total ?? 0
|
|
175
|
+
const hits = stats?.hits ?? 0
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
totalEntries: total,
|
|
179
|
+
memoryEntries: this.memoryCache.size,
|
|
180
|
+
totalHits: hits,
|
|
181
|
+
hitRate: total > 0 ? hits / total : 0,
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
return {
|
|
185
|
+
totalEntries: 0,
|
|
186
|
+
memoryEntries: this.memoryCache.size,
|
|
187
|
+
totalHits: 0,
|
|
188
|
+
hitRate: 0,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private setMemoryCache(key: string, entry: CacheEntry) {
|
|
194
|
+
if (this.memoryCache.size >= this.maxMemoryEntries) {
|
|
195
|
+
const oldest = this.memoryCache.keys().next().value
|
|
196
|
+
if (oldest) this.memoryCache.delete(oldest)
|
|
197
|
+
}
|
|
198
|
+
this.memoryCache.set(key, { entry, expiresAt: new Date(entry.expiresAt).getTime() })
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function computeQueryHash(query: string, model: string): Promise<string> {
|
|
203
|
+
const normalized = query.toLowerCase().trim().replace(/\s+/g, ' ')
|
|
204
|
+
const data = `${normalized}:${model}`
|
|
205
|
+
const encoder = new TextEncoder()
|
|
206
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data))
|
|
207
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
208
|
+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export const semanticCacheStore = new SemanticCache()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Config } from './schema'
|
|
2
|
+
|
|
3
|
+
export const configDefaults: Config = {
|
|
4
|
+
port: 3333,
|
|
5
|
+
hostname: '127.0.0.1',
|
|
6
|
+
logLevel: 'info',
|
|
7
|
+
dataDir: '',
|
|
8
|
+
auth: {
|
|
9
|
+
initialPassword: 'changeme',
|
|
10
|
+
jwtSecret: '',
|
|
11
|
+
requireApiKey: false,
|
|
12
|
+
},
|
|
13
|
+
rtk: {
|
|
14
|
+
enabled: true,
|
|
15
|
+
compressionLevel: 'balanced',
|
|
16
|
+
filters: ['git-diff', 'grep', 'ls-tree', 'log-dump', 'smart-truncate'],
|
|
17
|
+
},
|
|
18
|
+
proxy: {
|
|
19
|
+
enabled: false,
|
|
20
|
+
type: 'socks5',
|
|
21
|
+
host: '127.0.0.1',
|
|
22
|
+
port: 9050,
|
|
23
|
+
},
|
|
24
|
+
tor: {
|
|
25
|
+
enabled: false,
|
|
26
|
+
socksPort: 9050,
|
|
27
|
+
controlPort: 9051,
|
|
28
|
+
password: '',
|
|
29
|
+
},
|
|
30
|
+
cache: {
|
|
31
|
+
enabled: true,
|
|
32
|
+
ttlMinutes: 10,
|
|
33
|
+
maxSize: '100MB',
|
|
34
|
+
},
|
|
35
|
+
fallback: {
|
|
36
|
+
maxRetries: 3,
|
|
37
|
+
timeoutMs: 30000,
|
|
38
|
+
cooldownMinutes: 5,
|
|
39
|
+
},
|
|
40
|
+
cloudSync: {
|
|
41
|
+
enabled: false,
|
|
42
|
+
url: '',
|
|
43
|
+
encryptionKey: '',
|
|
44
|
+
},
|
|
45
|
+
semanticCache: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
similarityThreshold: 0.95,
|
|
48
|
+
ttlMinutes: 30,
|
|
49
|
+
maxEntries: 10000,
|
|
50
|
+
embeddingModel: 'local-minilm',
|
|
51
|
+
},
|
|
52
|
+
memory: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
episodicTtlDays: 90,
|
|
55
|
+
distillationIntervalHours: 6,
|
|
56
|
+
},
|
|
57
|
+
skills: {
|
|
58
|
+
enabled: true,
|
|
59
|
+
defaultRotationStrategy: 'task_match',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import { configSchema, type Config } from './schema'
|
|
5
|
+
import { configDefaults } from './defaults'
|
|
6
|
+
|
|
7
|
+
let cachedConfig: Config | null = null
|
|
8
|
+
|
|
9
|
+
function getConfigPath(): string {
|
|
10
|
+
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), '.synapse')
|
|
11
|
+
return path.join(dataDir, 'config.json')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ensureDataDir(): void {
|
|
15
|
+
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), '.synapse')
|
|
16
|
+
if (!fs.existsSync(dataDir)) {
|
|
17
|
+
fs.mkdirSync(dataDir, { recursive: true })
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadConfig(): Config {
|
|
22
|
+
if (cachedConfig) return cachedConfig
|
|
23
|
+
|
|
24
|
+
ensureDataDir()
|
|
25
|
+
const configPath = getConfigPath()
|
|
26
|
+
|
|
27
|
+
let rawConfig: Record<string, unknown> = {}
|
|
28
|
+
if (fs.existsSync(configPath)) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = fs.readFileSync(configPath, 'utf-8')
|
|
31
|
+
rawConfig = JSON.parse(raw)
|
|
32
|
+
} catch {
|
|
33
|
+
rawConfig = {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const merged = { ...configDefaults, ...rawConfig }
|
|
38
|
+
const parsed = configSchema.safeParse(merged)
|
|
39
|
+
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
console.error('Config validation errors:', parsed.error.flatten())
|
|
42
|
+
cachedConfig = configDefaults
|
|
43
|
+
return cachedConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cachedConfig = parsed.data
|
|
47
|
+
|
|
48
|
+
if (!fs.existsSync(configPath)) {
|
|
49
|
+
saveConfig(cachedConfig)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return cachedConfig
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveConfig(config: Config): void {
|
|
56
|
+
ensureDataDir()
|
|
57
|
+
const configPath = getConfigPath()
|
|
58
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8')
|
|
59
|
+
cachedConfig = config
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function updateConfig(partial: Partial<Config>): Config {
|
|
63
|
+
const current = loadConfig()
|
|
64
|
+
const updated = { ...current, ...partial }
|
|
65
|
+
const parsed = configSchema.parse(updated)
|
|
66
|
+
saveConfig(parsed)
|
|
67
|
+
return parsed
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getConfig(): Config {
|
|
71
|
+
return loadConfig()
|
|
72
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const configSchema = z.object({
|
|
4
|
+
port: z.number().default(3333),
|
|
5
|
+
hostname: z.string().default('127.0.0.1'),
|
|
6
|
+
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
7
|
+
dataDir: z.string().default(''),
|
|
8
|
+
auth: z.object({
|
|
9
|
+
initialPassword: z.string().default('changeme'),
|
|
10
|
+
jwtSecret: z.string().default(''),
|
|
11
|
+
requireApiKey: z.boolean().default(false),
|
|
12
|
+
}).default({ initialPassword: 'changeme', jwtSecret: '', requireApiKey: false }),
|
|
13
|
+
rtk: z.object({
|
|
14
|
+
enabled: z.boolean().default(true),
|
|
15
|
+
compressionLevel: z.enum(['none', 'light', 'balanced', 'aggressive']).default('balanced'),
|
|
16
|
+
filters: z.array(z.string()).default(['git-diff', 'grep', 'ls-tree', 'log-dump', 'smart-truncate']),
|
|
17
|
+
}).default({ enabled: true, compressionLevel: 'balanced', filters: ['git-diff', 'grep', 'ls-tree', 'log-dump', 'smart-truncate'] }),
|
|
18
|
+
proxy: z.object({
|
|
19
|
+
enabled: z.boolean().default(false),
|
|
20
|
+
type: z.enum(['http', 'socks5']).default('socks5'),
|
|
21
|
+
host: z.string().default('127.0.0.1'),
|
|
22
|
+
port: z.number().default(9050),
|
|
23
|
+
}).default({ enabled: false, type: 'socks5', host: '127.0.0.1', port: 9050 }),
|
|
24
|
+
tor: z.object({
|
|
25
|
+
enabled: z.boolean().default(false),
|
|
26
|
+
socksPort: z.number().default(9050),
|
|
27
|
+
controlPort: z.number().default(9051),
|
|
28
|
+
password: z.string().default(''),
|
|
29
|
+
}).default({ enabled: false, socksPort: 9050, controlPort: 9051, password: '' }),
|
|
30
|
+
cache: z.object({
|
|
31
|
+
enabled: z.boolean().default(true),
|
|
32
|
+
ttlMinutes: z.number().default(10),
|
|
33
|
+
maxSize: z.string().default('100MB'),
|
|
34
|
+
}).default({ enabled: true, ttlMinutes: 10, maxSize: '100MB' }),
|
|
35
|
+
fallback: z.object({
|
|
36
|
+
maxRetries: z.number().default(3),
|
|
37
|
+
timeoutMs: z.number().default(30000),
|
|
38
|
+
cooldownMinutes: z.number().default(5),
|
|
39
|
+
}).default({ maxRetries: 3, timeoutMs: 30000, cooldownMinutes: 5 }),
|
|
40
|
+
cloudSync: z.object({
|
|
41
|
+
enabled: z.boolean().default(false),
|
|
42
|
+
url: z.string().default(''),
|
|
43
|
+
encryptionKey: z.string().default(''),
|
|
44
|
+
}).default({ enabled: false, url: '', encryptionKey: '' }),
|
|
45
|
+
semanticCache: z.object({
|
|
46
|
+
enabled: z.boolean().default(true),
|
|
47
|
+
similarityThreshold: z.number().min(0).max(1).default(0.95),
|
|
48
|
+
ttlMinutes: z.number().default(30),
|
|
49
|
+
maxEntries: z.number().default(10000),
|
|
50
|
+
embeddingModel: z.string().default('local-minilm'),
|
|
51
|
+
}).default({ enabled: true, similarityThreshold: 0.95, ttlMinutes: 30, maxEntries: 10000, embeddingModel: 'local-minilm' }),
|
|
52
|
+
memory: z.object({
|
|
53
|
+
enabled: z.boolean().default(true),
|
|
54
|
+
episodicTtlDays: z.number().default(90),
|
|
55
|
+
distillationIntervalHours: z.number().default(6),
|
|
56
|
+
}).default({ enabled: true, episodicTtlDays: 90, distillationIntervalHours: 6 }),
|
|
57
|
+
skills: z.object({
|
|
58
|
+
enabled: z.boolean().default(true),
|
|
59
|
+
defaultRotationStrategy: z.enum(['task_match', 'round_robin', 'quality_based', 'schedule', 'weighted_random']).default('task_match'),
|
|
60
|
+
}).default({ enabled: true, defaultRotationStrategy: 'task_match' }),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
export type Config = z.infer<typeof configSchema>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import Database from 'better-sqlite3'
|
|
2
|
+
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
3
|
+
import * as schema from './schema'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
import fs from 'fs'
|
|
7
|
+
|
|
8
|
+
const dataDir = process.env.DATA_DIR || path.join(os.homedir(), '.synapse')
|
|
9
|
+
const dbDir = path.join(dataDir, 'db')
|
|
10
|
+
const dbPath = path.join(dbDir, 'synapse.db')
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(dbDir)) {
|
|
13
|
+
fs.mkdirSync(dbDir, { recursive: true })
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const sqlite = new Database(dbPath)
|
|
17
|
+
sqlite.pragma('journal_mode = WAL')
|
|
18
|
+
sqlite.pragma('foreign_keys = ON')
|
|
19
|
+
sqlite.pragma('busy_timeout = 5000')
|
|
20
|
+
|
|
21
|
+
export const db = drizzle(sqlite, { schema })
|
|
22
|
+
export { sqlite }
|