saeeol 1.0.9 → 1.1.1

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 (63) hide show
  1. package/npm/bin/saeeol +42 -0
  2. package/npm/package.json +39 -0
  3. package/npm/postinstall.js +162 -0
  4. package/package.json +2 -2
  5. package/src/cli/cmd/mcp-refresh.ts +47 -0
  6. package/src/cli/cmd/mcp.ts +3 -1
  7. package/src/cli/cmd/tui/app-commands-core.tsx +11 -0
  8. package/src/cli/cmd/tui/app-commands-system.tsx +20 -0
  9. package/src/cli/cmd/tui/app-events.ts +43 -0
  10. package/src/cli/cmd/tui/app.tsx +4 -0
  11. package/src/cli/cmd/tui/component/dialog-model.tsx +2 -2
  12. package/src/cli/cmd/tui/component/prompt/use-prompt-memos.ts +1 -1
  13. package/src/cli/cmd/tui/component/use-connected.tsx +1 -1
  14. package/src/cli/cmd/tui/context/local.tsx +10 -3
  15. package/src/cli/cmd/tui/context/route.tsx +5 -1
  16. package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +1 -1
  17. package/src/cli/cmd/tui/plugin/api.tsx +7 -3
  18. package/src/cli/cmd/tui/routes/local-models.tsx +151 -0
  19. package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +1 -1
  20. package/src/cli/cmd/tui/util/model.ts +1 -1
  21. package/src/config/config-schema.ts +44 -0
  22. package/src/ltm/config.ts +124 -0
  23. package/src/ltm/events.ts +50 -0
  24. package/src/ltm/index.ts +12 -0
  25. package/src/ltm/memory/episodic.ts +83 -0
  26. package/src/ltm/memory/procedural.ts +102 -0
  27. package/src/ltm/memory/semantic.ts +80 -0
  28. package/src/ltm/pipeline.ts +155 -0
  29. package/src/ltm/retrieval.ts +62 -0
  30. package/src/ltm/scheduler.ts +55 -0
  31. package/src/ltm/store.ts +150 -0
  32. package/src/ltm/types.ts +108 -0
  33. package/src/mcp/index.ts +32 -1
  34. package/src/provider/custom-loaders.ts +12 -0
  35. package/src/provider/loader-local.ts +185 -0
  36. package/src/provider/local/embedder.ts +220 -0
  37. package/src/provider/local/events.ts +74 -0
  38. package/src/provider/local/gpu.ts +93 -0
  39. package/src/provider/local/hub.ts +174 -0
  40. package/src/provider/local/index.ts +10 -0
  41. package/src/provider/local/model-manager.ts +113 -0
  42. package/src/provider/local/orchestrator.ts +301 -0
  43. package/src/provider/local/rag.ts +112 -0
  44. package/src/provider/local/types.ts +142 -0
  45. package/src/provider/provider-conversion.ts +2 -0
  46. package/src/provider/provider-schema.ts +17 -2
  47. package/src/provider/provider-schemas.ts +10 -3
  48. package/src/provider/provider-state.ts +10 -2
  49. package/src/provider/provider.ts +2 -1
  50. package/src/saeeol/plugins/sidebar-usage.tsx +1 -1
  51. package/src/server/routes/instance/config.ts +1 -1
  52. package/src/server/routes/instance/httpapi/api.ts +2 -0
  53. package/src/server/routes/instance/httpapi/groups/local.ts +87 -0
  54. package/src/server/routes/instance/httpapi/groups/mcp.ts +10 -0
  55. package/src/server/routes/instance/httpapi/handlers/local.ts +95 -0
  56. package/src/server/routes/instance/httpapi/handlers/mcp.ts +5 -0
  57. package/src/server/routes/instance/httpapi/handlers/provider.ts +1 -1
  58. package/src/server/routes/instance/httpapi/server.ts +2 -0
  59. package/src/server/routes/instance/provider.ts +2 -2
  60. package/src/session/prompt-reminders.ts +29 -0
  61. package/test/fake/provider.ts +1 -0
  62. package/test/provider/local.test.ts +208 -0
  63. package/test/provider/provider-category.test.ts +190 -0
