saeeol 1.2.3 → 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 CHANGED
@@ -175,13 +175,29 @@ function findBinary(startDir) {
175
175
  }
176
176
 
177
177
  const resolved = findBinary(scriptDir)
178
- if (!resolved) {
179
- console.error(
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(resolved)
182
+ // Fallback: run source with bun if available
183
+ const indexPath = path.join(scriptDir, "src", "index.ts")
184
+ if (fs.existsSync(indexPath)) {
185
+ const bunExe = process.platform === "win32" ? "bun.exe" : "bun"
186
+ try {
187
+ const which = childProcess.spawnSync(bunExe, ["--version"], { encoding: "utf8", timeout: 3000 })
188
+ if (which.status === 0) {
189
+ const result = childProcess.spawnSync(bunExe, [
190
+ "run", "--conditions=browser", indexPath, ...process.argv.slice(2)
191
+ ], { stdio: "inherit" })
192
+ const code = typeof result.status === "number" ? result.status : 0
193
+ process.exit(code)
194
+ }
195
+ } catch {}
196
+ }
197
+
198
+ console.error(
199
+ "It seems that your package manager failed to install the right version of the SAEEOL CLI for your platform. You can try manually installing " +
200
+ names.map((n) => `\"${n}\"`).join(" or ") +
201
+ " package, or install bun (https://bun.sh) to run from source",
202
+ )
203
+ process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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
+ })