saeeol 1.2.2 → 1.2.4
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 +203 -0
- package/npm/bin/saeeol +0 -0
- package/package.json +2 -2
- package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +1 -1
- package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +5 -5
- package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +5 -5
- package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +2 -2
- package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +3 -3
- package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +4 -4
- package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +4 -4
- package/src/cli/cmd/tui/context/app/args.tsx +15 -0
- package/src/cli/cmd/tui/context/app/directory.ts +15 -0
- package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
- package/src/cli/cmd/tui/context/app/editor.ts +425 -0
- package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
- package/src/cli/cmd/tui/context/app/project.tsx +109 -0
- package/src/cli/cmd/tui/context/app/route.tsx +67 -0
- package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
- package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
- package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
- package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
- package/src/cli/cmd/tui/context/args.tsx +1 -15
- package/src/cli/cmd/tui/context/directory.ts +1 -15
- package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
- package/src/cli/cmd/tui/context/editor.ts +1 -425
- package/src/cli/cmd/tui/context/event.ts +1 -45
- package/src/cli/cmd/tui/context/exit.tsx +1 -67
- package/src/cli/cmd/tui/context/helper.tsx +1 -25
- package/src/cli/cmd/tui/context/keybind.tsx +1 -105
- package/src/cli/cmd/tui/context/kv.tsx +1 -76
- package/src/cli/cmd/tui/context/local.tsx +1 -478
- package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
- package/src/cli/cmd/tui/context/project.tsx +1 -109
- package/src/cli/cmd/tui/context/prompt.tsx +1 -18
- package/src/cli/cmd/tui/context/route.tsx +1 -67
- package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
- package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
- package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
- package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
- package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
- package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
- package/src/cli/cmd/tui/context/sdk.tsx +1 -142
- package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
- package/src/cli/cmd/tui/context/sync.tsx +1 -713
- package/src/cli/cmd/tui/context/theme.tsx +1 -307
- package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
- package/src/ltm/pipeline.ts +103 -1
- package/test/server/contract.test.ts +249 -0
|
@@ -1,307 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import path from "path"
|
|
3
|
-
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
|
4
|
-
import { createSimpleContext } from "./helper"
|
|
5
|
-
import { Glob } from "@saeeol/core/util/glob"
|
|
6
|
-
import { useKV } from "./kv"
|
|
7
|
-
import { useRenderer } from "@opentui/solid"
|
|
8
|
-
import { createStore, produce } from "solid-js/store"
|
|
9
|
-
import { Global } from "@saeeol/core/global"
|
|
10
|
-
import { Filesystem } from "@/util/filesystem"
|
|
11
|
-
import { useTuiConfig } from "./tui-config"
|
|
12
|
-
import { isRecord } from "@/util/record"
|
|
13
|
-
import { selectedForeground, tint } from "./theme/theme-types"
|
|
14
|
-
import type { ThemeJson, Theme } from "./theme/theme-types"
|
|
15
|
-
import { DEFAULT_THEMES, isValidTheme } from "./theme/theme-themes"
|
|
16
|
-
import { resolveTheme } from "./theme/theme-resolve"
|
|
17
|
-
import { generateSystem } from "./theme/theme-system"
|
|
18
|
-
import { generateSyntax, generateSubtleSyntax } from "./theme/theme-syntax"
|
|
19
|
-
|
|
20
|
-
export { selectedForeground, tint }
|
|
21
|
-
export type { ThemeJson }
|
|
22
|
-
export { DEFAULT_THEMES }
|
|
23
|
-
export { resolveTheme }
|
|
24
|
-
|
|
25
|
-
type State = {
|
|
26
|
-
themes: Record<string, ThemeJson>
|
|
27
|
-
mode: "dark" | "light"
|
|
28
|
-
lock: "dark" | "light" | undefined
|
|
29
|
-
active: string
|
|
30
|
-
ready: boolean
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const pluginThemes: Record<string, ThemeJson> = {}
|
|
34
|
-
let customThemes: Record<string, ThemeJson> = {}
|
|
35
|
-
let systemTheme: ThemeJson | undefined
|
|
36
|
-
|
|
37
|
-
function listThemes() {
|
|
38
|
-
const themes = {
|
|
39
|
-
...DEFAULT_THEMES,
|
|
40
|
-
...pluginThemes,
|
|
41
|
-
...customThemes,
|
|
42
|
-
}
|
|
43
|
-
if (!systemTheme) return themes
|
|
44
|
-
return {
|
|
45
|
-
...themes,
|
|
46
|
-
system: systemTheme,
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function syncThemes() {
|
|
51
|
-
setStore("themes", listThemes())
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const [store, setStore] = createStore<State>({
|
|
55
|
-
themes: listThemes(),
|
|
56
|
-
mode: "dark",
|
|
57
|
-
lock: undefined,
|
|
58
|
-
active: "saeeol",
|
|
59
|
-
ready: false,
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
export function allThemes() {
|
|
63
|
-
return store.themes
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function isTheme(theme: unknown): theme is ThemeJson {
|
|
67
|
-
if (!isRecord(theme)) return false
|
|
68
|
-
if (!isRecord(theme.theme)) return false
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function hasTheme(name: string) {
|
|
73
|
-
if (!name) return false
|
|
74
|
-
return allThemes()[name] !== undefined
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function addTheme(name: string, theme: unknown) {
|
|
78
|
-
if (!name) return false
|
|
79
|
-
if (!isTheme(theme)) return false
|
|
80
|
-
if (hasTheme(name)) return false
|
|
81
|
-
pluginThemes[name] = theme
|
|
82
|
-
syncThemes()
|
|
83
|
-
return true
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function upsertTheme(name: string, theme: unknown) {
|
|
87
|
-
if (!name) return false
|
|
88
|
-
if (!isTheme(theme)) return false
|
|
89
|
-
if (customThemes[name] !== undefined) {
|
|
90
|
-
customThemes[name] = theme
|
|
91
|
-
} else {
|
|
92
|
-
pluginThemes[name] = theme
|
|
93
|
-
}
|
|
94
|
-
syncThemes()
|
|
95
|
-
return true
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|
99
|
-
name: "Theme",
|
|
100
|
-
init: (props: { mode: "dark" | "light" }) => {
|
|
101
|
-
const renderer = useRenderer()
|
|
102
|
-
const config = useTuiConfig()
|
|
103
|
-
const kv = useKV()
|
|
104
|
-
const pick = (value: unknown) => {
|
|
105
|
-
if (value === "dark" || value === "light") return value
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
setStore(
|
|
110
|
-
produce((draft) => {
|
|
111
|
-
const lock = pick(kv.get("theme_mode_lock"))
|
|
112
|
-
const mode = lock ?? pick(renderer.themeMode) ?? props.mode
|
|
113
|
-
if (!lock && pick(kv.get("theme_mode")) !== undefined) {
|
|
114
|
-
kv.set("theme_mode", undefined)
|
|
115
|
-
}
|
|
116
|
-
draft.mode = mode
|
|
117
|
-
draft.lock = lock
|
|
118
|
-
const active = config.theme ?? kv.get("theme", "saeeol")
|
|
119
|
-
draft.active = typeof active === "string" ? active : "saeeol"
|
|
120
|
-
draft.ready = false
|
|
121
|
-
}),
|
|
122
|
-
)
|
|
123
|
-
|
|
124
|
-
createEffect(() => {
|
|
125
|
-
const theme = config.theme
|
|
126
|
-
if (theme) setStore("active", theme)
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
function init() {
|
|
130
|
-
void Promise.allSettled([
|
|
131
|
-
resolveSystemTheme(store.mode),
|
|
132
|
-
getCustomThemes()
|
|
133
|
-
.then((custom) => {
|
|
134
|
-
customThemes = custom
|
|
135
|
-
syncThemes()
|
|
136
|
-
})
|
|
137
|
-
.catch(() => {
|
|
138
|
-
setStore("active", "saeeol")
|
|
139
|
-
}),
|
|
140
|
-
]).finally(() => {
|
|
141
|
-
setStore("ready", true)
|
|
142
|
-
})
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
onMount(init)
|
|
146
|
-
|
|
147
|
-
function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
|
|
148
|
-
return renderer
|
|
149
|
-
.getPalette({
|
|
150
|
-
size: 16,
|
|
151
|
-
})
|
|
152
|
-
.then((colors: TerminalColors) => {
|
|
153
|
-
if (!colors.palette[0]) {
|
|
154
|
-
systemTheme = undefined
|
|
155
|
-
syncThemes()
|
|
156
|
-
if (store.active === "system") {
|
|
157
|
-
setStore("active", "saeeol")
|
|
158
|
-
}
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
systemTheme = generateSystem(colors, mode)
|
|
162
|
-
syncThemes()
|
|
163
|
-
})
|
|
164
|
-
.catch(() => {
|
|
165
|
-
systemTheme = undefined
|
|
166
|
-
syncThemes()
|
|
167
|
-
if (store.active === "system") {
|
|
168
|
-
setStore("active", "saeeol")
|
|
169
|
-
}
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function apply(mode: "dark" | "light") {
|
|
174
|
-
if (store.lock !== undefined) kv.set("theme_mode", mode)
|
|
175
|
-
if (store.mode === mode) return
|
|
176
|
-
setStore("mode", mode)
|
|
177
|
-
renderer.clearPaletteCache()
|
|
178
|
-
void resolveSystemTheme(mode)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function pin(mode: "dark" | "light" = store.mode) {
|
|
182
|
-
setStore("lock", mode)
|
|
183
|
-
kv.set("theme_mode_lock", mode)
|
|
184
|
-
apply(mode)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function free() {
|
|
188
|
-
setStore("lock", undefined)
|
|
189
|
-
kv.set("theme_mode_lock", undefined)
|
|
190
|
-
kv.set("theme_mode", undefined)
|
|
191
|
-
const mode = renderer.themeMode
|
|
192
|
-
if (mode) apply(mode)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const handle = (mode: "dark" | "light") => {
|
|
196
|
-
if (store.lock) return
|
|
197
|
-
apply(mode)
|
|
198
|
-
}
|
|
199
|
-
renderer.on(CliRenderEvents.THEME_MODE, handle)
|
|
200
|
-
|
|
201
|
-
const refresh = () => {
|
|
202
|
-
renderer.clearPaletteCache()
|
|
203
|
-
init()
|
|
204
|
-
}
|
|
205
|
-
process.on("SIGUSR2", refresh)
|
|
206
|
-
|
|
207
|
-
onCleanup(() => {
|
|
208
|
-
renderer.off(CliRenderEvents.THEME_MODE, handle)
|
|
209
|
-
process.off("SIGUSR2", refresh)
|
|
210
|
-
})
|
|
211
|
-
const values = createMemo(() => {
|
|
212
|
-
const active = store.themes[store.active]
|
|
213
|
-
if (active) {
|
|
214
|
-
return resolveTheme(active, store.mode)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const saved = kv.get("theme")
|
|
218
|
-
if (typeof saved === "string") {
|
|
219
|
-
const theme = store.themes[saved]
|
|
220
|
-
if (theme) {
|
|
221
|
-
return resolveTheme(theme, store.mode)
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return resolveTheme(store.themes.saeeol, store.mode)
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
createEffect(() => {
|
|
229
|
-
renderer.setBackgroundColor(values().background)
|
|
230
|
-
})
|
|
231
|
-
|
|
232
|
-
const syntax = createMemo(() => generateSyntax(values()))
|
|
233
|
-
const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
|
|
234
|
-
return {
|
|
235
|
-
theme: new Proxy({} as Theme, {
|
|
236
|
-
get(_target, prop) {
|
|
237
|
-
// @ts-expect-error
|
|
238
|
-
return values()[prop]
|
|
239
|
-
},
|
|
240
|
-
}),
|
|
241
|
-
get selected() {
|
|
242
|
-
return store.active
|
|
243
|
-
},
|
|
244
|
-
all() {
|
|
245
|
-
return allThemes()
|
|
246
|
-
},
|
|
247
|
-
has(name: string) {
|
|
248
|
-
return hasTheme(name)
|
|
249
|
-
},
|
|
250
|
-
syntax,
|
|
251
|
-
subtleSyntax,
|
|
252
|
-
mode() {
|
|
253
|
-
return store.mode
|
|
254
|
-
},
|
|
255
|
-
locked() {
|
|
256
|
-
return store.lock !== undefined
|
|
257
|
-
},
|
|
258
|
-
lock() {
|
|
259
|
-
pin(store.mode)
|
|
260
|
-
},
|
|
261
|
-
unlock() {
|
|
262
|
-
free()
|
|
263
|
-
},
|
|
264
|
-
setMode(mode: "dark" | "light") {
|
|
265
|
-
pin(mode)
|
|
266
|
-
},
|
|
267
|
-
set(theme: string) {
|
|
268
|
-
if (!hasTheme(theme)) return false
|
|
269
|
-
setStore("active", theme)
|
|
270
|
-
kv.set("theme", theme)
|
|
271
|
-
return true
|
|
272
|
-
},
|
|
273
|
-
get ready() {
|
|
274
|
-
return store.ready
|
|
275
|
-
},
|
|
276
|
-
}
|
|
277
|
-
},
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
async function getCustomThemes() {
|
|
281
|
-
const directories = [
|
|
282
|
-
Global.Path.config,
|
|
283
|
-
...(await Array.fromAsync(
|
|
284
|
-
Filesystem.up({
|
|
285
|
-
targets: [".saeeol", ".saeeol"],
|
|
286
|
-
start: process.cwd(),
|
|
287
|
-
}),
|
|
288
|
-
)),
|
|
289
|
-
]
|
|
290
|
-
|
|
291
|
-
const result: Record<string, ThemeJson> = {}
|
|
292
|
-
for (const dir of directories) {
|
|
293
|
-
for (const item of await Glob.scan("themes/*.json", {
|
|
294
|
-
cwd: dir,
|
|
295
|
-
absolute: true,
|
|
296
|
-
dot: true,
|
|
297
|
-
symlink: true,
|
|
298
|
-
})) {
|
|
299
|
-
const name = path.basename(item, ".json")
|
|
300
|
-
if (name in DEFAULT_THEMES) continue
|
|
301
|
-
const json = await Filesystem.readJson(item).catch(() => null)
|
|
302
|
-
if (!isValidTheme(json)) continue
|
|
303
|
-
result[name] = json
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return result
|
|
307
|
-
}
|
|
1
|
+
export * from "./app/theme"
|
|
@@ -1,9 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { createSimpleContext } from "./helper"
|
|
3
|
-
|
|
4
|
-
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
|
|
5
|
-
name: "TuiConfig",
|
|
6
|
-
init: (props: { config: TuiConfig.Info }) => {
|
|
7
|
-
return props.config
|
|
8
|
-
},
|
|
9
|
-
})
|
|
1
|
+
export * from "./app/tui-config"
|
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 { 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
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* contract.test.ts — SAEEOL 서버 계약 테스트
|
|
3
|
+
*
|
|
4
|
+
* closebook 패턴 기반:
|
|
5
|
+
* - response-conformance: Effect Schema로 응답 바디 적합성 검사
|
|
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 } 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 { it } from "../lib/effect"
|
|
20
|
+
import * as Log from "@saeeol/core/util/log"
|
|
21
|
+
import { z } from "zod"
|
|
22
|
+
|
|
23
|
+
void Log.init({ print: false })
|
|
24
|
+
|
|
25
|
+
const original = Flag.SAEEOL_EXPERIMENTAL_HTTPAPI
|
|
26
|
+
|
|
27
|
+
function app(experimental = true) {
|
|
28
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = experimental
|
|
29
|
+
return experimental ? Server.Default().app : Server.Legacy().app
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
Flag.SAEEOL_EXPERIMENTAL_HTTPAPI = original
|
|
34
|
+
await disposeAllInstances()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// ── Zod 스키마 (계약: 클라이언트가 기대하는 응답 형태) ──
|
|
38
|
+
|
|
39
|
+
const HealthSchema = z.object({
|
|
40
|
+
healthy: z.boolean(),
|
|
41
|
+
version: z.string(),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const ConfigSchema = z.object({
|
|
45
|
+
model: z.string().optional(),
|
|
46
|
+
username: z.string().optional(),
|
|
47
|
+
}).passthrough()
|
|
48
|
+
|
|
49
|
+
const SessionListSchema = z.array(z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
title: z.string().optional(),
|
|
52
|
+
createdAt: z.number().optional(),
|
|
53
|
+
}).passthrough())
|
|
54
|
+
|
|
55
|
+
const SessionCreateSchema = z.object({
|
|
56
|
+
id: z.string(),
|
|
57
|
+
}).passthrough()
|
|
58
|
+
|
|
59
|
+
const AgentListSchema = z.array(z.unknown())
|
|
60
|
+
|
|
61
|
+
const ErrorResponseSchema = z.object({
|
|
62
|
+
error: z.string().or(z.record(z.unknown())),
|
|
63
|
+
}).passthrough()
|
|
64
|
+
|
|
65
|
+
// ── 응답 적합성 검증 (closebook: response-conformance) ──
|
|
66
|
+
|
|
67
|
+
function checkConformance<T extends z.ZodType>(
|
|
68
|
+
body: unknown,
|
|
69
|
+
status: number,
|
|
70
|
+
schema: T,
|
|
71
|
+
allowedStatuses: number[] = [200],
|
|
72
|
+
): { ok: boolean; violations: string[] } {
|
|
73
|
+
const violations: string[] = []
|
|
74
|
+
|
|
75
|
+
if (!allowedStatuses.includes(status)) {
|
|
76
|
+
violations.push(`status: expected ${allowedStatuses.join("/")}, got ${status}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const parsed = schema.safeParse(body)
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
for (const issue of parsed.error.issues) {
|
|
82
|
+
violations.push(`${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { ok: violations.length === 0, violations }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 테스트 ──
|
|
90
|
+
|
|
91
|
+
describe("SAEEOL server contract tests", () => {
|
|
92
|
+
describe("GET /global/health", () => {
|
|
93
|
+
it.live("returns valid health response (legacy)", () =>
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const res = yield* Effect.promise(async () => await app(false).request("/global/health"))
|
|
96
|
+
const body = yield* Effect.promise(async () => await res.json())
|
|
97
|
+
expect(res.status).toBe(200)
|
|
98
|
+
const { ok, violations } = checkConformance(body, res.status, HealthSchema)
|
|
99
|
+
expect(ok).toBe(true)
|
|
100
|
+
if (!ok) console.error("Health violations:", violations)
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
it.live("returns valid health response (experimental)", () =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
const res = yield* Effect.promise(async () => await app(true).request("/global/health"))
|
|
107
|
+
const body = yield* Effect.promise(async () => await res.json())
|
|
108
|
+
expect(res.status).toBe(200)
|
|
109
|
+
const { ok, violations } = checkConformance(body, res.status, HealthSchema)
|
|
110
|
+
expect(ok).toBe(true)
|
|
111
|
+
if (!ok) console.error("Health violations:", violations)
|
|
112
|
+
}),
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("GET /global/config", () => {
|
|
117
|
+
it.live("returns valid config response", () =>
|
|
118
|
+
Effect.gen(function* () {
|
|
119
|
+
await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
|
|
120
|
+
const res = yield* Effect.promise(
|
|
121
|
+
async () => await app(false).request("/global/config", {
|
|
122
|
+
headers: { "x-saeeol-directory": tmp.path },
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
const body = yield* Effect.promise(async () => await res.json())
|
|
126
|
+
const { ok, violations } = checkConformance(body, res.status, ConfigSchema)
|
|
127
|
+
expect(ok).toBe(true)
|
|
128
|
+
if (!ok) console.error("Config violations:", violations)
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("GET /agent", () => {
|
|
134
|
+
it.live("returns agent list", () =>
|
|
135
|
+
Effect.gen(function* () {
|
|
136
|
+
await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
|
|
137
|
+
const res = yield* Effect.promise(
|
|
138
|
+
async () => await app(false).request("/agent", {
|
|
139
|
+
headers: { "x-saeeol-directory": tmp.path },
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
const body = yield* Effect.promise(async () => await res.json())
|
|
143
|
+
const { ok, violations } = checkConformance(body, res.status, AgentListSchema)
|
|
144
|
+
expect(ok).toBe(true)
|
|
145
|
+
if (!ok) console.error("Agent violations:", violations)
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe("session CRUD", () => {
|
|
151
|
+
it.live("creates and lists sessions", () =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
await using tmp = yield* Effect.promise(() => tmpdir({ git: true, config: {} }))
|
|
154
|
+
const headers = { "x-saeeol-directory": tmp.path, "Content-Type": "application/json" }
|
|
155
|
+
|
|
156
|
+
// Create session
|
|
157
|
+
const createRes = yield* Effect.promise(
|
|
158
|
+
async () => await app(false).request("/session", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
body: JSON.stringify({ title: "contract-test" }),
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
const createBody = yield* Effect.promise(async () => await createRes.json())
|
|
165
|
+
const { ok: createOk, violations: createViolations } = checkConformance(
|
|
166
|
+
createBody,
|
|
167
|
+
createRes.status,
|
|
168
|
+
SessionCreateSchema,
|
|
169
|
+
)
|
|
170
|
+
expect(createOk).toBe(true)
|
|
171
|
+
if (!createOk) console.error("Session create violations:", createViolations)
|
|
172
|
+
|
|
173
|
+
// List sessions
|
|
174
|
+
const listRes = yield* Effect.promise(
|
|
175
|
+
async () => await app(false).request("/session", { headers }),
|
|
176
|
+
)
|
|
177
|
+
const listBody = yield* Effect.promise(async () => await listRes.json())
|
|
178
|
+
const { ok: listOk, violations: listViolations } = checkConformance(
|
|
179
|
+
listBody,
|
|
180
|
+
listRes.status,
|
|
181
|
+
SessionListSchema,
|
|
182
|
+
)
|
|
183
|
+
expect(listOk).toBe(true)
|
|
184
|
+
if (!listOk) console.error("Session list violations:", listViolations)
|
|
185
|
+
}),
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe("unknown route", () => {
|
|
190
|
+
it.live("returns error for unknown endpoints", () =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
const res = yield* Effect.promise(async () => await app(false).request("/nonexistent"))
|
|
193
|
+
expect(res.status).toBe(404)
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// ── 계약 파일 생성 (closebook: consumer-contract) ──
|
|
199
|
+
|
|
200
|
+
describe("pact artifact generation", () => {
|
|
201
|
+
it.live("collects verified interactions into contract", () =>
|
|
202
|
+
Effect.gen(function* () {
|
|
203
|
+
await using tmp = yield* Effect.promise(() => tmpdir({ config: {} }))
|
|
204
|
+
const headers = { "x-saeeol-directory": tmp.path }
|
|
205
|
+
const interactions = []
|
|
206
|
+
|
|
207
|
+
// Health 인터랙션
|
|
208
|
+
const healthRes = yield* Effect.promise(
|
|
209
|
+
async () => await app(false).request("/global/health"),
|
|
210
|
+
)
|
|
211
|
+
const healthBody = yield* Effect.promise(async () => await healthRes.json())
|
|
212
|
+
interactions.push({
|
|
213
|
+
description: "health check",
|
|
214
|
+
providerStates: [],
|
|
215
|
+
request: { method: "GET", path: "/global/health" },
|
|
216
|
+
response: { status: healthRes.status, body: healthBody },
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Config 인터랙션
|
|
220
|
+
const configRes = yield* Effect.promise(
|
|
221
|
+
async () => await app(false).request("/global/config", { headers }),
|
|
222
|
+
)
|
|
223
|
+
const configBody = yield* Effect.promise(async () => await configRes.json())
|
|
224
|
+
interactions.push({
|
|
225
|
+
description: "global config",
|
|
226
|
+
providerStates: [],
|
|
227
|
+
request: { method: "GET", path: "/global/config" },
|
|
228
|
+
response: { status: configRes.status, body: configBody },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
// 계약 파일 검증
|
|
232
|
+
const pact = {
|
|
233
|
+
consumer: { name: "saeeol-vscode" },
|
|
234
|
+
provider: { name: "saeeol-server" },
|
|
235
|
+
interactions,
|
|
236
|
+
metadata: { version: "1.0.0" },
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
expect(pact.interactions.length).toBeGreaterThanOrEqual(2)
|
|
240
|
+
for (const ix of pact.interactions) {
|
|
241
|
+
expect(ix).toHaveProperty("description")
|
|
242
|
+
expect(ix).toHaveProperty("request")
|
|
243
|
+
expect(ix).toHaveProperty("response")
|
|
244
|
+
expect(ix.response.status).toBeLessThan(500)
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
})
|