saeeol 1.2.3 → 1.2.5
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/bin/saeeol.cjs +25 -8
- package/package.json +1 -1
- package/src/ltm/pipeline.ts +103 -1
- package/src/ltm/scheduler.ts +86 -12
- package/src/ltm/types.ts +1 -1
- package/src/provider/local/embedder.ts +3 -3
- package/test/ltm/ltm.test.ts +230 -0
- package/test/server/contract.test.ts +231 -0
package/bin/saeeol.cjs
CHANGED
|
@@ -175,13 +175,30 @@ function findBinary(startDir) {
|
|
|
175
175
|
}
|
|
176
176
|
|
|
177
177
|
const resolved = findBinary(scriptDir)
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
"It seems that your package manager failed to install the right version of the SAEEOL CLI for your platform. You can try manually installing " +
|
|
181
|
-
names.map((n) => `\"${n}\"`).join(" or ") +
|
|
182
|
-
" package",
|
|
183
|
-
)
|
|
184
|
-
process.exit(1)
|
|
178
|
+
if (resolved) {
|
|
179
|
+
run(resolved)
|
|
185
180
|
}
|
|
186
181
|
|
|
187
|
-
run
|
|
182
|
+
// Fallback: run source with bun if available
|
|
183
|
+
const srcDir = path.join(scriptDir, "..", "src")
|
|
184
|
+
const indexPath = path.join(srcDir, "index.ts")
|
|
185
|
+
if (fs.existsSync(indexPath)) {
|
|
186
|
+
const bunExe = process.platform === "win32" ? "bun.exe" : "bun"
|
|
187
|
+
try {
|
|
188
|
+
const which = childProcess.spawnSync(bunExe, ["--version"], { encoding: "utf8", timeout: 3000 })
|
|
189
|
+
if (which.status === 0) {
|
|
190
|
+
const result = childProcess.spawnSync(bunExe, [
|
|
191
|
+
"run", "--conditions=browser", indexPath, ...process.argv.slice(2)
|
|
192
|
+
], { stdio: "inherit" })
|
|
193
|
+
const code = typeof result.status === "number" ? result.status : 0
|
|
194
|
+
process.exit(code)
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
console.error(
|
|
200
|
+
"It seems that your package manager failed to install the right version of the SAEEOL CLI for your platform. You can try manually installing " +
|
|
201
|
+
names.map((n) => `\"${n}\"`).join(" or ") +
|
|
202
|
+
" package, or install bun (https://bun.sh) to run from source",
|
|
203
|
+
)
|
|
204
|
+
process.exit(1)
|
package/package.json
CHANGED
package/src/ltm/pipeline.ts
CHANGED
|
@@ -11,11 +11,13 @@ import * as Procedural from "@/ltm/memory/procedural"
|
|
|
11
11
|
import * as Scheduler from "@/ltm/scheduler"
|
|
12
12
|
import type { LTMConfig, LLMBakeParams } from "@/ltm/types"
|
|
13
13
|
import { LTMEvent } from "@/ltm/events"
|
|
14
|
+
import { Event as SessionEvent } from "@/session/core/session-types"
|
|
14
15
|
|
|
15
16
|
const log = Log.create({ service: "ltm/pipeline" })
|
|
16
17
|
|
|
17
18
|
let running = false
|
|
18
19
|
let config: LTMConfig | undefined
|
|
20
|
+
let unsubscribers: Array<() => void> = []
|
|
19
21
|
|
|
20
22
|
// ── 파이프라인 수명 ──
|
|
21
23
|
|
|
@@ -57,6 +59,9 @@ export async function start(cfg: LTMConfig, bake: LLMBakeParams): Promise<void>
|
|
|
57
59
|
log.info("trimmed memories to max", { removed: toRemove.length })
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// BusEvent 구독 바인딩
|
|
63
|
+
bindSubscriptions()
|
|
64
|
+
|
|
60
65
|
log.info("pipeline started", { memoryCount: await Store.count() })
|
|
61
66
|
}
|
|
62
67
|
|
|
@@ -64,6 +69,13 @@ export async function start(cfg: LTMConfig, bake: LLMBakeParams): Promise<void>
|
|
|
64
69
|
export async function stop(): Promise<void> {
|
|
65
70
|
if (!running) return
|
|
66
71
|
running = false
|
|
72
|
+
|
|
73
|
+
// 구독 해제
|
|
74
|
+
for (const unsub of unsubscribers) {
|
|
75
|
+
try { unsub() } catch { /* ignore */ }
|
|
76
|
+
}
|
|
77
|
+
unsubscribers = []
|
|
78
|
+
|
|
67
79
|
await Embedder.stop()
|
|
68
80
|
config = undefined
|
|
69
81
|
log.info("pipeline stopped")
|
|
@@ -74,7 +86,90 @@ export function isActive(): boolean {
|
|
|
74
86
|
return running
|
|
75
87
|
}
|
|
76
88
|
|
|
77
|
-
// ──
|
|
89
|
+
// ── BusEvent 구독 바인딩 ──
|
|
90
|
+
|
|
91
|
+
function bindSubscriptions() {
|
|
92
|
+
if (!config) return
|
|
93
|
+
|
|
94
|
+
// 1. 세션 diff → 에피소드 기억 (코드 변경 후 AI 응답 완료 시)
|
|
95
|
+
const unsubDiff = Bus.subscribe(SessionEvent.Diff, async (event) => {
|
|
96
|
+
if (!running || !config?.episodic.enabled) return
|
|
97
|
+
|
|
98
|
+
const sessionID = event.properties.sessionID
|
|
99
|
+
// diff 이벤트는 assistant 응답 후 세션 상태가 업데이트될 때 발생
|
|
100
|
+
// 사용자 메시지와 assistant 응답을 모두 포함할 수 있는 컨텍스트로 처리
|
|
101
|
+
const diffs = event.properties.diff
|
|
102
|
+
if (!diffs || diffs.length === 0) return
|
|
103
|
+
|
|
104
|
+
const summary = `code changes: ${diffs.map((d) => `${d.filePath} (${d.status})`).join(", ")}`
|
|
105
|
+
try {
|
|
106
|
+
const vector = await Embedder.embedOne(summary)
|
|
107
|
+
const memory = {
|
|
108
|
+
id: `epi:${sessionID}:${Date.now()}`,
|
|
109
|
+
type: "episodic" as const,
|
|
110
|
+
content: summary.slice(0, 500),
|
|
111
|
+
summary,
|
|
112
|
+
vector,
|
|
113
|
+
metadata: {
|
|
114
|
+
source: `session:${sessionID}`,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
sessionID,
|
|
117
|
+
tags: ["code-change"],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
await Store.upsert(memory)
|
|
121
|
+
void Bus.publish(LTMEvent.MemoryStored, {
|
|
122
|
+
id: memory.id,
|
|
123
|
+
type: memory.type,
|
|
124
|
+
source: memory.metadata.source,
|
|
125
|
+
})
|
|
126
|
+
} catch (e) {
|
|
127
|
+
log.error("failed to store session diff memory", { error: e })
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
unsubscribers.push(unsubDiff)
|
|
131
|
+
|
|
132
|
+
// 2. 세션 에러 → 에피소드 기억 (문제/해결 추적)
|
|
133
|
+
const unsubError = Bus.subscribe(SessionEvent.Error, async (event) => {
|
|
134
|
+
if (!running || !config?.episodic.enabled) return
|
|
135
|
+
const sessionID = event.properties.sessionID
|
|
136
|
+
if (!sessionID) return
|
|
137
|
+
|
|
138
|
+
const err = event.properties.error
|
|
139
|
+
if (!err) return
|
|
140
|
+
|
|
141
|
+
const summary = `error in session: ${typeof err === "object" ? JSON.stringify(err).slice(0, 200) : String(err).slice(0, 200)}`
|
|
142
|
+
try {
|
|
143
|
+
const vector = await Embedder.embedOne(summary)
|
|
144
|
+
const memory = {
|
|
145
|
+
id: `epi:err:${sessionID}:${Date.now()}`,
|
|
146
|
+
type: "episodic" as const,
|
|
147
|
+
content: summary.slice(0, 500),
|
|
148
|
+
summary,
|
|
149
|
+
vector,
|
|
150
|
+
metadata: {
|
|
151
|
+
source: `session:${sessionID}`,
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
sessionID,
|
|
154
|
+
tags: ["error"],
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
await Store.upsert(memory)
|
|
158
|
+
void Bus.publish(LTMEvent.MemoryStored, {
|
|
159
|
+
id: memory.id,
|
|
160
|
+
type: memory.type,
|
|
161
|
+
source: memory.metadata.source,
|
|
162
|
+
})
|
|
163
|
+
} catch (e) {
|
|
164
|
+
log.error("failed to store error memory", { error: e })
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
unsubscribers.push(unsubError)
|
|
168
|
+
|
|
169
|
+
log.info("bus subscriptions bound", { count: unsubscribers.length })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── 직접 호출 핸들러 (외부에서 명시적으로 호출) ──
|
|
78
173
|
|
|
79
174
|
/** 대화 메시지 → 에피소드 기억 */
|
|
80
175
|
export async function onMessageCompleted(
|
|
@@ -104,6 +199,13 @@ export async function onFileChanged(
|
|
|
104
199
|
): Promise<void> {
|
|
105
200
|
if (!running || !config?.semantic.enabled || !config.semantic.indexOnFileChange) return
|
|
106
201
|
|
|
202
|
+
// VRAM 스케줄러 확인 — 임베딩 작업 가능한지
|
|
203
|
+
const canRun = await Scheduler.canRunEmbedding()
|
|
204
|
+
if (!canRun) {
|
|
205
|
+
log.info("skipping file indexing — VRAM budget exceeded", { filePath })
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
107
209
|
// 파일을 청크로 분할 (간단한 줄 기반)
|
|
108
210
|
const lines = content.split("\n")
|
|
109
211
|
const chunkSize = 50
|
package/src/ltm/scheduler.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface Allocation {
|
|
|
15
15
|
available: number
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** 스케줄링 전략 */
|
|
19
|
+
export type Strategy = "concurrent" | "alternating" | "cpu-fallback" | "no-gpu"
|
|
20
|
+
|
|
21
|
+
// ── VRAM 게이지 ──
|
|
22
|
+
|
|
18
23
|
/** 현재 VRAM 할당 상태 조회 */
|
|
19
24
|
export async function allocation(): Promise<Allocation> {
|
|
20
25
|
const gpu = await Effect.runPromise(GPU.profile)
|
|
@@ -26,30 +31,99 @@ export async function allocation(): Promise<Allocation> {
|
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
|
|
34
|
+
/** 스케줄링 전략 반환 */
|
|
35
|
+
export async function strategy(): Promise<Strategy> {
|
|
36
|
+
const gpu = await Effect.runPromise(GPU.profile)
|
|
37
|
+
if (!gpu.cudaAvailable) return "no-gpu"
|
|
38
|
+
if (gpu.totalVRAMMB >= 16384) return "concurrent"
|
|
39
|
+
if (gpu.totalVRAMMB >= 8192) return "alternating"
|
|
40
|
+
return "cpu-fallback"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── 실행 판정 ──
|
|
44
|
+
|
|
29
45
|
/** 임베딩 백그라운드 작업 실행 가능한지 판단 */
|
|
30
46
|
export async function canRunEmbedding(): Promise<boolean> {
|
|
47
|
+
const strat = await strategy()
|
|
48
|
+
// no-gpu나 cpu-fallback에서는 항상 가능 (CPU 처리)
|
|
49
|
+
if (strat === "no-gpu" || strat === "cpu-fallback") return true
|
|
50
|
+
// concurrent에서는 항상 가능
|
|
51
|
+
if (strat === "concurrent") return true
|
|
52
|
+
// alternating: VRAM 최소 2GB 필요
|
|
31
53
|
const alloc = await allocation()
|
|
32
|
-
// 최소 2GB VRAM 필요
|
|
33
54
|
return alloc.available >= 2048
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
/** LLM과 임베딩 동시 실행 가능한지 판단 */
|
|
37
58
|
export async function canRunConcurrent(hw: HardwareProfile): Promise<boolean> {
|
|
38
|
-
// 16GB 이상 VRAM이면 항상 동시 실행 가능
|
|
39
59
|
if (hw.totalVRAMMB >= 16384) return true
|
|
40
|
-
// 8GB 이상이면 임베딩 모델이 1GB 이하일 때 가능
|
|
41
60
|
if (hw.totalVRAMMB >= 8192 && Embedder.vramUsage() <= 1024) return true
|
|
42
|
-
// 그 외는 교대 실행
|
|
43
61
|
return false
|
|
44
62
|
}
|
|
45
63
|
|
|
46
|
-
|
|
47
|
-
export type Strategy = "concurrent" | "alternating" | "cpu-fallback" | "no-gpu"
|
|
64
|
+
// ── LLM ↔ 임베딩 교대 실행 ──
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
/** LLM이 VRAM을 요청할 때 호출 — 필요시 임베딩 일시정지 */
|
|
67
|
+
export async function requestLLM(vramNeededMB: number): Promise<boolean> {
|
|
68
|
+
const strat = await strategy()
|
|
69
|
+
|
|
70
|
+
// concurrent 전략: 둘 다 실행 가능
|
|
71
|
+
if (strat === "concurrent") return true
|
|
72
|
+
// no-gpu: GPU 필요 없음
|
|
73
|
+
if (strat === "no-gpu") return true
|
|
74
|
+
|
|
75
|
+
const alloc = await allocation()
|
|
76
|
+
|
|
77
|
+
// 충분한 VRAM이 있으면 그대로 진행
|
|
78
|
+
if (alloc.available >= vramNeededMB) {
|
|
79
|
+
log.info("LLM request: enough VRAM", { available: alloc.available, needed: vramNeededMB })
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// alternating 전략: 임베딩 서버를 일시정지하여 VRAM 확보
|
|
84
|
+
if (strat === "alternating" && alloc.embedding > 0) {
|
|
85
|
+
log.info("LLM request: pausing embedding to free VRAM", {
|
|
86
|
+
embedding: alloc.embedding,
|
|
87
|
+
needed: vramNeededMB,
|
|
88
|
+
})
|
|
89
|
+
await Embedder.stop()
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// cpu-fallback: 임베딩은 이미 CPU에서 실행 중, GPU는 LLM 전용
|
|
94
|
+
if (strat === "cpu-fallback") return true
|
|
95
|
+
|
|
96
|
+
log.warn("LLM request: insufficient VRAM", { available: alloc.available, needed: vramNeededMB })
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 임베딩 재시작 — LLM 유휴 시 호출 */
|
|
101
|
+
export async function resumeEmbedding(bake: {
|
|
102
|
+
embeddingModel: string
|
|
103
|
+
embeddingDimensions: number
|
|
104
|
+
}): Promise<void> {
|
|
105
|
+
const current = Embedder.status()
|
|
106
|
+
if (current?.status === "running") return
|
|
107
|
+
|
|
108
|
+
const canRun = await canRunEmbedding()
|
|
109
|
+
if (!canRun) {
|
|
110
|
+
log.info("embedder resume skipped — VRAM budget exceeded")
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
log.info("resuming embedding server after LLM idle")
|
|
115
|
+
try {
|
|
116
|
+
await Embedder.start({
|
|
117
|
+
embeddingModel: bake.embeddingModel,
|
|
118
|
+
embeddingDimensions: bake.embeddingDimensions,
|
|
119
|
+
embeddingVRAMMB: 0,
|
|
120
|
+
contextLength: 4096,
|
|
121
|
+
batchSize: 16,
|
|
122
|
+
numThread: 4,
|
|
123
|
+
numGPU: 0,
|
|
124
|
+
hardwareHash: "",
|
|
125
|
+
})
|
|
126
|
+
} catch (e) {
|
|
127
|
+
log.error("failed to resume embedding server", { error: e })
|
|
128
|
+
}
|
|
55
129
|
}
|
package/src/ltm/types.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type EmbedderStatus = Schema.Schema.Type<typeof EmbedderStatus>
|
|
|
38
38
|
export const EmbeddingServer = Schema.Struct({
|
|
39
39
|
id: Schema.String,
|
|
40
40
|
model: Schema.String,
|
|
41
|
-
status: EmbedderStatus,
|
|
41
|
+
status: Schema.mutable(EmbedderStatus),
|
|
42
42
|
endpoint: Schema.String,
|
|
43
43
|
dimensions: Schema.Number,
|
|
44
44
|
vramMB: Schema.Number,
|
|
@@ -151,7 +151,7 @@ export async function start(bake: LLMBakeParams): Promise<EmbeddingServer> {
|
|
|
151
151
|
const running = await isOllamaRunning(endpoint)
|
|
152
152
|
if (!running) {
|
|
153
153
|
log.warn("Ollama not running, embedding server unavailable", { endpoint })
|
|
154
|
-
server
|
|
154
|
+
server = { ...server, status: "error" }
|
|
155
155
|
void Bus.publish(LTMEvent.EmbedderStatusChanged, { status: "error", model: bake.embeddingModel })
|
|
156
156
|
return server
|
|
157
157
|
}
|
|
@@ -173,13 +173,13 @@ export async function start(bake: LLMBakeParams): Promise<EmbeddingServer> {
|
|
|
173
173
|
log.info("model warmed up", { model: ollamaModel })
|
|
174
174
|
} catch (e) {
|
|
175
175
|
log.error("warmup failed", { model: ollamaModel, error: e })
|
|
176
|
-
server
|
|
176
|
+
server = { ...server, status: "error" }
|
|
177
177
|
void Bus.publish(LTMEvent.EmbedderStatusChanged, { status: "error", model: bake.embeddingModel })
|
|
178
178
|
return server
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
182
|
+
server = { ...server, status: "running" }
|
|
183
183
|
void Bus.publish(LTMEvent.EmbedderStatusChanged, { status: "running", model: bake.embeddingModel })
|
|
184
184
|
log.info("embedding server started", { model: bake.embeddingModel, dimensions: bake.embeddingDimensions })
|
|
185
185
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** LTM unit tests — store, retrieval, embedder cycle */
|
|
2
|
+
|
|
3
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import { mkdir, rm } from "fs/promises"
|
|
6
|
+
import * as Store from "@/ltm/store"
|
|
7
|
+
import * as Retrieval from "@/ltm/retrieval"
|
|
8
|
+
import type { Memory, LTMConfig } from "@/ltm/types"
|
|
9
|
+
import * as Episodic from "@/ltm/memory/episodic"
|
|
10
|
+
import * as Procedural from "@/ltm/memory/procedural"
|
|
11
|
+
|
|
12
|
+
// ── Mock embedder — Ollama 없이 테스트 ──
|
|
13
|
+
|
|
14
|
+
const mockVectors = new Map<string, number[]>()
|
|
15
|
+
|
|
16
|
+
/** 간단한 mock 임베딩 (텍스트 해시 기반) */
|
|
17
|
+
function mockEmbed(text: string): number[] {
|
|
18
|
+
if (mockVectors.has(text)) return mockVectors.get(text)!
|
|
19
|
+
const vector = Array.from({ length: 384 }, (_, i) => {
|
|
20
|
+
const code = text.charCodeAt(i % text.length) || 0
|
|
21
|
+
return Math.sin(code * (i + 1)) * 0.5 + 0.5
|
|
22
|
+
})
|
|
23
|
+
mockVectors.set(text, vector)
|
|
24
|
+
return vector
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 테스트 설정
|
|
28
|
+
const testConfig: LTMConfig = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
embeddingModel: "mock",
|
|
31
|
+
vectorStore: "filesystem",
|
|
32
|
+
maxMemories: 100,
|
|
33
|
+
episodic: { enabled: true, summaryInterval: 5, retainDays: 90 },
|
|
34
|
+
semantic: { enabled: true, indexOnFileChange: true },
|
|
35
|
+
procedural: { enabled: true, trackPreferences: true },
|
|
36
|
+
retrieval: { topK: 5, minScore: 0.5, maxTokens: 2000 },
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Store 테스트 ──
|
|
40
|
+
|
|
41
|
+
describe("LTM Store", () => {
|
|
42
|
+
test("upsert and search", async () => {
|
|
43
|
+
const vector = mockEmbed("hello world test")
|
|
44
|
+
const memory: Memory = {
|
|
45
|
+
id: "test:1",
|
|
46
|
+
type: "episodic",
|
|
47
|
+
content: "test content about hello world",
|
|
48
|
+
summary: "hello world test",
|
|
49
|
+
vector,
|
|
50
|
+
metadata: {
|
|
51
|
+
source: "test:unit",
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
tags: ["test"],
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await Store.upsert(memory)
|
|
58
|
+
const results = await Store.search(vector, { topK: 5, minScore: 0.0 })
|
|
59
|
+
expect(results.length).toBeGreaterThan(0)
|
|
60
|
+
expect(results[0]!.id).toBe("test:1")
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("search filters by type", async () => {
|
|
64
|
+
const v1 = mockEmbed("episodic query")
|
|
65
|
+
await Store.upsert({
|
|
66
|
+
id: "test:epi",
|
|
67
|
+
type: "episodic",
|
|
68
|
+
content: "episodic content",
|
|
69
|
+
summary: "episodic query",
|
|
70
|
+
vector: v1,
|
|
71
|
+
metadata: { source: "test", timestamp: Date.now(), tags: [] },
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const v2 = mockEmbed("semantic code query")
|
|
75
|
+
await Store.upsert({
|
|
76
|
+
id: "test:sem",
|
|
77
|
+
type: "semantic",
|
|
78
|
+
content: "code content",
|
|
79
|
+
summary: "semantic code query",
|
|
80
|
+
vector: v2,
|
|
81
|
+
metadata: { source: "test", timestamp: Date.now(), tags: [] },
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const results = await Store.search(v1, { topK: 5, minScore: 0.0, type: "episodic" })
|
|
85
|
+
expect(results.every((m) => m.type === "episodic")).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("remove deletes memories", async () => {
|
|
89
|
+
const id = `test:rm:${Date.now()}`
|
|
90
|
+
await Store.upsert({
|
|
91
|
+
id,
|
|
92
|
+
type: "episodic",
|
|
93
|
+
content: "to be removed",
|
|
94
|
+
summary: "remove test",
|
|
95
|
+
vector: mockEmbed("remove test"),
|
|
96
|
+
metadata: { source: "test", timestamp: Date.now(), tags: [] },
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const before = await Store.count()
|
|
100
|
+
await Store.remove([id])
|
|
101
|
+
const after = await Store.count()
|
|
102
|
+
expect(after).toBeLessThanOrEqual(before)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("prune removes old memories", async () => {
|
|
106
|
+
const oldTimestamp = Date.now() - 100 * 24 * 60 * 60 * 1000 // 100 days ago
|
|
107
|
+
const id = `test:old:${Date.now()}`
|
|
108
|
+
await Store.upsert({
|
|
109
|
+
id,
|
|
110
|
+
type: "episodic",
|
|
111
|
+
content: "old memory",
|
|
112
|
+
summary: "old memory content",
|
|
113
|
+
vector: mockEmbed("old memory content"),
|
|
114
|
+
metadata: { source: "test", timestamp: oldTimestamp, tags: [] },
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const pruned = await Store.prune(90 * 24 * 60 * 60 * 1000) // 90 days
|
|
118
|
+
expect(pruned).toBeGreaterThanOrEqual(0)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("upsert updates existing memory", async () => {
|
|
122
|
+
const id = "test:update"
|
|
123
|
+
const v1 = mockEmbed("version 1")
|
|
124
|
+
await Store.upsert({
|
|
125
|
+
id,
|
|
126
|
+
type: "semantic",
|
|
127
|
+
content: "version 1",
|
|
128
|
+
summary: "version 1",
|
|
129
|
+
vector: v1,
|
|
130
|
+
metadata: { source: "test", timestamp: 1, tags: [] },
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const v2 = mockEmbed("version 2 updated")
|
|
134
|
+
await Store.upsert({
|
|
135
|
+
id,
|
|
136
|
+
type: "semantic",
|
|
137
|
+
content: "version 2",
|
|
138
|
+
summary: "version 2 updated",
|
|
139
|
+
vector: v2,
|
|
140
|
+
metadata: { source: "test", timestamp: 2, tags: [] },
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const all = await Store.list()
|
|
144
|
+
const found = all.find((m) => m.id === id)
|
|
145
|
+
expect(found).toBeDefined()
|
|
146
|
+
expect(found!.content).toBe("version 2")
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("count returns total memories", async () => {
|
|
150
|
+
const count = await Store.count()
|
|
151
|
+
expect(typeof count).toBe("number")
|
|
152
|
+
expect(count).toBeGreaterThanOrEqual(0)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// ── Episodic Memory 테스트 ──
|
|
157
|
+
|
|
158
|
+
describe("LTM Episodic Memory", () => {
|
|
159
|
+
test("shouldSummarize triggers at interval", () => {
|
|
160
|
+
expect(Episodic.shouldSummarize(10, 10)).toBe(true)
|
|
161
|
+
expect(Episodic.shouldSummarize(5, 10)).toBe(false)
|
|
162
|
+
expect(Episodic.shouldSummarize(20, 10)).toBe(true)
|
|
163
|
+
expect(Episodic.shouldSummarize(0, 10)).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ── Procedural Memory 테스트 ──
|
|
168
|
+
|
|
169
|
+
describe("LTM Procedural Memory", () => {
|
|
170
|
+
test("extractStyleSignals detects indentation", () => {
|
|
171
|
+
const signals = Procedural.extractStyleSignals("test.ts", "const x = 1\n const y = 2\n const z = 3")
|
|
172
|
+
const indent = signals.find((s) => s.pattern === "indent")
|
|
173
|
+
expect(indent).toBeDefined()
|
|
174
|
+
expect(indent!.evidence).toContain("2-space")
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("extractStyleSignals detects Korean comments", () => {
|
|
178
|
+
const signals = Procedural.extractStyleSignals("test.ts", "// 한국어 주석\nconst x = 1")
|
|
179
|
+
const commentLang = signals.find((s) => s.pattern === "comment-language")
|
|
180
|
+
expect(commentLang).toBeDefined()
|
|
181
|
+
expect(commentLang!.evidence).toContain("Korean")
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("extractStyleSignals detects quote style", () => {
|
|
185
|
+
const signals = Procedural.extractStyleSignals("test.ts", "const a = 'hello'\nconst b = 'world'\nconst c = 'test'")
|
|
186
|
+
const quotes = signals.find((s) => s.pattern === "quotes")
|
|
187
|
+
expect(quotes).toBeDefined()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("extractStyleSignals returns empty for empty file", () => {
|
|
191
|
+
const signals = Procedural.extractStyleSignals("test.txt", "")
|
|
192
|
+
expect(signals).toBeArray()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ── Retrieval 포맷 테스트 ──
|
|
197
|
+
|
|
198
|
+
describe("LTM Retrieval", () => {
|
|
199
|
+
test("format produces context block", () => {
|
|
200
|
+
const memories: Memory[] = [
|
|
201
|
+
{
|
|
202
|
+
id: "test:r1",
|
|
203
|
+
type: "episodic",
|
|
204
|
+
content: "auth bug fix",
|
|
205
|
+
summary: "fixed CSRF vulnerability in auth/login.ts",
|
|
206
|
+
vector: [],
|
|
207
|
+
metadata: { source: "session:abc", timestamp: Date.now(), tags: ["fix", "auth"] },
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "test:r2",
|
|
211
|
+
type: "semantic",
|
|
212
|
+
content: "express middleware",
|
|
213
|
+
summary: "project uses Express middleware chain pattern",
|
|
214
|
+
vector: [],
|
|
215
|
+
metadata: { source: "file:src/app.ts", timestamp: Date.now(), tags: ["architecture"] },
|
|
216
|
+
},
|
|
217
|
+
]
|
|
218
|
+
|
|
219
|
+
const formatted = Retrieval.format(memories)
|
|
220
|
+
expect(formatted).toContain("[long-term memory")
|
|
221
|
+
expect(formatted).toContain("episodic")
|
|
222
|
+
expect(formatted).toContain("semantic")
|
|
223
|
+
expect(formatted).toContain("CSRF")
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test("format returns empty for no memories", () => {
|
|
227
|
+
const formatted = Retrieval.format([])
|
|
228
|
+
expect(formatted).toBe("")
|
|
229
|
+
})
|
|
230
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract.test.ts — SAEEOL 서버 계약 테스트
|
|
3
|
+
*
|
|
4
|
+
* closebook 패턴 기반:
|
|
5
|
+
* - response-conformance: Zod 스키마로 응답 바디 적합성 검사
|
|
6
|
+
* - consumer-contract: SDK/클라이언트가 기대하는 API 응답 스키마 검증
|
|
7
|
+
* - stateful-api-testing: 세션 생성 → 목록 조회 순차 테스트
|
|
8
|
+
*
|
|
9
|
+
* Hono app.request()로 실제 HTTP 서버 없이 테스트.
|
|
10
|
+
* Bun test + Effect + zod 검증.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
14
|
+
import { Effect } from "effect"
|
|
15
|
+
import { Flag } from "@saeeol/core/flag/flag"
|
|
16
|
+
import { Server } from "../../src/server/server"
|
|
17
|
+
import { resetDatabase } from "../fixture/db"
|
|
18
|
+
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
19
|
+
import * as Log from "@saeeol/core/util/log"
|
|
20
|
+
import { z } from "zod"
|
|
21
|
+
|
|
22
|
+
void Log.init({ print: false })
|
|
23
|
+
|
|
24
|
+
const original = Flag.SAEEOL_EXPERIMENTAL_HTTPAPI
|
|
25
|
+
|
|
26
|
+
function app(experimental = true) {
|
|
27
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = experimental
|
|
28
|
+
return experimental ? Server.Default().app : Server.Legacy().app
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Zod 스키마 (계약: 클라이언트가 기대하는 응답 형태) ──
|
|
32
|
+
|
|
33
|
+
const HealthSchema = z.object({
|
|
34
|
+
healthy: z.boolean(),
|
|
35
|
+
version: z.string(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const ConfigSchema = z.object({
|
|
39
|
+
model: z.string().optional(),
|
|
40
|
+
username: z.string().optional(),
|
|
41
|
+
}).passthrough()
|
|
42
|
+
|
|
43
|
+
const SessionListSchema = z.array(z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
title: z.string().optional(),
|
|
46
|
+
createdAt: z.number().optional(),
|
|
47
|
+
}).passthrough())
|
|
48
|
+
|
|
49
|
+
const SessionCreateSchema = z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
}).passthrough()
|
|
52
|
+
|
|
53
|
+
const AgentListSchema = z.array(z.unknown())
|
|
54
|
+
|
|
55
|
+
// ── 응답 적합성 검증 (closebook: response-conformance) ──
|
|
56
|
+
|
|
57
|
+
function checkConformance<T extends z.ZodType>(
|
|
58
|
+
body: unknown,
|
|
59
|
+
status: number,
|
|
60
|
+
schema: T,
|
|
61
|
+
allowedStatuses: number[] = [200],
|
|
62
|
+
): { ok: boolean; violations: string[] } {
|
|
63
|
+
const violations: string[] = []
|
|
64
|
+
|
|
65
|
+
if (!allowedStatuses.includes(status)) {
|
|
66
|
+
violations.push(`status: expected ${allowedStatuses.join("/")}, got ${status}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parsed = schema.safeParse(body)
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
for (const issue of parsed.error.issues) {
|
|
72
|
+
violations.push(`${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: violations.length === 0, violations }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── 테스트 ──
|
|
80
|
+
|
|
81
|
+
describe("SAEEOL server contract tests", () => {
|
|
82
|
+
afterEach(async () => {
|
|
83
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = original
|
|
84
|
+
await disposeAllInstances()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe("GET /global/health", () => {
|
|
88
|
+
test("returns valid health response (legacy)", async () => {
|
|
89
|
+
const res = await app(false).request("/global/health")
|
|
90
|
+
const body = await res.json()
|
|
91
|
+
expect(res.status).toBe(200)
|
|
92
|
+
const { ok, violations } = checkConformance(body, res.status, HealthSchema)
|
|
93
|
+
expect(ok).toBe(true)
|
|
94
|
+
if (!ok) console.error("Health violations:", violations)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("returns valid health response (experimental)", async () => {
|
|
98
|
+
const res = await app(true).request("/global/health")
|
|
99
|
+
const body = await res.json()
|
|
100
|
+
expect(res.status).toBe(200)
|
|
101
|
+
const { ok, violations } = checkConformance(body, res.status, HealthSchema)
|
|
102
|
+
expect(ok).toBe(true)
|
|
103
|
+
if (!ok) console.error("Health violations:", violations)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe("GET /global/config", () => {
|
|
108
|
+
test("returns valid config response", async () => {
|
|
109
|
+
await using tmp = await tmpdir({ config: {} })
|
|
110
|
+
const res = await app(false).request("/global/config", {
|
|
111
|
+
headers: { "x-saeeol-directory": tmp.path },
|
|
112
|
+
})
|
|
113
|
+
const body = await res.json()
|
|
114
|
+
const { ok, violations } = checkConformance(body, res.status, ConfigSchema)
|
|
115
|
+
expect(ok).toBe(true)
|
|
116
|
+
if (!ok) console.error("Config violations:", violations)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe("GET /agent", () => {
|
|
121
|
+
test("returns agent list", async () => {
|
|
122
|
+
await using tmp = await tmpdir({ config: {} })
|
|
123
|
+
const res = await app(false).request("/agent", {
|
|
124
|
+
headers: { "x-saeeol-directory": tmp.path },
|
|
125
|
+
})
|
|
126
|
+
const body = await res.json()
|
|
127
|
+
const { ok, violations } = checkConformance(body, res.status, AgentListSchema)
|
|
128
|
+
expect(ok).toBe(true)
|
|
129
|
+
if (!ok) console.error("Agent violations:", violations)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ── 상태 순차 테스트 (closebook: stateful-api-testing) ──
|
|
134
|
+
|
|
135
|
+
describe("session lifecycle", () => {
|
|
136
|
+
test("create → list flow", async () => {
|
|
137
|
+
await using tmp = await tmpdir({ git: true, config: {} })
|
|
138
|
+
const headers = { "x-saeeol-directory": tmp.path, "Content-Type": "application/json" }
|
|
139
|
+
|
|
140
|
+
// Step 1: 세션 생성
|
|
141
|
+
const createRes = await app(false).request("/session", {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers,
|
|
144
|
+
body: JSON.stringify({ title: "contract-test" }),
|
|
145
|
+
})
|
|
146
|
+
const createBody = await createRes.json()
|
|
147
|
+
const { ok: createOk, violations: createViolations } = checkConformance(
|
|
148
|
+
createBody,
|
|
149
|
+
createRes.status,
|
|
150
|
+
SessionCreateSchema,
|
|
151
|
+
)
|
|
152
|
+
expect(createOk).toBe(true)
|
|
153
|
+
if (!createOk) console.error("Session create violations:", createViolations)
|
|
154
|
+
|
|
155
|
+
// Step 2: 세션 목록
|
|
156
|
+
const listRes = await app(false).request("/session", { headers })
|
|
157
|
+
const listBody = await listRes.json()
|
|
158
|
+
const { ok: listOk, violations: listViolations } = checkConformance(
|
|
159
|
+
listBody,
|
|
160
|
+
listRes.status,
|
|
161
|
+
SessionListSchema,
|
|
162
|
+
)
|
|
163
|
+
expect(listOk).toBe(true)
|
|
164
|
+
if (!listOk) console.error("Session list violations:", listViolations)
|
|
165
|
+
expect(listBody.length).toBeGreaterThanOrEqual(1)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe("unknown route", () => {
|
|
170
|
+
test("returns 404 for unknown endpoints", async () => {
|
|
171
|
+
const res = await app(false).request("/nonexistent")
|
|
172
|
+
expect(res.status).toBe(404)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// ── 계약 파일 생성 (closebook: consumer-contract) ──
|
|
177
|
+
|
|
178
|
+
describe("pact artifact generation", () => {
|
|
179
|
+
test("collects verified interactions into contract", async () => {
|
|
180
|
+
await using tmp = await tmpdir({ config: {} })
|
|
181
|
+
const headers = { "x-saeeol-directory": tmp.path }
|
|
182
|
+
const interactions = []
|
|
183
|
+
|
|
184
|
+
// Health 인터랙션
|
|
185
|
+
const healthRes = await app(false).request("/global/health")
|
|
186
|
+
const healthBody = await healthRes.json()
|
|
187
|
+
interactions.push({
|
|
188
|
+
description: "health check",
|
|
189
|
+
providerStates: [],
|
|
190
|
+
request: { method: "GET", path: "/global/health" },
|
|
191
|
+
response: { status: healthRes.status, body: healthBody },
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Config 인터랙션
|
|
195
|
+
const configRes = await app(false).request("/global/config", { headers })
|
|
196
|
+
const configBody = await configRes.json()
|
|
197
|
+
interactions.push({
|
|
198
|
+
description: "global config",
|
|
199
|
+
providerStates: [],
|
|
200
|
+
request: { method: "GET", path: "/global/config" },
|
|
201
|
+
response: { status: configRes.status, body: configBody },
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// Agent 인터랙션
|
|
205
|
+
const agentRes = await app(false).request("/agent", { headers })
|
|
206
|
+
const agentBody = await agentRes.json()
|
|
207
|
+
interactions.push({
|
|
208
|
+
description: "agent list",
|
|
209
|
+
providerStates: [],
|
|
210
|
+
request: { method: "GET", path: "/agent" },
|
|
211
|
+
response: { status: agentRes.status, body: agentBody },
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
// 계약 파일 검증
|
|
215
|
+
const pact = {
|
|
216
|
+
consumer: { name: "saeeol-vscode" },
|
|
217
|
+
provider: { name: "saeeol-server" },
|
|
218
|
+
interactions,
|
|
219
|
+
metadata: { version: "1.0.0" },
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(pact.interactions.length).toBe(3)
|
|
223
|
+
for (const ix of pact.interactions) {
|
|
224
|
+
expect(ix).toHaveProperty("description")
|
|
225
|
+
expect(ix).toHaveProperty("request")
|
|
226
|
+
expect(ix).toHaveProperty("response")
|
|
227
|
+
expect(ix.response.status).toBeLessThan(500)
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
})
|