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,151 @@
|
|
|
1
|
+
/** TUI route — Local model management page */
|
|
2
|
+
|
|
3
|
+
import { createSignal, createEffect, For, Show } from "solid-js"
|
|
4
|
+
import type { BackendStatus, ModelInstance } from "@/provider/local/types"
|
|
5
|
+
|
|
6
|
+
function fmt(bytes: number): string {
|
|
7
|
+
if (bytes < 1024) return `${bytes}B`
|
|
8
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`
|
|
9
|
+
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)}MB`
|
|
10
|
+
return `${(bytes / 1073741824).toFixed(1)}GB`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LocalModels() {
|
|
14
|
+
const [tab, setTab] = createSignal<0 | 1 | 2 | 3>(0)
|
|
15
|
+
const [backends, setBackends] = createSignal<BackendStatus[]>([])
|
|
16
|
+
const [instances, setInstances] = createSignal<ModelInstance[]>([])
|
|
17
|
+
const [installed, setInstalled] = createSignal<Array<{ repo: string; filename: string; path: string; sizeBytes: number }>>([])
|
|
18
|
+
const [gpu, setGpu] = createSignal<any>(null)
|
|
19
|
+
const [hubQuery, setHubQuery] = createSignal("")
|
|
20
|
+
const [hubResults, setHubResults] = createSignal<any[]>([])
|
|
21
|
+
const [loading, setLoading] = createSignal(false)
|
|
22
|
+
|
|
23
|
+
createEffect(() => { refresh() })
|
|
24
|
+
|
|
25
|
+
async function refresh() {
|
|
26
|
+
setLoading(true)
|
|
27
|
+
try {
|
|
28
|
+
const [g, b, m, i] = await Promise.all([
|
|
29
|
+
fetch("/local/gpu").then((r) => r.json()).catch(() => null),
|
|
30
|
+
fetch("/local/backends").then((r) => r.json()).catch(() => []),
|
|
31
|
+
fetch("/local/models").then((r) => r.json()).catch(() => []),
|
|
32
|
+
fetch("/local/instances").then((r) => r.json()).catch(() => []),
|
|
33
|
+
])
|
|
34
|
+
setGpu(g); setBackends(b); setInstalled(m); setInstances(i)
|
|
35
|
+
} finally { setLoading(false) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function searchHub() {
|
|
39
|
+
const q = hubQuery(); if (!q) return
|
|
40
|
+
const res = await fetch(`/local/hub/search?q=${encodeURIComponent(q)}`)
|
|
41
|
+
setHubResults(await res.json())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function loadModel(repo: string, filename: string) {
|
|
45
|
+
await fetch("/local/instances/load", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ repo, filename }) })
|
|
46
|
+
await refresh()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function unloadModel(instanceId: string) {
|
|
50
|
+
await fetch("/local/instances/unload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ instanceId }) })
|
|
51
|
+
await refresh()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tabLabels = ["Backends", "Models", "HuggingFace", "RAG"]
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<box flexDirection="column" width="100%" height="100%" paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2}>
|
|
58
|
+
{/* Header */}
|
|
59
|
+
<box flexDirection="row">
|
|
60
|
+
<text fg="cyan">{"Local Models"}</text>
|
|
61
|
+
<text>{" "}</text>
|
|
62
|
+
<Show when={gpu()} fallback={<text>{"No GPU info"}</text>}>
|
|
63
|
+
<text>{gpu().cudaAvailable ? `GPU: ${gpu().gpus.length}x | VRAM: ${gpu().availableVRAMMB}MB free` : "No CUDA GPU"}</text>
|
|
64
|
+
</Show>
|
|
65
|
+
</box>
|
|
66
|
+
|
|
67
|
+
{/* Tabs */}
|
|
68
|
+
<box flexDirection="row" paddingTop={1} paddingBottom={1}>
|
|
69
|
+
<For each={tabLabels}>
|
|
70
|
+
{(label, i) => (
|
|
71
|
+
<box paddingRight={3}>
|
|
72
|
+
<text fg={tab() === i() ? "green" : "white"}>{tab() === i() ? `[${label}]` : ` ${label} `}</text>
|
|
73
|
+
</box>
|
|
74
|
+
)}
|
|
75
|
+
</For>
|
|
76
|
+
</box>
|
|
77
|
+
|
|
78
|
+
{/* Tab: Backends */}
|
|
79
|
+
<Show when={tab() === 0}>
|
|
80
|
+
<text fg="white">{"Detected Backends"}</text>
|
|
81
|
+
<box paddingTop={1} />
|
|
82
|
+
<For each={backends()}>
|
|
83
|
+
{(b) => (
|
|
84
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
85
|
+
<text fg={b.available ? "green" : "red"}>{b.available ? "● " : "○ "}</text>
|
|
86
|
+
<text>{b.type + " "}</text>
|
|
87
|
+
<text>{b.endpoint}</text>
|
|
88
|
+
</box>
|
|
89
|
+
)}
|
|
90
|
+
</For>
|
|
91
|
+
</Show>
|
|
92
|
+
|
|
93
|
+
{/* Tab: Models */}
|
|
94
|
+
<Show when={tab() === 1}>
|
|
95
|
+
<text fg="white">{"Installed Models"}</text>
|
|
96
|
+
<box paddingTop={1} />
|
|
97
|
+
<For each={installed()}>
|
|
98
|
+
{(m) => (
|
|
99
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
100
|
+
<text>{m.repo + "/" + m.filename + " "}</text>
|
|
101
|
+
<text>{fmt(m.sizeBytes) + " "}</text>
|
|
102
|
+
<text fg="green">{"[Load]"}</text>
|
|
103
|
+
</box>
|
|
104
|
+
)}
|
|
105
|
+
</For>
|
|
106
|
+
|
|
107
|
+
<Show when={instances().length > 0}>
|
|
108
|
+
<box paddingTop={1} />
|
|
109
|
+
<text fg="white">{"Running Instances"}</text>
|
|
110
|
+
<box paddingTop={1} />
|
|
111
|
+
<For each={instances()}>
|
|
112
|
+
{(inst) => (
|
|
113
|
+
<box flexDirection="row" paddingBottom={1}>
|
|
114
|
+
<text fg={inst.status === "running" ? "green" : "yellow"}>{inst.status + " "}</text>
|
|
115
|
+
<text>{inst.artifact.repo + " "}</text>
|
|
116
|
+
<Show when={inst.endpoint}><text>{ "-> " + inst.endpoint + " "}</text></Show>
|
|
117
|
+
<text fg="red">{"[Stop]"}</text>
|
|
118
|
+
</box>
|
|
119
|
+
)}
|
|
120
|
+
</For>
|
|
121
|
+
</Show>
|
|
122
|
+
</Show>
|
|
123
|
+
|
|
124
|
+
{/* Tab: Hub Search */}
|
|
125
|
+
<Show when={tab() === 2}>
|
|
126
|
+
<text fg="white">{"Search HuggingFace Hub"}</text>
|
|
127
|
+
<box paddingTop={1} />
|
|
128
|
+
<For each={hubResults()}>
|
|
129
|
+
{(r) => (
|
|
130
|
+
<box flexDirection="column" paddingBottom={1}>
|
|
131
|
+
<text fg="cyan">{r.id}</text>
|
|
132
|
+
<text>{`DL: ${r.downloads} | Likes: ${r.likes}${r.pipelineTag ? " | " + r.pipelineTag : ""}`}</text>
|
|
133
|
+
</box>
|
|
134
|
+
)}
|
|
135
|
+
</For>
|
|
136
|
+
</Show>
|
|
137
|
+
|
|
138
|
+
{/* Tab: RAG */}
|
|
139
|
+
<Show when={tab() === 3}>
|
|
140
|
+
<text fg="white">{"RAG Assets"}</text>
|
|
141
|
+
<box paddingTop={1} />
|
|
142
|
+
<text>{"Embedding models, rerankers, and vector databases for RAG pipelines"}</text>
|
|
143
|
+
</Show>
|
|
144
|
+
|
|
145
|
+
{/* Loading indicator */}
|
|
146
|
+
<Show when={loading()}>
|
|
147
|
+
<text fg="yellow">{"Loading..."}</text>
|
|
148
|
+
</Show>
|
|
149
|
+
</box>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -40,7 +40,7 @@ export function SubagentFooter() {
|
|
|
40
40
|
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
|
41
41
|
if (tokens <= 0) return
|
|
42
42
|
|
|
43
|
-
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
|
43
|
+
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models?.[last.modelID]
|
|
44
44
|
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
|
45
45
|
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
|
|
46
46
|
|
|
@@ -11,7 +11,7 @@ export function get(list: Provider[] | ReadonlyMap<string, Provider> | undefined
|
|
|
11
11
|
: Array.isArray(list)
|
|
12
12
|
? list.find((item) => item.id === providerID)
|
|
13
13
|
: undefined
|
|
14
|
-
return provider?.models[modelID]
|
|
14
|
+
return provider?.models?.[modelID]
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function name(
|
|
@@ -228,6 +228,50 @@ export const Info = Schema.Struct({
|
|
|
228
228
|
}),
|
|
229
229
|
}),
|
|
230
230
|
),
|
|
231
|
+
ltm: Schema.optional(
|
|
232
|
+
Schema.Struct({
|
|
233
|
+
enabled: Schema.optional(Schema.Boolean).annotate({
|
|
234
|
+
description: "Enable long-term memory with local embedding (default: false)",
|
|
235
|
+
}),
|
|
236
|
+
embedding_model: Schema.optional(Schema.String).annotate({
|
|
237
|
+
description: "Embedding model ID or 'auto' for VRAM-based selection (default: auto)",
|
|
238
|
+
}),
|
|
239
|
+
vector_store: Schema.optional(Schema.Literals(["filesystem", "qdrant", "chroma"])).annotate({
|
|
240
|
+
description: "Vector store backend (default: filesystem)",
|
|
241
|
+
}),
|
|
242
|
+
max_memories: Schema.optional(PositiveInt).annotate({
|
|
243
|
+
description: "Maximum number of memories to retain (default: 10000)",
|
|
244
|
+
}),
|
|
245
|
+
episodic: Schema.optional(
|
|
246
|
+
Schema.Struct({
|
|
247
|
+
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable episodic (conversation) memory" }),
|
|
248
|
+
summary_interval: Schema.optional(PositiveInt).annotate({ description: "Summarize every N turns (default: 10)" }),
|
|
249
|
+
retain_days: Schema.optional(PositiveInt).annotate({ description: "Days to retain episodic memories (default: 90)" }),
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
semantic: Schema.optional(
|
|
253
|
+
Schema.Struct({
|
|
254
|
+
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable semantic (code) memory" }),
|
|
255
|
+
index_on_file_change: Schema.optional(Schema.Boolean).annotate({ description: "Auto-index on file change (default: true)" }),
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
procedural: Schema.optional(
|
|
259
|
+
Schema.Struct({
|
|
260
|
+
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable procedural (preference) memory" }),
|
|
261
|
+
track_preferences: Schema.optional(Schema.Boolean).annotate({ description: "Track coding style preferences (default: true)" }),
|
|
262
|
+
}),
|
|
263
|
+
),
|
|
264
|
+
retrieval: Schema.optional(
|
|
265
|
+
Schema.Struct({
|
|
266
|
+
top_k: Schema.optional(PositiveInt).annotate({ description: "Number of memories to retrieve (default: 5)" }),
|
|
267
|
+
min_score: Schema.optional(Schema.Number).annotate({ description: "Minimum similarity score 0-1 (default: 0.7)" }),
|
|
268
|
+
max_tokens: Schema.optional(PositiveInt).annotate({ description: "Max tokens to inject into prompt (default: 2000)" }),
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
}),
|
|
272
|
+
).annotate({
|
|
273
|
+
description: "Long-term memory configuration with local embedding model",
|
|
274
|
+
}),
|
|
231
275
|
})
|
|
232
276
|
.annotate({ identifier: "Config" })
|
|
233
277
|
.pipe(
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** LTM — 하드웨어 자동 감지 + 결정론적 LLM 파라미터 계산 */
|
|
2
|
+
|
|
3
|
+
import os from "os"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import * as Log from "@saeeol/core/util/log"
|
|
6
|
+
import { Global } from "@saeeol/core/global"
|
|
7
|
+
import * as GPU from "@/provider/local/gpu"
|
|
8
|
+
import * as RAG from "@/provider/local/rag"
|
|
9
|
+
import type { HardwareProfile, LLMBakeParams, LTMConfig } from "./types"
|
|
10
|
+
|
|
11
|
+
const log = Log.create({ service: "ltm/config" })
|
|
12
|
+
|
|
13
|
+
// ── 기본 LTM 설정 ──
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_LTM_CONFIG: LTMConfig = {
|
|
16
|
+
enabled: false,
|
|
17
|
+
embeddingModel: "auto",
|
|
18
|
+
vectorStore: "filesystem",
|
|
19
|
+
maxMemories: 10000,
|
|
20
|
+
episodic: { enabled: true, summaryInterval: 10, retainDays: 90 },
|
|
21
|
+
semantic: { enabled: true, indexOnFileChange: true },
|
|
22
|
+
procedural: { enabled: true, trackPreferences: true },
|
|
23
|
+
retrieval: { topK: 5, minScore: 0.7, maxTokens: 2000 },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── 하드웨어 프로파일링 ──
|
|
27
|
+
|
|
28
|
+
/** 시스템 하드웨어 정보 수집 */
|
|
29
|
+
export async function profileHardware(): Promise<HardwareProfile> {
|
|
30
|
+
const gpu = await Effect.runPromise(GPU.profile)
|
|
31
|
+
const totalRAMMB = Math.round(os.totalmem() / (1024 * 1024))
|
|
32
|
+
const cpuCores = os.cpus().length
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
gpuCount: gpu.gpus.length,
|
|
36
|
+
totalVRAMMB: gpu.totalVRAMMB,
|
|
37
|
+
availableVRAMMB: gpu.availableVRAMMB,
|
|
38
|
+
totalRAMMB,
|
|
39
|
+
cpuCores,
|
|
40
|
+
cudaAvailable: gpu.cudaAvailable,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 하드웨어 해시 (변경 감지용) */
|
|
45
|
+
export function hardwareHash(hw: HardwareProfile): string {
|
|
46
|
+
const raw = `${hw.gpuCount}|${hw.totalVRAMMB}|${hw.totalRAMMB}|${hw.cpuCores}|${hw.cudaAvailable}`
|
|
47
|
+
let h = 0
|
|
48
|
+
for (let i = 0; i < raw.length; i++) {
|
|
49
|
+
h = ((h << 5) - h + raw.charCodeAt(i)) | 0
|
|
50
|
+
}
|
|
51
|
+
return h.toString(36)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── 임베딩 모델 선택 ──
|
|
55
|
+
|
|
56
|
+
/** VRAM 기반 임베딩 모델 자동 선택 */
|
|
57
|
+
export function selectEmbeddingModel(hw: HardwareProfile) {
|
|
58
|
+
const vram = hw.availableVRAMMB
|
|
59
|
+
// 한국어 지원 모델 우선
|
|
60
|
+
if (vram >= 3000 || hw.totalRAMMB >= 16000) return RAG.EMBEDDING_MODELS[3] // bge-m3, 1024d, 2.2GB
|
|
61
|
+
if (vram >= 1500 || hw.totalRAMMB >= 8000) return RAG.EMBEDDING_MODELS[4] // nomic-embed, 768d, 550MB
|
|
62
|
+
if (vram >= 500) return RAG.EMBEDDING_MODELS[0] // bge-small-en, 384d, 130MB
|
|
63
|
+
return RAG.EMBEDDING_MODELS[5] // all-minilm-l6, 384d, 80MB
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Bake (결정론적 파라미터 계산) ──
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 하드웨어 기반 LLM 파라미터를 계산하고 파일에 저장.
|
|
70
|
+
* 한 번 계산되면 하드웨어가 바뀌지 않는 한 동일한 값 유지.
|
|
71
|
+
*/
|
|
72
|
+
export async function bake(hw: HardwareProfile): Promise<LLMBakeParams> {
|
|
73
|
+
const existing = await readBake()
|
|
74
|
+
const hash = hardwareHash(hw)
|
|
75
|
+
|
|
76
|
+
// 해시가 같으면 기존 파라미터 재사용 (결정론적)
|
|
77
|
+
if (existing && existing.hardwareHash === hash) {
|
|
78
|
+
log.info("bake: hardware unchanged, reusing existing params", { hash })
|
|
79
|
+
return existing
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const model = selectEmbeddingModel(hw)
|
|
83
|
+
const params: LLMBakeParams = {
|
|
84
|
+
embeddingModel: model.id,
|
|
85
|
+
embeddingDimensions: model.dimensions,
|
|
86
|
+
embeddingVRAMMB: Math.ceil(model.sizeBytes / (1024 * 1024)),
|
|
87
|
+
contextLength: hw.cudaAvailable ? 8192 : 4096,
|
|
88
|
+
batchSize: hw.cudaAvailable ? 64 : 16,
|
|
89
|
+
numThread: Math.max(1, Math.floor(hw.cpuCores * 0.75)),
|
|
90
|
+
numGPU: hw.cudaAvailable ? -1 : 0,
|
|
91
|
+
hardwareHash: hash,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await writeBake(params)
|
|
95
|
+
log.info("bake: computed new params", { hash, model: model.id })
|
|
96
|
+
return params
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── 영속화 ──
|
|
100
|
+
|
|
101
|
+
import path from "path"
|
|
102
|
+
import { mkdir, readFile, writeFile } from "fs/promises"
|
|
103
|
+
|
|
104
|
+
function bakeDir(): string {
|
|
105
|
+
return path.join(Global.Path.data, "ltm")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function bakePath(): string {
|
|
109
|
+
return path.join(bakeDir(), "bake.json")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readBake(): Promise<LLMBakeParams | undefined> {
|
|
113
|
+
try {
|
|
114
|
+
const raw = await readFile(bakePath(), "utf-8")
|
|
115
|
+
return JSON.parse(raw) as LLMBakeParams
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function writeBake(params: LLMBakeParams): Promise<void> {
|
|
122
|
+
await mkdir(bakeDir(), { recursive: true })
|
|
123
|
+
await writeFile(bakePath(), JSON.stringify(params, null, 2))
|
|
124
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** LTM — BusEvent 정의 */
|
|
2
|
+
|
|
3
|
+
import { Schema } from "effect"
|
|
4
|
+
import * as BusEvent from "@/bus/bus-event"
|
|
5
|
+
|
|
6
|
+
export const LTMEvent = {
|
|
7
|
+
MemoryStored: BusEvent.define(
|
|
8
|
+
"ltm.memory.stored",
|
|
9
|
+
Schema.Struct({
|
|
10
|
+
id: Schema.String,
|
|
11
|
+
type: Schema.String,
|
|
12
|
+
source: Schema.String,
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
|
|
16
|
+
MemoryPruned: BusEvent.define(
|
|
17
|
+
"ltm.memory.pruned",
|
|
18
|
+
Schema.Struct({
|
|
19
|
+
count: Schema.Number,
|
|
20
|
+
type: Schema.optional(Schema.String),
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
|
|
24
|
+
IndexingProgress: BusEvent.define(
|
|
25
|
+
"ltm.indexing.progress",
|
|
26
|
+
Schema.Struct({
|
|
27
|
+
processed: Schema.Number,
|
|
28
|
+
total: Schema.Number,
|
|
29
|
+
type: Schema.String,
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
|
|
33
|
+
EmbedderStatusChanged: BusEvent.define(
|
|
34
|
+
"ltm.embedder.status",
|
|
35
|
+
Schema.Struct({
|
|
36
|
+
status: Schema.String,
|
|
37
|
+
model: Schema.optional(Schema.String),
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
|
|
41
|
+
HardwareProfiled: BusEvent.define(
|
|
42
|
+
"ltm.hardware.profiled",
|
|
43
|
+
Schema.Struct({
|
|
44
|
+
gpuCount: Schema.Number,
|
|
45
|
+
totalVRAMMB: Schema.Number,
|
|
46
|
+
totalRAMMB: Schema.Number,
|
|
47
|
+
cpuCores: Schema.Number,
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
}
|
package/src/ltm/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** LTM — 장기 기억 공개 API */
|
|
2
|
+
|
|
3
|
+
export * from "./types"
|
|
4
|
+
export * as Config from "./config"
|
|
5
|
+
export * as Store from "./store"
|
|
6
|
+
export * as Pipeline from "./pipeline"
|
|
7
|
+
export * as Retrieval from "./retrieval"
|
|
8
|
+
export * as Scheduler from "./scheduler"
|
|
9
|
+
export * as Episodic from "./memory/episodic"
|
|
10
|
+
export * as Semantic from "./memory/semantic"
|
|
11
|
+
export * as Procedural from "./memory/procedural"
|
|
12
|
+
export { LTMEvent } from "./events"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** LTM episodic memory — conversation summaries (English, LLM-to-LLM) */
|
|
2
|
+
|
|
3
|
+
import * as Log from "@saeeol/core/util/log"
|
|
4
|
+
import * as Embedder from "@/provider/local/embedder"
|
|
5
|
+
import type { Memory } from "@/ltm/types"
|
|
6
|
+
|
|
7
|
+
const log = Log.create({ service: "ltm/memory/episodic" })
|
|
8
|
+
|
|
9
|
+
/** Create an episodic memory from a conversation exchange */
|
|
10
|
+
export async function fromConversation(
|
|
11
|
+
sessionID: string,
|
|
12
|
+
projectID: string | undefined,
|
|
13
|
+
userMsg: string,
|
|
14
|
+
assistantMsg: string,
|
|
15
|
+
): Promise<Memory | undefined> {
|
|
16
|
+
if (assistantMsg.length < 50) return undefined
|
|
17
|
+
|
|
18
|
+
const summary = summarize(userMsg, assistantMsg)
|
|
19
|
+
if (!summary) return undefined
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const vector = await Embedder.embedOne(summary)
|
|
23
|
+
return {
|
|
24
|
+
id: `epi:${sessionID}:${Date.now()}`,
|
|
25
|
+
type: "episodic",
|
|
26
|
+
content: assistantMsg.slice(0, 500),
|
|
27
|
+
summary,
|
|
28
|
+
vector,
|
|
29
|
+
metadata: {
|
|
30
|
+
source: `session:${sessionID}`,
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
projectID,
|
|
33
|
+
sessionID,
|
|
34
|
+
tags: extractTags(userMsg + " " + assistantMsg),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
} catch (e) {
|
|
38
|
+
log.error("failed to create episodic memory", { error: e })
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a rule-based summary (no LLM call) */
|
|
44
|
+
function summarize(user: string, assistant: string): string {
|
|
45
|
+
const userBrief = user.slice(0, 120).replace(/\n/g, " ").trim()
|
|
46
|
+
const assistantBrief = assistant.slice(0, 200).replace(/\n/g, " ").trim()
|
|
47
|
+
return `user asked: ${userBrief}\nassistant did: ${assistantBrief}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Extract topic tags from message content */
|
|
51
|
+
function extractTags(msg: string): string[] {
|
|
52
|
+
const tags: string[] = []
|
|
53
|
+
const lower = msg.toLowerCase()
|
|
54
|
+
|
|
55
|
+
const patterns: Array<{ keyword: string; tag: string }> = [
|
|
56
|
+
{ keyword: "bug", tag: "bug" },
|
|
57
|
+
{ keyword: "fix", tag: "fix" },
|
|
58
|
+
{ keyword: "refactor", tag: "refactor" },
|
|
59
|
+
{ keyword: "test", tag: "test" },
|
|
60
|
+
{ keyword: "config", tag: "config" },
|
|
61
|
+
{ keyword: "api", tag: "api" },
|
|
62
|
+
{ keyword: "auth", tag: "auth" },
|
|
63
|
+
{ keyword: "deploy", tag: "deploy" },
|
|
64
|
+
{ keyword: "error", tag: "error" },
|
|
65
|
+
{ keyword: "crash", tag: "crash" },
|
|
66
|
+
{ keyword: "performance", tag: "perf" },
|
|
67
|
+
{ keyword: "security", tag: "security" },
|
|
68
|
+
{ keyword: "database", tag: "database" },
|
|
69
|
+
{ keyword: "migration", tag: "migration" },
|
|
70
|
+
{ keyword: "feature", tag: "feature" },
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
for (const { keyword, tag } of patterns) {
|
|
74
|
+
if (lower.includes(keyword) && !tags.includes(tag)) tags.push(tag)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return tags
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Check if summarization should trigger at this turn count */
|
|
81
|
+
export function shouldSummarize(turnCount: number, interval: number): boolean {
|
|
82
|
+
return turnCount > 0 && turnCount % interval === 0
|
|
83
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** LTM procedural memory — user coding preferences and patterns (English, LLM-to-LLM) */
|
|
2
|
+
|
|
3
|
+
import * as Log from "@saeeol/core/util/log"
|
|
4
|
+
import * as Embedder from "@/provider/local/embedder"
|
|
5
|
+
import type { Memory } from "@/ltm/types"
|
|
6
|
+
|
|
7
|
+
const log = Log.create({ service: "ltm/memory/procedural" })
|
|
8
|
+
|
|
9
|
+
/** A detected coding style signal */
|
|
10
|
+
interface StyleSignal {
|
|
11
|
+
language: string
|
|
12
|
+
pattern: string
|
|
13
|
+
evidence: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Extract coding style signals from a file edit */
|
|
17
|
+
export function extractStyleSignals(
|
|
18
|
+
filePath: string,
|
|
19
|
+
content: string,
|
|
20
|
+
): StyleSignal[] {
|
|
21
|
+
const signals: StyleSignal[] = []
|
|
22
|
+
const ext = filePath.split(".").pop() ?? ""
|
|
23
|
+
|
|
24
|
+
// Comment language
|
|
25
|
+
if (content.includes("//") && (ext === "ts" || ext === "js")) {
|
|
26
|
+
const hasKoreanComment = /[ㄱ-ㅎㅏ-ㅣ가-힣]/.test(content)
|
|
27
|
+
signals.push({
|
|
28
|
+
language: ext,
|
|
29
|
+
pattern: "comment-language",
|
|
30
|
+
evidence: hasKoreanComment ? "comments in Korean" : "comments in English",
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Indentation
|
|
35
|
+
if (content.includes(" ") && !content.includes("\t")) {
|
|
36
|
+
signals.push({ language: ext, pattern: "indent", evidence: "2-space indentation" })
|
|
37
|
+
} else if (content.includes("\t")) {
|
|
38
|
+
signals.push({ language: ext, pattern: "indent", evidence: "tab indentation" })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Semicolons
|
|
42
|
+
if (ext === "ts" || ext === "js") {
|
|
43
|
+
const hasSemicolons = /;\s*\n/.test(content)
|
|
44
|
+
signals.push({
|
|
45
|
+
language: ext,
|
|
46
|
+
pattern: "semicolons",
|
|
47
|
+
evidence: hasSemicolons ? "uses semicolons" : "no semicolons",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Quote style
|
|
52
|
+
const singleQuotes = (content.match(/'/g) ?? []).length
|
|
53
|
+
const doubleQuotes = (content.match(/"/g) ?? []).length
|
|
54
|
+
if (singleQuotes > doubleQuotes * 2) {
|
|
55
|
+
signals.push({ language: ext, pattern: "quotes", evidence: "prefers single quotes" })
|
|
56
|
+
} else if (doubleQuotes > singleQuotes * 2) {
|
|
57
|
+
signals.push({ language: ext, pattern: "quotes", evidence: "prefers double quotes" })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Naming: camelCase vs snake_case
|
|
61
|
+
const camelCase = (content.match(/[a-z][A-Z]/g) ?? []).length
|
|
62
|
+
const snakeCase = (content.match(/_[a-z]/g) ?? []).length
|
|
63
|
+
if (camelCase > snakeCase * 3) {
|
|
64
|
+
signals.push({ language: ext, pattern: "naming", evidence: "camelCase naming" })
|
|
65
|
+
} else if (snakeCase > camelCase * 3) {
|
|
66
|
+
signals.push({ language: ext, pattern: "naming", evidence: "snake_case naming" })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return signals
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Convert style signals into procedural memories */
|
|
73
|
+
export async function fromStyleSignals(
|
|
74
|
+
projectID: string | undefined,
|
|
75
|
+
signals: StyleSignal[],
|
|
76
|
+
): Promise<Memory[]> {
|
|
77
|
+
const memories: Memory[] = []
|
|
78
|
+
|
|
79
|
+
for (const signal of signals) {
|
|
80
|
+
const summary = `user coding style: ${signal.language} ${signal.pattern} = ${signal.evidence}`
|
|
81
|
+
try {
|
|
82
|
+
const vector = await Embedder.embedOne(summary)
|
|
83
|
+
memories.push({
|
|
84
|
+
id: `proc:style:${signal.language}:${signal.pattern}`,
|
|
85
|
+
type: "procedural",
|
|
86
|
+
content: signal.evidence,
|
|
87
|
+
summary,
|
|
88
|
+
vector,
|
|
89
|
+
metadata: {
|
|
90
|
+
source: `style:${signal.language}`,
|
|
91
|
+
timestamp: Date.now(),
|
|
92
|
+
projectID,
|
|
93
|
+
tags: ["style", signal.language, signal.pattern],
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
} catch (e) {
|
|
97
|
+
log.error("failed to create procedural memory", { error: e, pattern: signal.pattern })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return memories
|
|
102
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** LTM semantic memory — code and project knowledge (English, LLM-to-LLM) */
|
|
2
|
+
|
|
3
|
+
import * as Log from "@saeeol/core/util/log"
|
|
4
|
+
import * as Embedder from "@/provider/local/embedder"
|
|
5
|
+
import type { Memory } from "@/ltm/types"
|
|
6
|
+
|
|
7
|
+
const log = Log.create({ service: "ltm/memory/semantic" })
|
|
8
|
+
|
|
9
|
+
/** Create a semantic memory from a code chunk */
|
|
10
|
+
export async function fromCodeChunk(
|
|
11
|
+
projectID: string,
|
|
12
|
+
filePath: string,
|
|
13
|
+
chunk: string,
|
|
14
|
+
startLine: number,
|
|
15
|
+
endLine: number,
|
|
16
|
+
): Promise<Memory | undefined> {
|
|
17
|
+
if (chunk.length < 20) return undefined
|
|
18
|
+
|
|
19
|
+
const summary = codeSummary(filePath, chunk, startLine, endLine)
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const vector = await Embedder.embedOne(summary)
|
|
23
|
+
return {
|
|
24
|
+
id: `sem:${projectID}:${filePath}:${startLine}-${endLine}`,
|
|
25
|
+
type: "semantic",
|
|
26
|
+
content: chunk.slice(0, 500),
|
|
27
|
+
summary,
|
|
28
|
+
vector,
|
|
29
|
+
metadata: {
|
|
30
|
+
source: `file:${filePath}`,
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
projectID,
|
|
33
|
+
tags: [pathExtension(filePath), "code"],
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
log.error("failed to create semantic memory", { error: e, filePath })
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Create a semantic memory from project structure overview */
|
|
43
|
+
export async function fromProjectStructure(
|
|
44
|
+
projectID: string,
|
|
45
|
+
structure: string,
|
|
46
|
+
): Promise<Memory | undefined> {
|
|
47
|
+
const summary = `project structure: ${structure.slice(0, 300)}`
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const vector = await Embedder.embedOne(summary)
|
|
51
|
+
return {
|
|
52
|
+
id: `sem:${projectID}:structure`,
|
|
53
|
+
type: "semantic",
|
|
54
|
+
content: structure.slice(0, 500),
|
|
55
|
+
summary,
|
|
56
|
+
vector,
|
|
57
|
+
metadata: {
|
|
58
|
+
source: `project:${projectID}`,
|
|
59
|
+
timestamp: Date.now(),
|
|
60
|
+
projectID,
|
|
61
|
+
tags: ["structure"],
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log.error("failed to create project structure memory", { error: e })
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a code chunk summary for embedding */
|
|
71
|
+
function codeSummary(filePath: string, chunk: string, start: number, end: number): string {
|
|
72
|
+
const ext = pathExtension(filePath)
|
|
73
|
+
const preview = chunk.slice(0, 150).replace(/\n/g, " ").trim()
|
|
74
|
+
return `${filePath}:${start}-${end} (${ext}) ${preview}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pathExtension(filePath: string): string {
|
|
78
|
+
const parts = filePath.split(".")
|
|
79
|
+
return parts.length > 1 ? parts[parts.length - 1]! : "unknown"
|
|
80
|
+
}
|