@@ -0,0 +1,155 @@
1
+ /** LTM — 백그라운드 수집 파이프라인 */
2
+
3
+ import { Effect } from "effect"
4
+ import * as Log from "@saeeol/core/util/log"
5
+ import * as Bus from "@/bus"
6
+ import * as Store from "@/ltm/store"
7
+ import * as Embedder from "@/provider/local/embedder"
8
+ import * as Episodic from "@/ltm/memory/episodic"
9
+ import * as Semantic from "@/ltm/memory/semantic"
10
+ import * as Procedural from "@/ltm/memory/procedural"
11
+ import * as Scheduler from "@/ltm/scheduler"
12
+ import type { LTMConfig, LLMBakeParams } from "@/ltm/types"
13
+ import { LTMEvent } from "@/ltm/events"
14
+
15
+ const log = Log.create({ service: "ltm/pipeline" })
16
+
17
+ let running = false
18
+ let config: LTMConfig | undefined
19
+
20
+ // ── 파이프라인 수명 ──
21
+
22
+ /** 파이프라인 시작 */
23
+ export async function start(cfg: LTMConfig, bake: LLMBakeParams): Promise<void> {
24
+ if (running) return
25
+ if (!cfg.enabled) return
26
+
27
+ config = cfg
28
+ running = true
29
+
30
+ log.info("pipeline starting", { model: bake.embeddingModel })
31
+
32
+ // 임베딩 서버 시작
33
+ const server = await Embedder.start(bake)
34
+ if (server.status !== "running") {
35
+ log.error("embedding server failed to start, pipeline disabled")
36
+ running = false
37
+ return
38
+ }
39
+
40
+ // 오래된 기억 정리
41
+ if (cfg.episodic.enabled) {
42
+ const pruned = await Store.prune(cfg.episodic.retainDays * 24 * 60 * 60 * 1000)
43
+ if (pruned > 0) {
44
+ log.info("pruned old episodic memories", { count: pruned })
45
+ void Bus.publish(LTMEvent.MemoryPruned, { count: pruned, type: "episodic" })
46
+ }
47
+ }
48
+
49
+ // 기억 수 제한
50
+ const count = await Store.count()
51
+ if (count > cfg.maxMemories) {
52
+ const excess = count - cfg.maxMemories
53
+ const memories = await Store.list()
54
+ const sorted = memories.sort((a, b) => a.metadata.timestamp - b.metadata.timestamp)
55
+ const toRemove = sorted.slice(0, excess).map((m) => m.id)
56
+ await Store.remove(toRemove)
57
+ log.info("trimmed memories to max", { removed: toRemove.length })
58
+ }
59
+
60
+ log.info("pipeline started", { memoryCount: await Store.count() })
61
+ }
62
+
63
+ /** 파이프라인 중지 */
64
+ export async function stop(): Promise<void> {
65
+ if (!running) return
66
+ running = false
67
+ await Embedder.stop()
68
+ config = undefined
69
+ log.info("pipeline stopped")
70
+ }
71
+
72
+ /** 현재 상태 */
73
+ export function isActive(): boolean {
74
+ return running
75
+ }
76
+
77
+ // ── 이벤트 핸들러 ──
78
+
79
+ /** 대화 메시지 → 에피소드 기억 */
80
+ export async function onMessageCompleted(
81
+ sessionID: string,
82
+ projectID: string | undefined,
83
+ userMsg: string,
84
+ assistantMsg: string,
85
+ ): Promise<void> {
86
+ if (!running || !config?.episodic.enabled) return
87
+
88
+ const memory = await Episodic.fromConversation(sessionID, projectID, userMsg, assistantMsg)
89
+ if (!memory) return
90
+
91
+ await Store.upsert(memory)
92
+ void Bus.publish(LTMEvent.MemoryStored, {
93
+ id: memory.id,
94
+ type: memory.type,
95
+ source: memory.metadata.source,
96
+ })
97
+ }
98
+
99
+ /** 파일 변경 → 시맨틱 기억 */
100
+ export async function onFileChanged(
101
+ projectID: string,
102
+ filePath: string,
103
+ content: string,
104
+ ): Promise<void> {
105
+ if (!running || !config?.semantic.enabled || !config.semantic.indexOnFileChange) return
106
+
107
+ // 파일을 청크로 분할 (간단한 줄 기반)
108
+ const lines = content.split("\n")
109
+ const chunkSize = 50
110
+ const chunks: Array<{ text: string; start: number; end: number }> = []
111
+
112
+ for (let i = 0; i < lines.length; i += chunkSize) {
113
+ const chunk = lines.slice(i, i + chunkSize).join("\n")
114
+ if (chunk.trim().length > 20) {
115
+ chunks.push({ text: chunk, start: i + 1, end: Math.min(i + chunkSize, lines.length) })
116
+ }
117
+ }
118
+
119
+ for (const chunk of chunks) {
120
+ const memory = await Semantic.fromCodeChunk(
121
+ projectID,
122
+ filePath,
123
+ chunk.text,
124
+ chunk.start,
125
+ chunk.end,
126
+ )
127
+ if (memory) {
128
+ await Store.upsert(memory)
129
+ void Bus.publish(LTMEvent.MemoryStored, {
130
+ id: memory.id,
131
+ type: memory.type,
132
+ source: memory.metadata.source,
133
+ })
134
+ }
135
+ }
136
+
137
+ log.info("indexed file", { filePath, chunks: chunks.length })
138
+ }
139
+
140
+ /** 코드 편집 → 절차적 기억 */
141
+ export async function onCodeEdit(
142
+ projectID: string | undefined,
143
+ filePath: string,
144
+ content: string,
145
+ ): Promise<void> {
146
+ if (!running || !config?.procedural.enabled || !config.procedural.trackPreferences) return
147
+
148
+ const signals = Procedural.extractStyleSignals(filePath, content)
149
+ if (signals.length === 0) return
150
+
151
+ const memories = await Procedural.fromStyleSignals(projectID, signals)
152
+ for (const memory of memories) {
153
+ await Store.upsert(memory)
154
+ }
155
+ }
@@ -0,0 +1,62 @@
1
+ /** LTM retrieval — search + prompt context injection (English, LLM-to-LLM) */
2
+
3
+ import * as Log from "@saeeol/core/util/log"
4
+ import * as Store from "@/ltm/store"
5
+ import * as Embedder from "@/provider/local/embedder"
6
+ import type { Memory, LTMConfig } from "@/ltm/types"
7
+
8
+ const log = Log.create({ service: "ltm/retrieval" })
9
+
10
+ /** Search memories relevant to a query */
11
+ export async function search(
12
+ query: string,
13
+ config: LTMConfig,
14
+ ): Promise<Memory[]> {
15
+ try {
16
+ const vector = await Embedder.embedOne(query)
17
+ return Store.search(vector, {
18
+ topK: config.retrieval.topK,
19
+ minScore: config.retrieval.minScore,
20
+ })
21
+ } catch (e) {
22
+ log.error("retrieval search failed", { error: e })
23
+ return []
24
+ }
25
+ }
26
+
27
+ /** Format memories as prompt context block (English, consumed by LLM) */
28
+ export function format(memories: Memory[]): string {
29
+ if (memories.length === 0) return ""
30
+
31
+ const lines = memories.map((m) => {
32
+ const date = new Date(m.metadata.timestamp).toISOString().slice(0, 10)
33
+ const tag = m.type
34
+ const summary = m.summary.slice(0, 200)
35
+ return `- [${date}] (${tag}) ${summary}`
36
+ })
37
+
38
+ return `[long-term memory — ${memories.length} recalled]\n${lines.join("\n")}`
39
+ }
40
+
41
+ /** Inject relevant memories into the session prompt */
42
+ export async function inject(
43
+ query: string,
44
+ config: LTMConfig,
45
+ ): Promise<string | undefined> {
46
+ const memories = await search(query, config)
47
+ if (memories.length === 0) return undefined
48
+
49
+ const formatted = format(memories)
50
+ const tokenEstimate = Math.ceil(formatted.length / 4)
51
+
52
+ if (tokenEstimate > config.retrieval.maxTokens) {
53
+ const kept = Math.max(1, Math.floor(memories.length * config.retrieval.maxTokens / tokenEstimate))
54
+ const trimmed = memories.slice(0, kept)
55
+ const reduced = format(trimmed)
56
+ log.info("retrieval trimmed", { original: memories.length, trimmed: trimmed.length })
57
+ return reduced
58
+ }
59
+
60
+ log.info("retrieval injected", { count: memories.length, tokens: tokenEstimate })
61
+ return formatted
62
+ }
@@ -0,0 +1,55 @@
1
+ /** LTM — VRAM/작업 스케줄러 */
2
+
3
+ import * as Log from "@saeeol/core/util/log"
4
+ import type { HardwareProfile } from "./types"
5
+ import * as GPU from "@/provider/local/gpu"
6
+ import * as Embedder from "@/provider/local/embedder"
7
+ import { Effect } from "effect"
8
+
9
+ const log = Log.create({ service: "ltm/scheduler" })
10
+
11
+ /** 현재 할당 상태 */
12
+ export interface Allocation {
13
+ llm: number
14
+ embedding: number
15
+ available: number
16
+ }
17
+
18
+ /** 현재 VRAM 할당 상태 조회 */
19
+ export async function allocation(): Promise<Allocation> {
20
+ const gpu = await Effect.runPromise(GPU.profile)
21
+ const embVRAM = Embedder.vramUsage()
22
+ return {
23
+ llm: gpu.totalVRAMMB - gpu.availableVRAMMB - embVRAM,
24
+ embedding: embVRAM,
25
+ available: gpu.availableVRAMMB,
26
+ }
27
+ }
28
+
29
+ /** 임베딩 백그라운드 작업 실행 가능한지 판단 */
30
+ export async function canRunEmbedding(): Promise<boolean> {
31
+ const alloc = await allocation()
32
+ // 최소 2GB VRAM 필요
33
+ return alloc.available >= 2048
34
+ }
35
+
36
+ /** LLM과 임베딩 동시 실행 가능한지 판단 */
37
+ export async function canRunConcurrent(hw: HardwareProfile): Promise<boolean> {
38
+ // 16GB 이상 VRAM이면 항상 동시 실행 가능
39
+ if (hw.totalVRAMMB >= 16384) return true
40
+ // 8GB 이상이면 임베딩 모델이 1GB 이하일 때 가능
41
+ if (hw.totalVRAMMB >= 8192 && Embedder.vramUsage() <= 1024) return true
42
+ // 그 외는 교대 실행
43
+ return false
44
+ }
45
+
46
+ /** 스케줄링 전략 반환 */
47
+ export type Strategy = "concurrent" | "alternating" | "cpu-fallback" | "no-gpu"
48
+
49
+ export async function strategy(): Promise<Strategy> {
50
+ const gpu = await Effect.runPromise(GPU.profile)
51
+ if (!gpu.cudaAvailable) return "no-gpu"
52
+ if (gpu.totalVRAMMB >= 16384) return "concurrent"
53
+ if (gpu.totalVRAMMB >= 8192) return "alternating"
54
+ return "cpu-fallback"
55
+ }
@@ -0,0 +1,150 @@
1
+ /** LTM — 파일시스템 기반 벡터 스토어 */
2
+
3
+ import path from "path"
4
+ import { mkdir, readFile, writeFile, readdir, rm, stat } from "fs/promises"
5
+ import * as Log from "@saeeol/core/util/log"
6
+ import { Global } from "@saeeol/core/global"
7
+ import type { Memory, MemoryType } from "./types"
8
+
9
+ const log = Log.create({ service: "ltm/store" })
10
+
11
+ // ── 코사인 유사도 ──
12
+
13
+ function cosine(a: number[], b: number[]): number {
14
+ let dot = 0
15
+ let na = 0
16
+ let nb = 0
17
+ const len = Math.min(a.length, b.length)
18
+ for (let i = 0; i < len; i++) {
19
+ dot += a[i]! * b[i]!
20
+ na += a[i]! * a[i]!
21
+ nb += b[i]! * b[i]!
22
+ }
23
+ const denom = Math.sqrt(na) * Math.sqrt(nb)
24
+ return denom === 0 ? 0 : dot / denom
25
+ }
26
+
27
+ // ── 파일 경로 ──
28
+
29
+ function storeDir(): string {
30
+ return path.join(Global.Path.data, "ltm", "memories")
31
+ }
32
+
33
+ function memoryPath(id: string): string {
34
+ return path.join(storeDir(), `${id}.json`)
35
+ }
36
+
37
+ function indexPath(): string {
38
+ return path.join(Global.Path.data, "ltm", "index.json")
39
+ }
40
+
41
+ async function ensure(): Promise<void> {
42
+ await mkdir(storeDir(), { recursive: true })
43
+ }
44
+
45
+ // ── 인덱스 ──
46
+
47
+ interface Index {
48
+ memories: Array<{ id: string; type: MemoryType; timestamp: number; source: string }>
49
+ }
50
+
51
+ async function readIndex(): Promise<Index> {
52
+ try {
53
+ const raw = await readFile(indexPath(), "utf-8")
54
+ return JSON.parse(raw) as Index
55
+ } catch {
56
+ return { memories: [] }
57
+ }
58
+ }
59
+
60
+ async function writeIndex(idx: Index): Promise<void> {
61
+ await ensure()
62
+ await writeFile(indexPath(), JSON.stringify(idx, null, 2))
63
+ }
64
+
65
+ // ── 공개 API ──
66
+
67
+ export async function upsert(memory: Memory): Promise<void> {
68
+ await ensure()
69
+ await writeFile(memoryPath(memory.id), JSON.stringify(memory, null, 2))
70
+
71
+ const idx = await readIndex()
72
+ const existing = idx.memories.findIndex((m) => m.id === memory.id)
73
+ const entry = { id: memory.id, type: memory.type, timestamp: memory.metadata.timestamp, source: memory.metadata.source }
74
+ if (existing >= 0) {
75
+ idx.memories[existing] = entry
76
+ } else {
77
+ idx.memories.push(entry)
78
+ }
79
+ await writeIndex(idx)
80
+ log.info("upserted", { id: memory.id, type: memory.type })
81
+ }
82
+
83
+ export async function search(
84
+ query: number[],
85
+ opts?: { topK?: number; minScore?: number; type?: MemoryType },
86
+ ): Promise<Memory[]> {
87
+ const topK = opts?.topK ?? 5
88
+ const minScore = opts?.minScore ?? 0.7
89
+ const idx = await readIndex()
90
+
91
+ const candidates: Memory[] = []
92
+ for (const entry of idx.memories) {
93
+ if (opts?.type && entry.type !== opts.type) continue
94
+ try {
95
+ const raw = await readFile(memoryPath(entry.id), "utf-8")
96
+ const mem = JSON.parse(raw) as Memory
97
+ const score = cosine(query, mem.vector)
98
+ if (score >= minScore) {
99
+ candidates.push({ ...mem, score })
100
+ }
101
+ } catch {
102
+ // 파일 삭제됨 — 인덱스에서도 제거
103
+ }
104
+ }
105
+
106
+ candidates.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
107
+ return candidates.slice(0, topK)
108
+ }
109
+
110
+ export async function remove(ids: string[]): Promise<void> {
111
+ for (const id of ids) {
112
+ try {
113
+ await rm(memoryPath(id))
114
+ } catch { /* already removed */ }
115
+ }
116
+
117
+ const idx = await readIndex()
118
+ const set = new Set(ids)
119
+ idx.memories = idx.memories.filter((m) => !set.has(m.id))
120
+ await writeIndex(idx)
121
+ }
122
+
123
+ export async function list(projectID?: string): Promise<Memory[]> {
124
+ const idx = await readIndex()
125
+ const result: Memory[] = []
126
+ for (const entry of idx.memories) {
127
+ try {
128
+ const raw = await readFile(memoryPath(entry.id), "utf-8")
129
+ const mem = JSON.parse(raw) as Memory
130
+ if (!projectID || mem.metadata.projectID === projectID) {
131
+ result.push(mem)
132
+ }
133
+ } catch { /* skip */ }
134
+ }
135
+ return result
136
+ }
137
+
138
+ export async function prune(olderThanMs: number): Promise<number> {
139
+ const cutoff = Date.now() - olderThanMs
140
+ const idx = await readIndex()
141
+ const toRemove = idx.memories.filter((m) => m.timestamp < cutoff)
142
+ await remove(toRemove.map((m) => m.id))
143
+ log.info("pruned", { count: toRemove.length })
144
+ return toRemove.length
145
+ }
146
+
147
+ export async function count(): Promise<number> {
148
+ const idx = await readIndex()
149
+ return idx.memories.length
150
+ }
@@ -0,0 +1,108 @@
1
+ /** LTM — 장기 기억 타입 정의 */
2
+
3
+ import { Schema } from "effect"
4
+ import { optionalOmitUndefined } from "@/util/schema"
5
+
6
+ // ── 기억 유형 ──
7
+
8
+ export const MemoryType = Schema.Literals(["episodic", "semantic", "procedural"])
9
+ export type MemoryType = Schema.Schema.Type<typeof MemoryType>
10
+
11
+ // ── 기억 엔트리 ──
12
+
13
+ export const MemoryMetadata = Schema.Struct({
14
+ source: Schema.String,
15
+ timestamp: Schema.Number,
16
+ projectID: optionalOmitUndefined(Schema.String),
17
+ sessionID: optionalOmitUndefined(Schema.String),
18
+ tags: Schema.mutable(Schema.Array(Schema.String)),
19
+ })
20
+ export type MemoryMetadata = Schema.Schema.Type<typeof MemoryMetadata>
21
+
22
+ export const Memory = Schema.Struct({
23
+ id: Schema.String,
24
+ type: MemoryType,
25
+ content: Schema.String,
26
+ summary: Schema.String,
27
+ vector: Schema.mutable(Schema.Array(Schema.Number)),
28
+ metadata: MemoryMetadata,
29
+ score: optionalOmitUndefined(Schema.Number),
30
+ })
31
+ export type Memory = Schema.Schema.Type<typeof Memory>
32
+
33
+ // ── 임베딩 서버 ──
34
+
35
+ export const EmbedderStatus = Schema.Literals(["stopped", "starting", "running", "error"])
36
+ export type EmbedderStatus = Schema.Schema.Type<typeof EmbedderStatus>
37
+
38
+ export const EmbeddingServer = Schema.Struct({
39
+ id: Schema.String,
40
+ model: Schema.String,
41
+ status: EmbedderStatus,
42
+ endpoint: Schema.String,
43
+ dimensions: Schema.Number,
44
+ vramMB: Schema.Number,
45
+ })
46
+ export type EmbeddingServer = Schema.Schema.Type<typeof EmbeddingServer>
47
+
48
+ // ── 하드웨어 프로파일 ──
49
+
50
+ export const HardwareProfile = Schema.Struct({
51
+ gpuCount: Schema.Number,
52
+ totalVRAMMB: Schema.Number,
53
+ availableVRAMMB: Schema.Number,
54
+ totalRAMMB: Schema.Number,
55
+ cpuCores: Schema.Number,
56
+ cudaAvailable: Schema.Boolean,
57
+ })
58
+ export type HardwareProfile = Schema.Schema.Type<typeof HardwareProfile>
59
+
60
+ // ── LLM 파라미터 (결정론적) ──
61
+
62
+ export const LLMBakeParams = Schema.Struct({
63
+ /** 임베딩 모델 ID */
64
+ embeddingModel: Schema.String,
65
+ /** 임베딩 차원 */
66
+ embeddingDimensions: Schema.Number,
67
+ /** 임베딩 모델 VRAM 사용량 (MB) */
68
+ embeddingVRAMMB: Schema.Number,
69
+ /** 컨텍스트 길이 */
70
+ contextLength: Schema.Number,
71
+ /** 배치 사이즈 */
72
+ batchSize: Schema.Number,
73
+ /** Ollama 스레드 수 */
74
+ numThread: Schema.Number,
75
+ /** GPU 레이어 수 (-1=전체) */
76
+ numGPU: Schema.Number,
77
+ /** 생성 시 프로파일 감지 해시 (변경 시 재계산 트리거) */
78
+ hardwareHash: Schema.String,
79
+ })
80
+ export type LLMBakeParams = Schema.Schema.Type<typeof LLMBakeParams>
81
+
82
+ // ── LTM 설정 ──
83
+
84
+ export const LTMConfig = Schema.Struct({
85
+ enabled: Schema.Boolean,
86
+ embeddingModel: Schema.String,
87
+ vectorStore: Schema.Literals(["filesystem", "qdrant", "chroma"]),
88
+ maxMemories: Schema.Number,
89
+ episodic: Schema.Struct({
90
+ enabled: Schema.Boolean,
91
+ summaryInterval: Schema.Number,
92
+ retainDays: Schema.Number,
93
+ }),
94
+ semantic: Schema.Struct({
95
+ enabled: Schema.Boolean,
96
+ indexOnFileChange: Schema.Boolean,
97
+ }),
98
+ procedural: Schema.Struct({
99
+ enabled: Schema.Boolean,
100
+ trackPreferences: Schema.Boolean,
101
+ }),
102
+ retrieval: Schema.Struct({
103
+ topK: Schema.Number,
104
+ minScore: Schema.Number,
105
+ maxTokens: Schema.Number,
106
+ }),
107
+ })
108
+ export type LTMConfig = Schema.Schema.Type<typeof LTMConfig>
package/src/mcp/index.ts CHANGED
@@ -46,6 +46,7 @@ export interface Interface {
46
46
  readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
47
47
  readonly connect: (name: string) => Effect.Effect<void>
48
48
  readonly disconnect: (name: string) => Effect.Effect<void>
49
+ readonly refresh: () => Effect.Effect<Record<string, Status>>
49
50
  readonly getPrompt: (clientName: string, name: string, args?: Record<string, string>) => Effect.Effect<Awaited<ReturnType<MCPClient["getPrompt"]>> | undefined>
50
51
  readonly readResource: (clientName: string, resourceUri: string) => Effect.Effect<Awaited<ReturnType<MCPClient["readResource"]>> | undefined>
51
52
  readonly startAuth: (mcpName: string) => Effect.Effect<{ authorizationUrl: string; oauthState: string }>
@@ -251,6 +252,35 @@ export const layer = Layer.effect(
251
252
  yield* closeClient(s, name); delete s.clients[name]; s.status[name] = { status: "disabled" }
252
253
  })
