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