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.
- package/npm/bin/saeeol +42 -0
- package/npm/package.json +39 -0
- package/npm/postinstall.js +162 -0
- package/package.json +2 -2
- package/src/cli/cmd/mcp-refresh.ts +47 -0
- package/src/cli/cmd/mcp.ts +3 -1
- package/src/cli/cmd/tui/app-commands-core.tsx +11 -0
- package/src/cli/cmd/tui/app-commands-system.tsx +20 -0
- package/src/cli/cmd/tui/app-events.ts +43 -0
- package/src/cli/cmd/tui/app.tsx +4 -0
- package/src/cli/cmd/tui/component/dialog-model.tsx +2 -2
- package/src/cli/cmd/tui/component/prompt/use-prompt-memos.ts +1 -1
- package/src/cli/cmd/tui/component/use-connected.tsx +1 -1
- package/src/cli/cmd/tui/context/local.tsx +10 -3
- package/src/cli/cmd/tui/context/route.tsx +5 -1
- package/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +1 -1
- package/src/cli/cmd/tui/plugin/api.tsx +7 -3
- package/src/cli/cmd/tui/routes/local-models.tsx +151 -0
- package/src/cli/cmd/tui/routes/session/subagent-footer.tsx +1 -1
- package/src/cli/cmd/tui/util/model.ts +1 -1
- package/src/config/config-schema.ts +44 -0
- package/src/ltm/config.ts +124 -0
- package/src/ltm/events.ts +50 -0
- package/src/ltm/index.ts +12 -0
- package/src/ltm/memory/episodic.ts +83 -0
- package/src/ltm/memory/procedural.ts +102 -0
- package/src/ltm/memory/semantic.ts +80 -0
- package/src/ltm/pipeline.ts +155 -0
- package/src/ltm/retrieval.ts +62 -0
- package/src/ltm/scheduler.ts +55 -0
- package/src/ltm/store.ts +150 -0
- package/src/ltm/types.ts +108 -0
- package/src/mcp/index.ts +32 -1
- package/src/provider/custom-loaders.ts +12 -0
- package/src/provider/loader-local.ts +185 -0
- package/src/provider/local/embedder.ts +220 -0
- package/src/provider/local/events.ts +74 -0
- package/src/provider/local/gpu.ts +93 -0
- package/src/provider/local/hub.ts +174 -0
- package/src/provider/local/index.ts +10 -0
- package/src/provider/local/model-manager.ts +113 -0
- package/src/provider/local/orchestrator.ts +301 -0
- package/src/provider/local/rag.ts +112 -0
- package/src/provider/local/types.ts +142 -0
- package/src/provider/provider-conversion.ts +2 -0
- package/src/provider/provider-schema.ts +17 -2
- package/src/provider/provider-schemas.ts +10 -3
- package/src/provider/provider-state.ts +10 -2
- package/src/provider/provider.ts +2 -1
- package/src/saeeol/plugins/sidebar-usage.tsx +1 -1
- package/src/server/routes/instance/config.ts +1 -1
- package/src/server/routes/instance/httpapi/api.ts +2 -0
- package/src/server/routes/instance/httpapi/groups/local.ts +87 -0
- package/src/server/routes/instance/httpapi/groups/mcp.ts +10 -0
- package/src/server/routes/instance/httpapi/handlers/local.ts +95 -0
- package/src/server/routes/instance/httpapi/handlers/mcp.ts +5 -0
- package/src/server/routes/instance/httpapi/handlers/provider.ts +1 -1
- package/src/server/routes/instance/httpapi/server.ts +2 -0
- package/src/server/routes/instance/provider.ts +2 -2
- package/src/session/prompt-reminders.ts +29 -0
- package/test/fake/provider.ts +1 -0
- package/test/provider/local.test.ts +208 -0
- 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
|
+
}
|
package/src/ltm/store.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ltm/types.ts
ADDED
|
@@ -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
|