saeeol 1.4.0 → 1.4.2
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/package.json +1 -1
- package/src/provider/provider-schemas.ts +1 -1
- package/src/server/routes/instance/httpapi/groups/ltm.ts +93 -0
- package/src/server/routes/instance/httpapi/handlers/ltm.ts +118 -0
- package/src/server/routes/instance/httpapi/handlers/provider.ts +4 -3
- package/src/server/routes/instance/index.ts +96 -1
- package/src/server/routes/instance/provider.ts +4 -3
- package/test/smoke/tui-walkthrough-driver.ts +234 -0
- package/test/smoke/tui-walkthrough-input.test.ts +285 -0
- package/test/smoke/tui-walkthrough-leader.test.ts +175 -0
- package/test/smoke/tui-walkthrough-scroll.test.ts +177 -0
- package/test/smoke/tui-walkthrough-slash.test.ts +302 -0
- package/test/smoke/tui-walkthrough-system.test.ts +208 -0
- package/test/smoke/.tui-debug-output.txt +0 -1
- package/test/smoke/.tui-debug-plain.txt +0 -1
- package/test/smoke/.tui-walkthrough-report.txt +0 -122
- package/test/smoke/tui-walkthrough.test.ts +0 -520
package/package.json
CHANGED
|
@@ -35,7 +35,7 @@ export const Info = Schema.Struct({
|
|
|
35
35
|
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
|
|
36
36
|
|
|
37
37
|
const DefaultModelIDs = Schema.Record(Schema.String, Schema.String)
|
|
38
|
-
export const ListResult = Schema.Struct({ all: Schema.Array(Info), default: DefaultModelIDs, connected: Schema.Array(Schema.String), failed: Schema.Array(Schema.String) }).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
38
|
+
export const ListResult = Schema.Struct({ all: Schema.Array(Info), default: DefaultModelIDs, connected: Schema.Array(Schema.String), failed: Schema.Array(Schema.String), gateway: Schema.NullOr(Info) }).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
39
39
|
export type ListResult = Types.DeepMutable<Schema.Schema.Type<typeof ListResult>>
|
|
40
40
|
export const ConfigProvidersResult = Schema.Struct({ providers: Schema.Array(Info), default: DefaultModelIDs }).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
41
41
|
export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof ConfigProvidersResult>>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groups/ltm.ts — LTM (Long-Term Memory) HTTP API group
|
|
3
|
+
*
|
|
4
|
+
* 외부 앱(chowriter 등)이 saeeol LTM에 기억을 저장/검색하는 엔드포인트.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Schema } from "effect"
|
|
8
|
+
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
|
9
|
+
import { Authorization } from "../middleware/authorization"
|
|
10
|
+
import { InstanceContextMiddleware } from "../middleware/instance-context"
|
|
11
|
+
import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
|
|
12
|
+
import { described } from "./metadata"
|
|
13
|
+
|
|
14
|
+
const root = "/ltm"
|
|
15
|
+
|
|
16
|
+
// Request schemas
|
|
17
|
+
const IngestPayload = Schema.Struct({
|
|
18
|
+
type: Schema.Literal("episodic", "semantic", "procedural"),
|
|
19
|
+
content: Schema.String,
|
|
20
|
+
summary: Schema.String,
|
|
21
|
+
projectID: Schema.optional(Schema.String),
|
|
22
|
+
sessionID: Schema.optional(Schema.String),
|
|
23
|
+
tags: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const SearchPayload = Schema.Struct({
|
|
27
|
+
query: Schema.String,
|
|
28
|
+
topK: Schema.optional(Schema.Number),
|
|
29
|
+
minScore: Schema.optional(Schema.Number),
|
|
30
|
+
type: Schema.optional(Schema.Literal("episodic", "semantic", "procedural")),
|
|
31
|
+
projectID: Schema.optional(Schema.String),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const DeletePayload = Schema.Struct({
|
|
35
|
+
ids: Schema.mutable(Schema.Array(Schema.String)),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Response schemas
|
|
39
|
+
const MemoryEntry = Schema.Struct({
|
|
40
|
+
id: Schema.String,
|
|
41
|
+
type: Schema.String,
|
|
42
|
+
summary: Schema.String,
|
|
43
|
+
content: Schema.String,
|
|
44
|
+
score: Schema.optional(Schema.Number),
|
|
45
|
+
metadata: Schema.Struct({
|
|
46
|
+
source: Schema.String,
|
|
47
|
+
timestamp: Schema.Number,
|
|
48
|
+
projectID: Schema.optional(Schema.String),
|
|
49
|
+
tags: Schema.mutable(Schema.Array(Schema.String)),
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const IngestResponse = Schema.Struct({
|
|
54
|
+
id: Schema.String,
|
|
55
|
+
success: Schema.Boolean,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const SearchResponse = Schema.Struct({
|
|
59
|
+
memories: Schema.mutable(Schema.Array(MemoryEntry)),
|
|
60
|
+
count: Schema.Number,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const DeleteResponse = Schema.Struct({
|
|
64
|
+
removed: Schema.Number,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const LTMHttpApi = HttpApi.make("ltm")
|
|
68
|
+
.add(
|
|
69
|
+
HttpApiGroup.make(root, "ltm")
|
|
70
|
+
.add(
|
|
71
|
+
HttpApiEndpoint.post("ingest", `${root}/ingest`)
|
|
72
|
+
.setPayload(IngestPayload)
|
|
73
|
+
.addSuccess(IngestResponse)
|
|
74
|
+
.annotateMerge(OpenApi.annotations({ identifier: "ltm.ingest", summary: "Store a memory in LTM" })),
|
|
75
|
+
)
|
|
76
|
+
.add(
|
|
77
|
+
HttpApiEndpoint.post("search", `${root}/search`)
|
|
78
|
+
.setPayload(SearchPayload)
|
|
79
|
+
.addSuccess(SearchResponse)
|
|
80
|
+
.annotateMerge(OpenApi.annotations({ identifier: "ltm.search", summary: "Search LTM memories by query" })),
|
|
81
|
+
)
|
|
82
|
+
.add(
|
|
83
|
+
HttpApiEndpoint.post("delete", `${root}/delete`)
|
|
84
|
+
.setPayload(DeletePayload)
|
|
85
|
+
.addSuccess(DeleteResponse)
|
|
86
|
+
.annotateMerge(OpenApi.annotations({ identifier: "ltm.delete", summary: "Delete LTM memories by ID" })),
|
|
87
|
+
)
|
|
88
|
+
.add(
|
|
89
|
+
HttpApiEndpoint.get("status", `${root}/status`)
|
|
90
|
+
.addSuccess(Schema.Struct({ enabled: Schema.Boolean, memoryCount: Schema.Number }))
|
|
91
|
+
.annotateMerge(OpenApi.annotations({ identifier: "ltm.status", summary: "Get LTM status" })),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handlers/ltm.ts — LTM HTTP API handlers
|
|
3
|
+
*
|
|
4
|
+
* 외부 앱이 saeeol LTM에 기억을 저장/검색/삭제.
|
|
5
|
+
* 임베딩은 로컬 서버가 처리, 클라이언트는 텍스트만 보냄.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Effect } from "effect"
|
|
9
|
+
import * as Log from "@saeeol/core/util/log"
|
|
10
|
+
import * as HttpApiBuilder from "effect/unstable/httpapi"
|
|
11
|
+
import * as LTM from "@/ltm"
|
|
12
|
+
import * as Embedder from "@/provider/local/embedder"
|
|
13
|
+
import { LTMHttpApi } from "../groups/ltm"
|
|
14
|
+
|
|
15
|
+
const log = Log.create({ service: "server/handlers/ltm" })
|
|
16
|
+
|
|
17
|
+
export const ltmHandlers = HttpApiBuilder.group(LTMHttpApi, "ltm", (handlers) =>
|
|
18
|
+
Effect.gen(function* () {
|
|
19
|
+
const config = LTM.Config.DEFAULT_LTM_CONFIG
|
|
20
|
+
|
|
21
|
+
return handlers
|
|
22
|
+
.handle("ingest", ({ payload }) =>
|
|
23
|
+
Effect.gen(function* () {
|
|
24
|
+
if (!config.enabled) {
|
|
25
|
+
yield* log.info("LTM ingest skipped — disabled")
|
|
26
|
+
return HttpApiBuilder.fail({ status: 400 as const, body: { message: "LTM is disabled" } })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const embedderStatus = Embedder.status()
|
|
30
|
+
if (!embedderStatus || embedderStatus.status !== "running") {
|
|
31
|
+
yield* log.info("LTM ingest skipped — embedder not running")
|
|
32
|
+
return HttpApiBuilder.fail({ status: 503 as const, body: { message: "Embedding server not running" } })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const vector = yield* Effect.tryPromise(() => Embedder.embedOne(payload.summary))
|
|
36
|
+
const id = `${payload.type[0]}:${payload.projectID ?? "global"}:${Date.now()}`
|
|
37
|
+
const memory: LTM.Memory = {
|
|
38
|
+
id,
|
|
39
|
+
type: payload.type,
|
|
40
|
+
content: payload.content.slice(0, 2000),
|
|
41
|
+
summary: payload.summary,
|
|
42
|
+
vector,
|
|
43
|
+
metadata: {
|
|
44
|
+
source: `external:${payload.projectID ?? "unknown"}`,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
projectID: payload.projectID,
|
|
47
|
+
sessionID: payload.sessionID,
|
|
48
|
+
tags: payload.tags ?? [],
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
yield* Effect.tryPromise(() => LTM.Store.upsert(memory))
|
|
53
|
+
yield* log.info("LTM ingest", { id, type: payload.type })
|
|
54
|
+
|
|
55
|
+
return { id, success: true }
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
.handle("search", ({ payload }) =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
if (!config.enabled) {
|
|
61
|
+
return { memories: [], count: 0 }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const embedderStatus = Embedder.status()
|
|
65
|
+
if (!embedderStatus || embedderStatus.status !== "running") {
|
|
66
|
+
return { memories: [], count: 0 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const vector = yield* Effect.tryPromise(() => Embedder.embedOne(payload.query))
|
|
70
|
+
const memories = yield* Effect.tryPromise(() =>
|
|
71
|
+
LTM.Store.search(vector, {
|
|
72
|
+
topK: payload.topK ?? config.retrieval.topK,
|
|
73
|
+
minScore: payload.minScore ?? config.retrieval.minScore,
|
|
74
|
+
type: payload.type,
|
|
75
|
+
}),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// projectID 필터 (store 레벨에서 안 되면 여기서)
|
|
79
|
+
const filtered = payload.projectID
|
|
80
|
+
? memories.filter((m) => m.metadata.projectID === payload.projectID)
|
|
81
|
+
: memories
|
|
82
|
+
|
|
83
|
+
// vector는 응답에서 제거 (용량 절약)
|
|
84
|
+
const lean = filtered.map((m) => ({
|
|
85
|
+
id: m.id,
|
|
86
|
+
type: m.type,
|
|
87
|
+
summary: m.summary,
|
|
88
|
+
content: m.content,
|
|
89
|
+
score: m.score,
|
|
90
|
+
metadata: {
|
|
91
|
+
source: m.metadata.source,
|
|
92
|
+
timestamp: m.metadata.timestamp,
|
|
93
|
+
projectID: m.metadata.projectID,
|
|
94
|
+
tags: m.metadata.tags,
|
|
95
|
+
},
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
return { memories: lean, count: lean.length }
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
.handle("delete", ({ payload }) =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
yield* Effect.tryPromise(() => LTM.Store.remove(payload.ids))
|
|
104
|
+
return { removed: payload.ids.length }
|
|
105
|
+
}),
|
|
106
|
+
)
|
|
107
|
+
.handle("status", () =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
const embedder = Embedder.status()
|
|
110
|
+
const count = yield* Effect.tryPromise(() => LTM.Store.count())
|
|
111
|
+
return {
|
|
112
|
+
enabled: config.enabled && embedder?.status === "running",
|
|
113
|
+
memoryCount: count,
|
|
114
|
+
}
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
@@ -39,10 +39,11 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
|
|
|
39
39
|
(item, id) => Object.keys(item.models).length > 0 || id in connected || failedSet.has(id),
|
|
40
40
|
)
|
|
41
41
|
return {
|
|
42
|
-
all: Object.values(validProviders),
|
|
42
|
+
all: Object.values(validProviders).filter((p) => p.id !== "saeeol"),
|
|
43
43
|
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models ?? {}).length > 0)),
|
|
44
|
-
connected: Object.keys(connected),
|
|
45
|
-
failed,
|
|
44
|
+
connected: Object.keys(connected).filter((id) => id !== "saeeol"),
|
|
45
|
+
failed: failed.filter((id) => id !== "saeeol"),
|
|
46
|
+
gateway: validProviders["saeeol"] ?? null,
|
|
46
47
|
}
|
|
47
48
|
})
|
|
48
49
|
|
|
@@ -27,7 +27,9 @@ import { EventRoutes } from "./event"
|
|
|
27
27
|
import { SyncRoutes } from "./sync"
|
|
28
28
|
import { InstanceMiddleware } from "./middleware"
|
|
29
29
|
import { jsonRequest } from "./trace"
|
|
30
|
-
import { register as registerSaeeolRoutes } from "@/saeeol/server/instance"
|
|
30
|
+
import { register as registerSaeeolRoutes } from "@/saeeol/server/instance"
|
|
31
|
+
import * as LTM from "@/ltm"
|
|
32
|
+
import * as Embedder from "@/provider/local/embedder"
|
|
31
33
|
|
|
32
34
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|
33
35
|
const app = new Hono()
|
|
@@ -47,6 +49,99 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|
|
47
49
|
.route("/", EventRoutes())
|
|
48
50
|
.route("/mcp", McpRoutes())
|
|
49
51
|
.route("/tui", TuiRoutes())
|
|
52
|
+
// ── LTM (Long-Term Memory) ──
|
|
53
|
+
.post(
|
|
54
|
+
"/ltm/ingest",
|
|
55
|
+
describeRoute({
|
|
56
|
+
summary: "Store a memory in LTM",
|
|
57
|
+
operationId: "ltm.ingest",
|
|
58
|
+
responses: { 200: { description: "Memory stored" } },
|
|
59
|
+
}),
|
|
60
|
+
async (c) => {
|
|
61
|
+
const body = await c.req.json<{ type: "episodic" | "semantic" | "procedural"; content: string; summary: string; projectID?: string; sessionID?: string; tags?: string[] }>()
|
|
62
|
+
const embedderStatus = Embedder.status()
|
|
63
|
+
if (!embedderStatus || embedderStatus.status !== "running") {
|
|
64
|
+
return c.json({ error: "Embedding server not running" }, 503)
|
|
65
|
+
}
|
|
66
|
+
const vector = await Embedder.embedOne(body.summary)
|
|
67
|
+
const id = `${body.type[0]}:${body.projectID ?? "global"}:${Date.now()}`
|
|
68
|
+
const memory: LTM.Memory = {
|
|
69
|
+
id,
|
|
70
|
+
type: body.type,
|
|
71
|
+
content: body.content.slice(0, 2000),
|
|
72
|
+
summary: body.summary,
|
|
73
|
+
vector,
|
|
74
|
+
metadata: {
|
|
75
|
+
source: `external:${body.projectID ?? "unknown"}`,
|
|
76
|
+
timestamp: Date.now(),
|
|
77
|
+
projectID: body.projectID,
|
|
78
|
+
sessionID: body.sessionID,
|
|
79
|
+
tags: body.tags ?? [],
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
await LTM.Store.upsert(memory)
|
|
83
|
+
return c.json({ id, success: true })
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
.post(
|
|
87
|
+
"/ltm/search",
|
|
88
|
+
describeRoute({
|
|
89
|
+
summary: "Search LTM memories by query",
|
|
90
|
+
operationId: "ltm.search",
|
|
91
|
+
responses: { 200: { description: "Search results" } },
|
|
92
|
+
}),
|
|
93
|
+
async (c) => {
|
|
94
|
+
const body = await c.req.json<{ query: string; topK?: number; minScore?: number; type?: "episodic" | "semantic" | "procedural"; projectID?: string }>()
|
|
95
|
+
const embedderStatus = Embedder.status()
|
|
96
|
+
if (!embedderStatus || embedderStatus.status !== "running") {
|
|
97
|
+
return c.json({ memories: [], count: 0 })
|
|
98
|
+
}
|
|
99
|
+
const vector = await Embedder.embedOne(body.query)
|
|
100
|
+
const memories = await LTM.Store.search(vector, {
|
|
101
|
+
topK: body.topK ?? 5,
|
|
102
|
+
minScore: body.minScore ?? 0.7,
|
|
103
|
+
type: body.type,
|
|
104
|
+
})
|
|
105
|
+
const filtered = body.projectID
|
|
106
|
+
? memories.filter((m) => m.metadata.projectID === body.projectID)
|
|
107
|
+
: memories
|
|
108
|
+
const lean = filtered.map((m) => ({
|
|
109
|
+
id: m.id,
|
|
110
|
+
type: m.type,
|
|
111
|
+
summary: m.summary,
|
|
112
|
+
content: m.content,
|
|
113
|
+
score: m.score,
|
|
114
|
+
metadata: { source: m.metadata.source, timestamp: m.metadata.timestamp, projectID: m.metadata.projectID, tags: m.metadata.tags },
|
|
115
|
+
}))
|
|
116
|
+
return c.json({ memories: lean, count: lean.length })
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
.post(
|
|
120
|
+
"/ltm/delete",
|
|
121
|
+
describeRoute({
|
|
122
|
+
summary: "Delete LTM memories by ID",
|
|
123
|
+
operationId: "ltm.delete",
|
|
124
|
+
responses: { 200: { description: "Deleted" } },
|
|
125
|
+
}),
|
|
126
|
+
async (c) => {
|
|
127
|
+
const body = await c.req.json<{ ids: string[] }>()
|
|
128
|
+
await LTM.Store.remove(body.ids)
|
|
129
|
+
return c.json({ removed: body.ids.length })
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
.get(
|
|
133
|
+
"/ltm/status",
|
|
134
|
+
describeRoute({
|
|
135
|
+
summary: "Get LTM status",
|
|
136
|
+
operationId: "ltm.status",
|
|
137
|
+
responses: { 200: { description: "LTM status" } },
|
|
138
|
+
}),
|
|
139
|
+
async (c) => {
|
|
140
|
+
const embedder = Embedder.status()
|
|
141
|
+
const count = await LTM.Store.count()
|
|
142
|
+
return c.json({ enabled: embedder?.status === "running", memoryCount: count })
|
|
143
|
+
},
|
|
144
|
+
)
|
|
50
145
|
.post(
|
|
51
146
|
"/instance/dispose",
|
|
52
147
|
describeRoute({
|
|
@@ -61,10 +61,11 @@ export const ProviderRoutes = lazy(() =>
|
|
|
61
61
|
(item, id) => Object.keys(item.models ?? {}).length > 0 || id in connected || failedSet.has(id),
|
|
62
62
|
)
|
|
63
63
|
return {
|
|
64
|
-
all: Object.values(validProviders),
|
|
64
|
+
all: Object.values(validProviders).filter((p) => p.id !== "saeeol"),
|
|
65
65
|
default: Provider.defaultModelIDs(pickBy(validProviders, (item) => Object.keys(item.models ?? {}).length > 0)),
|
|
66
|
-
connected: Object.keys(connected),
|
|
67
|
-
failed,
|
|
66
|
+
connected: Object.keys(connected).filter((id) => id !== "saeeol"),
|
|
67
|
+
failed: failed.filter((id) => id !== "saeeol"),
|
|
68
|
+
gateway: validProviders["saeeol"] ?? null,
|
|
68
69
|
}
|
|
69
70
|
}),
|
|
70
71
|
)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tui-walkthrough-driver.ts — 공유 TUI PTY 드라이버
|
|
3
|
+
*
|
|
4
|
+
* 모든 walkthrough 테스트가 사용하는 공통 유틸리티.
|
|
5
|
+
* bun-pty 기반 TUI 제어 + 에러 감지.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { spawn as PtySpawn, type IPty } from "bun-pty"
|
|
9
|
+
import path from "path"
|
|
10
|
+
|
|
11
|
+
const BIN = path.resolve(import.meta.dir, "../../bin/saeeol.cjs")
|
|
12
|
+
|
|
13
|
+
// ── ANSI 제거 ──
|
|
14
|
+
|
|
15
|
+
export function stripAnsi(s: string): string {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
|
|
18
|
+
.replace(/\x1b\].*?\x07/g, "")
|
|
19
|
+
.replace(/\x1b\[.*?m/g, "")
|
|
20
|
+
.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f]/g, "")
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Known issues — 해결되면 배열에서 제거 ──
|
|
24
|
+
|
|
25
|
+
const KNOWN_ISSUES = [
|
|
26
|
+
/export named 'jsx' not found in module.*@opentui\/solid\/jsx-runtime/i,
|
|
27
|
+
/e=Export named 'jsx' not found.*exception/i,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function hasError(plain: string): string[] {
|
|
31
|
+
return plain
|
|
32
|
+
.split("\n")
|
|
33
|
+
.filter((l) => {
|
|
34
|
+
const t = l.trim()
|
|
35
|
+
if (!t) return false
|
|
36
|
+
if (KNOWN_ISSUES.some((re) => re.test(t))) return false
|
|
37
|
+
return (
|
|
38
|
+
/unhandled|TypeError|ReferenceError|SyntaxError|Cannot find module|Error:/i.test(t) ||
|
|
39
|
+
/exception/i.test(t) ||
|
|
40
|
+
/\be=\b.*not found/i.test(t) ||
|
|
41
|
+
/crashed|fatal|panic/i.test(t)
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── PTY 키 시퀀스 ──
|
|
47
|
+
|
|
48
|
+
export const Key = {
|
|
49
|
+
// 제어 문자
|
|
50
|
+
ctrl: (ch: string) => {
|
|
51
|
+
const code = ch.charCodeAt(0) - 96
|
|
52
|
+
return String.fromCharCode(code < 0 ? code + 64 : code)
|
|
53
|
+
},
|
|
54
|
+
esc: "\x1b",
|
|
55
|
+
enter: "\r",
|
|
56
|
+
tab: "\t",
|
|
57
|
+
backspace: "\x7f",
|
|
58
|
+
delete: "\x1b[3~",
|
|
59
|
+
// 화살표
|
|
60
|
+
up: "\x1b[A",
|
|
61
|
+
down: "\x1b[B",
|
|
62
|
+
right: "\x1b[C",
|
|
63
|
+
left: "\x1b[D",
|
|
64
|
+
// 기능키
|
|
65
|
+
f1: "\x1bOP",
|
|
66
|
+
f2: "\x1bOQ",
|
|
67
|
+
f3: "\x1bOR",
|
|
68
|
+
f4: "\x1bOS",
|
|
69
|
+
// 네비게이션
|
|
70
|
+
home: "\x1b[H",
|
|
71
|
+
end: "\x1b[F",
|
|
72
|
+
pageUp: "\x1b[5~",
|
|
73
|
+
pageDown: "\x1b[6~",
|
|
74
|
+
// 수정자 조합
|
|
75
|
+
ctrlUp: "\x1b[1;5A",
|
|
76
|
+
ctrlDown: "\x1b[1;5B",
|
|
77
|
+
ctrlRight: "\x1b[1;5C",
|
|
78
|
+
ctrlLeft: "\x1b[1;5D",
|
|
79
|
+
shiftUp: "\x1b[1;2A",
|
|
80
|
+
shiftDown: "\x1b[1;2B",
|
|
81
|
+
shiftRight: "\x1b[1;2C",
|
|
82
|
+
shiftLeft: "\x1b[1;2D",
|
|
83
|
+
shiftTab: "\x1b[Z",
|
|
84
|
+
altA: "\x1ba",
|
|
85
|
+
altE: "\x1be",
|
|
86
|
+
altF: "\x1bf",
|
|
87
|
+
altB: "\x1bb",
|
|
88
|
+
altD: "\x1bd",
|
|
89
|
+
// ctrl+조합
|
|
90
|
+
ctrlA: "\x01",
|
|
91
|
+
ctrlB: "\x02",
|
|
92
|
+
ctrlC: "\x03",
|
|
93
|
+
ctrlD: "\x04",
|
|
94
|
+
ctrlE: "\x05",
|
|
95
|
+
ctrlF: "\x06",
|
|
96
|
+
ctrlG: "\x07",
|
|
97
|
+
ctrlK: "\x0b",
|
|
98
|
+
ctrlN: "\x0e",
|
|
99
|
+
ctrlP: "\x10",
|
|
100
|
+
ctrlR: "\x12",
|
|
101
|
+
ctrlT: "\x14",
|
|
102
|
+
ctrlU: "\x15",
|
|
103
|
+
ctrlV: "\x16",
|
|
104
|
+
ctrlW: "\x17",
|
|
105
|
+
ctrlX: "\x18",
|
|
106
|
+
ctrlZ: "\x1a",
|
|
107
|
+
// ctrl+shift
|
|
108
|
+
ctrlShiftA: "\x1b[1;6A",
|
|
109
|
+
ctrlShiftD: "\x1b[1;6D",
|
|
110
|
+
ctrlShiftE: "\x1b[1;6E",
|
|
111
|
+
ctrlPageUp: "\x1b[5;5~",
|
|
112
|
+
ctrlPageDown: "\x1b[6;5~",
|
|
113
|
+
shiftF2: "\x1b[1;2Q",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── TUI 드라이버 ──
|
|
117
|
+
|
|
118
|
+
export class TuiDriver {
|
|
119
|
+
pty: IPty
|
|
120
|
+
output = ""
|
|
121
|
+
snapshots: Array<{ name: string; raw: string; plain: string }> = []
|
|
122
|
+
private exited = false
|
|
123
|
+
|
|
124
|
+
constructor(cwd: string, env?: Record<string, string>) {
|
|
125
|
+
this.pty = PtySpawn("node", [BIN, "tui"], {
|
|
126
|
+
name: "xterm-256color",
|
|
127
|
+
cols: 120,
|
|
128
|
+
rows: 30,
|
|
129
|
+
cwd,
|
|
130
|
+
env: {
|
|
131
|
+
...process.env,
|
|
132
|
+
TERM: "xterm-256color",
|
|
133
|
+
FORCE_COLOR: "1",
|
|
134
|
+
SAEEOL_LLM_PROVIDER: "custom",
|
|
135
|
+
SAEEOL_LLM_BASE_URL: "http://localhost:1",
|
|
136
|
+
SAEEOL_LLM_API_KEY: "test",
|
|
137
|
+
SAEEOL_LLM_MODEL: "test",
|
|
138
|
+
...env,
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
this.pty.onData((data) => {
|
|
142
|
+
this.output += data
|
|
143
|
+
})
|
|
144
|
+
this.pty.onExit(() => {
|
|
145
|
+
this.exited = true
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
write(s: string) {
|
|
150
|
+
this.pty.write(s)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async wait(ms: number) {
|
|
154
|
+
await Bun.sleep(ms)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
snapshot(name: string) {
|
|
158
|
+
const raw = this.output
|
|
159
|
+
const plain = stripAnsi(this.output)
|
|
160
|
+
this.snapshots.push({ name, raw, plain })
|
|
161
|
+
return { raw, plain }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
errors(): string[] {
|
|
165
|
+
return hasError(stripAnsi(this.output))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 마지막 스냅샷 이후 새로 발생한 에러만 */
|
|
169
|
+
newErrors(): string[] {
|
|
170
|
+
const prev = this.snapshots.length >= 2
|
|
171
|
+
? stripAnsi(this.snapshots[this.snapshots.length - 2].raw)
|
|
172
|
+
: ""
|
|
173
|
+
const curr = stripAnsi(this.output)
|
|
174
|
+
if (!prev) return hasError(curr)
|
|
175
|
+
// prev에 없던 에러 라인만
|
|
176
|
+
const prevSet = new Set(prev.split("\n").map((l) => l.trim()))
|
|
177
|
+
return hasError(curr).filter((l) => !prevSet.has(l.trim()))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async kill() {
|
|
181
|
+
try {
|
|
182
|
+
this.pty.kill()
|
|
183
|
+
} catch {
|
|
184
|
+
// process may have already exited
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
get isExited() {
|
|
189
|
+
return this.exited
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 특정 텍스트가 화면에 나타날 때까지 대기 */
|
|
193
|
+
async waitFor(text: string, timeoutMs = 10000): Promise<boolean> {
|
|
194
|
+
const start = Date.now()
|
|
195
|
+
while (Date.now() - start < timeoutMs) {
|
|
196
|
+
if (stripAnsi(this.output).includes(text)) return true
|
|
197
|
+
await Bun.sleep(200)
|
|
198
|
+
}
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** leader 키 (ctrl+x) + 후속 키 */
|
|
203
|
+
async leader(key: string, delay = 100) {
|
|
204
|
+
this.write(Key.ctrlX)
|
|
205
|
+
await this.wait(delay)
|
|
206
|
+
this.write(key)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 에스케이프로 다이얼로그 닫기 */
|
|
210
|
+
async dismiss(delay = 500) {
|
|
211
|
+
this.write(Key.esc)
|
|
212
|
+
await this.wait(delay)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 슬래시 명령어 실행 */
|
|
216
|
+
async slash(cmd: string, delay = 500) {
|
|
217
|
+
this.write(`/${cmd}`)
|
|
218
|
+
await this.wait(delay)
|
|
219
|
+
this.write(Key.enter)
|
|
220
|
+
await this.wait(1500)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 스냅샷 리포트 저장 */
|
|
224
|
+
async saveReport(filename: string) {
|
|
225
|
+
const reportPath = path.join(import.meta.dir, filename)
|
|
226
|
+
const report = this.snapshots
|
|
227
|
+
.map(
|
|
228
|
+
(s) =>
|
|
229
|
+
`=== ${s.name} (${s.raw.length} bytes) ===\n${s.plain.slice(-500)}`,
|
|
230
|
+
)
|
|
231
|
+
.join("\n\n")
|
|
232
|
+
await Bun.write(reportPath, report)
|
|
233
|
+
}
|
|
234
|
+
}
|