253
254
 
255
+ const refresh = Effect.fn("MCP.refresh")(function* () {
256
+ const s = yield* InstanceState.get(state)
257
+ const cfg = yield* cfgSvc.get()
258
+ const config = cfg.mcp ?? {}
259
+ // Disconnect servers no longer in config
260
+ for (const name of Object.keys(s.clients)) {
261
+ if (!config[name] || !isMcpConfigured(config[name])) {
262
+ log.info("removing server no longer in config", { name })
263
+ yield* closeClient(s, name); delete s.clients[name]; delete s.defs[name]; delete s.status[name]
264
+ }
265
+ }
266
+ // Connect new or reconnect changed servers
267
+ yield* Effect.forEach(Object.entries(config), ([key, mcp]) =>
268
+ Effect.gen(function* () {
269
+ if (!isMcpConfigured(mcp)) return
270
+ if (mcp.enabled === false) {
271
+ if (s.clients[key]) { yield* closeClient(s, key); delete s.clients[key]; delete s.defs[key] }
272
+ s.status[key] = { status: "disabled" }
273
+ return
274
+ }
275
+ const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
276
+ if (!result) return
277
+ s.status[key] = result.status
278
+ if (result.mcpClient) yield* storeClient(s, key, result.mcpClient, result.defs!, mcp.timeout)
279
+ }), { concurrency: "unbounded" })
280
+ yield* bus.publish(ToolsChanged, { server: "*" }).pipe(Effect.ignore)
281
+ return s.status
282
+ })
283
+
254
284
  const tools = Effect.fn("MCP.tools")(function* () {
255
285
  const result: Record<string, Tool> = {}
256
286
  const s = yield* InstanceState.get(state)
@@ -395,7 +425,7 @@ export const layer = Layer.effect(
395
425
  return (expired ? "expired" : "authenticated") as AuthStatus
396
426
  })
397
427
 
398
- return Service.of({ status, clients, tools, prompts, resources, add, connect, disconnect, getPrompt, readResource, startAuth, authenticate, finishAuth, removeAuth, supportsOAuth, hasStoredTokens, getAuthStatus })
428
+ return Service.of({ status, clients, tools, prompts, resources, add, connect, disconnect, refresh, getPrompt, readResource, startAuth, authenticate, finishAuth, removeAuth, supportsOAuth, hasStoredTokens, getAuthStatus })
399
429
  }),
400
430
  )
401
431
 
@@ -410,4 +440,5 @@ const { runPromise } = makeRuntime(Service, defaultLayer)
410
440
  export const status = () => runPromise((svc) => svc.status())
411
441
  export const connect = (name: string) => runPromise((svc) => svc.connect(name))
412
442
  export const disconnect = (name: string) => runPromise((svc) => svc.disconnect(name))
443
+ export const refresh = () => runPromise((svc) => svc.refresh())
413
444
  export * as MCP from "."
@@ -4,10 +4,12 @@ import type { Info } from "./provider-schema"
4
4
  import { useLanguageModel } from "./bundled-providers"
5
5
  import { cloudLoaders } from "./loader-cloud"
6
6
  import { platformLoaders } from "./loader-platform"
7
+ import { localLoaders } from "./loader-local"
7
8
  import { iife } from "@/util/iife"
8
9
 
9
10
  export function custom(dep: CustomDep): Record<string, CustomLoader> {
10
11
  return {
12
+ ...localLoaders(dep),
11
13
  ...cloudLoaders(dep),
12
14
  ...platformLoaders(dep),
13
15
  anthropic: () =>
@@ -161,3 +163,13 @@ export function custom(dep: CustomDep): Record<string, CustomLoader> {
161
163
  }),
162
164
  }
163
165
  }
166
+
167
+ // Provider ID lists by category
168
+ export const LOCAL_PROVIDERS = ["ollama", "lmstudio", "vllm", "text-generation-webui", "llama.cpp"] as const
169
+ export const CUSTOM_PROVIDERS = ["llmgateway", "openrouter", "nvidia", "vercel", "zenmux", "cerebras"] as const
170
+ export const CLOUD_PROVIDERS = [
171
+ "anthropic", "openai", "xai", "saeeol",
172
+ "azure", "azure-cognitive-services", "amazon-bedrock",
173
+ "google-vertex", "google-vertex-anthropic", "sap-ai-core",
174
+ "gitlab", "cloudflare-workers-ai", "cloudflare-ai-gateway", "github-copilot",
175
+ ] as const