byuckchon-frontend-cli 1.1.0 → 1.4.0

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.
@@ -0,0 +1,75 @@
1
+ import path from 'node:path';
2
+
3
+ import { CONFIG_PATHS } from '../config/index.js';
4
+
5
+ /**
6
+ * 시스템 프롬프트를 만든다.
7
+ *
8
+ * 가능하면 bc.config.json 의 `framework` 와 `detected.*` 를 우선 사용해서
9
+ * 모델에게 정확한 스택을 알린다. 그게 없으면 보수적인 기본 가정만 박는다.
10
+ */
11
+ export function buildSystemPrompt({ effective, paths, project }) {
12
+ const lines = [
13
+ '너는 Byuckchon 프론트엔드 팀의 페어 프로그래밍 AI 다.',
14
+ '한국어로 친근하고 간결하게 답한다. 코드 답변은 마크다운 코드블록(언어 태그 포함)으로 준다.',
15
+ '추측 대신 모르면 모른다고 말한다. 파일 경로를 언급할 때는 백틱으로 감싼다.',
16
+ ];
17
+
18
+ const stack = describeStack(project);
19
+ if (stack) {
20
+ lines.push('', '프로젝트 스택:');
21
+ for (const line of stack) lines.push(' ' + line);
22
+ } else {
23
+ lines.push(
24
+ '',
25
+ '프로젝트 스택을 모르면 React 19 + TypeScript 라고 가정한다.',
26
+ );
27
+ }
28
+
29
+ const meta = [];
30
+ if (paths.projectFile) {
31
+ meta.push(
32
+ `- 설정 파일: \`${
33
+ path.relative(process.cwd(), paths.projectFile) || CONFIG_PATHS.projectFileName
34
+ }\``,
35
+ );
36
+ }
37
+ if (project?.design?.figma) meta.push(`- Figma: ${project.design.figma}`);
38
+ if (project?.api?.openapi) meta.push(`- OpenAPI: ${project.api.openapi}`);
39
+ if (project?.api?.baseUrl) meta.push(`- API base URL: ${project.api.baseUrl}`);
40
+ if (effective?.model) meta.push(`- 사용 모델: ${effective.model}`);
41
+
42
+ if (meta.length) {
43
+ lines.push('', '추가 메타:', ...meta);
44
+ }
45
+
46
+ return lines.join('\n');
47
+ }
48
+
49
+ function describeStack(project) {
50
+ if (!project) return null;
51
+
52
+ const fw = project.framework;
53
+ const d = project.detected;
54
+ if (!fw && !d) return null;
55
+
56
+ const out = [];
57
+ if (fw) out.push('- 프레임워크: ' + fw);
58
+ if (d?.language) out.push('- 언어: ' + (d.language === 'ts' ? 'TypeScript' : 'JavaScript'));
59
+ if (d?.styling) {
60
+ const s = Object.entries(d.styling)
61
+ .filter(([, v]) => v)
62
+ .map(([k]) => k);
63
+ if (s.length) out.push('- 스타일링: ' + s.join(', '));
64
+ }
65
+ if (d?.packageManager && d.packageManager !== 'unknown')
66
+ out.push('- 패키지 매니저: ' + d.packageManager);
67
+ if (d?.routing) out.push('- 라우팅: ' + d.routing);
68
+ if (d?.componentDirs?.length)
69
+ out.push('- 새 컴포넌트는 다음 위치 중 하나에 만든다: ' + d.componentDirs.join(', '));
70
+ if (d?.designTokensFiles?.length)
71
+ out.push('- 디자인 토큰 파일: ' + d.designTokensFiles.join(', ') + ' — 색/사이즈는 가능한 한 토큰 사용');
72
+ if (d?.hasStorybook) out.push('- Storybook 사용 중. 새 컴포넌트는 .stories.tsx 도 같이 제안.');
73
+ if (d?.hasTests) out.push('- 테스트 도구: ' + d.hasTests + '. 새 코드는 테스트도 같이 제안.');
74
+ return out;
75
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 한 chat 세션 동안 토큰/비용을 누적한다.
3
+ *
4
+ * AI SDK 의 streamText 결과에서는 `await result.usage` 로 input/output 토큰을
5
+ * 받을 수 있다. 이걸 모델 메타의 가격과 곱해 추정 비용을 계산한다.
6
+ *
7
+ * 캐시 히트는 SDK 가 별도 필드로 주는 경우가 있어서 들어오면 반영, 없으면 0.
8
+ */
9
+ export class TokenMeter {
10
+ constructor(modelMeta, limits = {}) {
11
+ this.meta = modelMeta;
12
+ this.limits = limits;
13
+ this.totals = {
14
+ input: 0,
15
+ output: 0,
16
+ cachedInput: 0,
17
+ requests: 0,
18
+ costUsd: 0,
19
+ };
20
+ }
21
+
22
+ add(usage) {
23
+ if (!usage) return;
24
+ const inputTokens = usage.promptTokens ?? usage.inputTokens ?? 0;
25
+ const outputTokens = usage.completionTokens ?? usage.outputTokens ?? 0;
26
+ const cached =
27
+ usage.cachedPromptTokens ?? usage.cachedInputTokens ?? 0;
28
+
29
+ const billableInput = Math.max(0, inputTokens - cached);
30
+ const p = this.meta.pricing ?? { input: 0, output: 0, cachedInput: 0 };
31
+
32
+ const cost =
33
+ (billableInput * p.input) / 1_000_000 +
34
+ (cached * (p.cachedInput ?? p.input)) / 1_000_000 +
35
+ (outputTokens * p.output) / 1_000_000;
36
+
37
+ this.totals.input += inputTokens;
38
+ this.totals.output += outputTokens;
39
+ this.totals.cachedInput += cached;
40
+ this.totals.requests += 1;
41
+ this.totals.costUsd += cost;
42
+ }
43
+
44
+ /** 다음 요청을 실제로 보낼지 사용자에게 물어봐야 하는지 여부. */
45
+ shouldConfirmNextRequest(estimatedInputTokens) {
46
+ const cap = this.limits?.confirmAtTokens ?? Infinity;
47
+ return estimatedInputTokens >= cap;
48
+ }
49
+
50
+ /** 누적이 경고선을 넘었는지. */
51
+ shouldWarn() {
52
+ const cap = this.limits?.warnAtTokens ?? Infinity;
53
+ return this.totals.input + this.totals.output >= cap;
54
+ }
55
+
56
+ format() {
57
+ const t = this.totals;
58
+ const fmt = (n) => n.toLocaleString('en-US');
59
+ const cost =
60
+ t.costUsd >= 0.01 ? `$${t.costUsd.toFixed(3)}` : `$${t.costUsd.toFixed(5)}`;
61
+ return `${this.meta.id} · ${fmt(t.input)} in / ${fmt(t.output)} out · cached ${fmt(
62
+ t.cachedInput,
63
+ )} · ${t.requests} req · ~${cost}`;
64
+ }
65
+ }
@@ -0,0 +1,146 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import chalk from 'chalk';
5
+ import inquirer from 'inquirer';
6
+
7
+ import { detectProjectContext, summarizeContext } from '../context/detect.js';
8
+ import { CONFIG_PATHS } from '../config/index.js';
9
+ import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
10
+
11
+ /**
12
+ * `bc adopt`
13
+ *
14
+ * 기존 프로젝트(현재 디렉터리)에 bc.config.json 만 살포시 깔아준다.
15
+ * 소스 코드는 절대 건드리지 않는다.
16
+ *
17
+ * 흐름:
18
+ * 1) 자동 감지(framework, styling, language, routing 등) 결과를 보여주고
19
+ * 2) 모델·Figma·OpenAPI URL 만 추가로 묻고
20
+ * 3) bc.config.json 생성/덮어쓰기 (이미 있으면 confirm)
21
+ */
22
+ export async function adoptCommand(opts = {}) {
23
+ const cwd = process.cwd();
24
+ const targetFile = path.join(cwd, CONFIG_PATHS.projectFileName);
25
+
26
+ console.log(chalk.bold.cyan('\n bc adopt — 기존 프로젝트에 bc 설정 추가\n'));
27
+
28
+ const ctx = await detectProjectContext(cwd);
29
+ if (!ctx.isProject) {
30
+ console.log(chalk.yellow(' ⚠ package.json 을 찾지 못했습니다.'));
31
+ console.log(chalk.dim(' 프로젝트 루트에서 실행해주세요. 아니면 새로 만들려면 `bc init`.\n'));
32
+ process.exit(1);
33
+ }
34
+
35
+ console.log(chalk.bold(' 감지된 프로젝트'));
36
+ console.log(` ${chalk.dim('이름 ')} ${ctx.pkg.name ?? '(이름 없음)'}`);
37
+ console.log(` ${chalk.dim('스택 ')} ${summarizeContext(ctx)}`);
38
+ if (ctx.componentDirs.length) {
39
+ console.log(` ${chalk.dim('컴포넌트 ')} ${ctx.componentDirs.join(', ')}`);
40
+ }
41
+ if (ctx.designTokensFiles.length) {
42
+ console.log(` ${chalk.dim('토큰파일 ')} ${ctx.designTokensFiles.join(', ')}`);
43
+ }
44
+ if (ctx.hasStorybook) console.log(` ${chalk.dim('스토리북 ')} 있음`);
45
+ if (ctx.hasTests) console.log(` ${chalk.dim('테스트 ')} ${ctx.hasTests}`);
46
+ console.log();
47
+
48
+ // 이미 bc.config.json 이 있으면 덮어쓰기 확인
49
+ let existing = null;
50
+ try {
51
+ existing = JSON.parse(await fs.readFile(targetFile, 'utf8'));
52
+ } catch (e) {
53
+ if (e.code !== 'ENOENT') {
54
+ console.log(chalk.yellow(' ⚠ 기존 bc.config.json 을 읽지 못했습니다 — 새로 작성합니다.'));
55
+ }
56
+ }
57
+
58
+ if (existing && !opts.force) {
59
+ const { ok } = await inquirer.prompt([
60
+ {
61
+ type: 'confirm',
62
+ name: 'ok',
63
+ message: 'bc.config.json 이 이미 있습니다. 새 값으로 머지할까요?',
64
+ default: true,
65
+ },
66
+ ]);
67
+ if (!ok) {
68
+ console.log(chalk.dim(' 취소했습니다.\n'));
69
+ return;
70
+ }
71
+ }
72
+
73
+ const answers = await inquirer.prompt([
74
+ {
75
+ type: 'list',
76
+ name: 'aiModel',
77
+ message: '이 프로젝트에서 기본으로 쓸 AI 모델은?',
78
+ choices: [
79
+ ...modelChoices(),
80
+ { name: '글로벌 기본값 따르기 (변경 없음)', value: null },
81
+ ],
82
+ default: existing?.ai?.model ?? DEFAULT_MODEL_ID,
83
+ },
84
+ {
85
+ type: 'input',
86
+ name: 'figmaUrl',
87
+ message: 'Figma 파일/노드 URL (선택, 엔터로 건너뛰기):',
88
+ default: existing?.design?.figma ?? '',
89
+ },
90
+ {
91
+ type: 'input',
92
+ name: 'openapiUrl',
93
+ message: '백엔드 OpenAPI(Swagger) URL (선택):',
94
+ default: existing?.api?.openapi ?? '',
95
+ },
96
+ {
97
+ type: 'input',
98
+ name: 'apiBaseUrl',
99
+ message: 'API base URL (선택):',
100
+ default: existing?.api?.baseUrl ?? '',
101
+ },
102
+ ]);
103
+
104
+ const next = {
105
+ $schema: 'https://byuckchon.dev/bc.schema.json',
106
+ ai: {
107
+ model: answers.aiModel ?? null,
108
+ },
109
+ design: {
110
+ figma: answers.figmaUrl?.trim() || null,
111
+ figmaTokenEnv: existing?.design?.figmaTokenEnv ?? 'FIGMA_TOKEN',
112
+ },
113
+ api: {
114
+ openapi: answers.openapiUrl?.trim() || null,
115
+ baseUrl: answers.apiBaseUrl?.trim() || null,
116
+ },
117
+ context: existing?.context ?? {
118
+ include: ['src/**/*.{ts,tsx,js,jsx}', 'app/**/*.{ts,tsx,js,jsx}', 'bc.config.json'],
119
+ exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**', '.next/**'],
120
+ maxFiles: 20,
121
+ },
122
+ framework: ctx.framework,
123
+ detected: {
124
+ language: ctx.language,
125
+ styling: ctx.styling,
126
+ packageManager: ctx.packageManager,
127
+ routing: ctx.routing,
128
+ componentDirs: ctx.componentDirs,
129
+ designTokensFiles: ctx.designTokensFiles,
130
+ hasStorybook: ctx.hasStorybook,
131
+ hasTests: ctx.hasTests,
132
+ detectedAt: new Date().toISOString(),
133
+ },
134
+ };
135
+
136
+ await fs.writeFile(targetFile, JSON.stringify(next, null, 2) + '\n', 'utf8');
137
+
138
+ console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
139
+ console.log(chalk.dim(` ${targetFile}\n`));
140
+ console.log(chalk.dim(' 다음:'));
141
+ if (!process.env.ANTHROPIC_API_KEY) {
142
+ console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
143
+ }
144
+ console.log(chalk.dim(' bc chat # 이 프로젝트 컨텍스트로 대화'));
145
+ console.log();
146
+ }
@@ -0,0 +1,300 @@
1
+ import process from 'node:process';
2
+
3
+ import chalk from 'chalk';
4
+ import { streamText } from 'ai';
5
+
6
+ import { loadEffectiveConfig } from '../config/index.js';
7
+ import { resolveModel } from '../ai/provider.js';
8
+ import { TokenMeter } from '../ai/tokenMeter.js';
9
+ import { buildSystemPrompt } from '../ai/systemPrompt.js';
10
+ import { findModel } from '../ai/models.js';
11
+ import {
12
+ createSession,
13
+ saveSession,
14
+ listSessions,
15
+ loadSession,
16
+ loadLatestSession,
17
+ } from '../history/store.js';
18
+ import { getCachedOpenApi } from '../openapi/cache.js';
19
+ import { summarizeOpenApi } from '../openapi/summary.js';
20
+
21
+ /**
22
+ * `bc chat`
23
+ *
24
+ * - TTY (사람이 직접 띄운 터미널) → ink 기반 풀 TUI
25
+ * - --once "질문" 또는 비-TTY (파이프/CI) → 단순 스트리밍 후 종료
26
+ * - --continue: 마지막 세션 이어가기
27
+ * - --resume <id>: 특정 세션 이어가기
28
+ * - --list-history: 저장된 세션 목록만 출력
29
+ */
30
+ export async function chatCommand(opts = {}) {
31
+ if (opts.listHistory) {
32
+ return printHistoryList();
33
+ }
34
+
35
+ let cfg;
36
+ try {
37
+ cfg = await loadEffectiveConfig();
38
+ } catch (err) {
39
+ console.error(chalk.red('설정을 읽을 수 없습니다: ') + err.message);
40
+ process.exit(1);
41
+ }
42
+
43
+ if (opts.model) {
44
+ if (!findModel(opts.model)) {
45
+ console.error(chalk.red(`알 수 없는 모델: ${opts.model}`));
46
+ console.error(chalk.dim(' bc config show 로 사용 가능한 모델을 확인하세요.'));
47
+ process.exit(1);
48
+ }
49
+ cfg.effective.model = opts.model;
50
+ }
51
+
52
+ let resolved;
53
+ try {
54
+ resolved = resolveModel(cfg.effective);
55
+ } catch (err) {
56
+ console.error('\n' + chalk.red(' ' + err.message) + '\n');
57
+ process.exit(1);
58
+ }
59
+
60
+ const baseSystem = buildSystemPrompt({
61
+ effective: cfg.effective,
62
+ paths: cfg.paths,
63
+ project: cfg.project,
64
+ });
65
+
66
+ // OpenAPI 자동 주입 — bc.config.json 의 api.openapi 가 있으면 fetch 후 요약을
67
+ // 시스템 프롬프트에 박는다. 1시간 캐시. 실패해도 chat 은 그대로 동작.
68
+ let openapiInfo = null;
69
+ let system = baseSystem;
70
+ if (cfg.effective.api?.openapi) {
71
+ try {
72
+ const res = await getCachedOpenApi(cfg.effective.api.openapi);
73
+ const summary = res.doc ? summarizeOpenApi(res.doc) : null;
74
+ if (summary) {
75
+ openapiInfo = {
76
+ source: cfg.effective.api.openapi,
77
+ cached: res.cached,
78
+ stale: !!res.stale,
79
+ summary,
80
+ };
81
+ system =
82
+ baseSystem +
83
+ '\n\n---\nOpenAPI 스펙 (자동 주입됨, 사용자가 별도로 명령 없이 알아서 사용 가능):\n' +
84
+ summary +
85
+ '\n\n사용자가 API 관련 코드를 요청하면 위 스펙을 우선 참조하라. ' +
86
+ '응답 타입이 필요하면 \`bc gen api-types\` 실행을 권하거나, ' +
87
+ '이미 \`*.gen.ts\` 가 있으면 그걸 import 해서 쓰라고 안내한다.';
88
+ }
89
+ } catch {
90
+ /* 비정상 URL/네트워크 실패 — 무시하고 계속. */
91
+ }
92
+ }
93
+
94
+ if (opts.once) {
95
+ return runOnce({ cfg, resolved, system, prompt: opts.once });
96
+ }
97
+
98
+ // 세션 결정 — 새로/이어가기/특정 id 복구.
99
+ let session;
100
+ if (opts.resume) {
101
+ try {
102
+ session = await loadSession(opts.resume);
103
+ console.log(
104
+ chalk.dim(` · 세션 ${session.id} 이어가기 (${session.messages.length} turns)\n`),
105
+ );
106
+ } catch (err) {
107
+ console.error(chalk.red('세션을 불러올 수 없습니다: ') + err.message);
108
+ process.exit(1);
109
+ }
110
+ } else if (opts.continueLast) {
111
+ try {
112
+ session = await loadLatestSession();
113
+ if (!session) {
114
+ console.log(chalk.dim(' · 이전 세션이 없습니다 — 새 세션으로 시작합니다.\n'));
115
+ } else {
116
+ console.log(
117
+ chalk.dim(` · 가장 최근 세션 ${session.id} 이어가기 (${session.messages.length} turns)\n`),
118
+ );
119
+ }
120
+ } catch {
121
+ /* 새 세션으로 폴백 */
122
+ }
123
+ }
124
+ if (!session) {
125
+ session = await createSession({ model: resolved.meta.id });
126
+ } else {
127
+ // 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
128
+ session.model = resolved.meta.id;
129
+ }
130
+ await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
131
+
132
+ // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
133
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
134
+ if (!isTTY || opts.plain) {
135
+ return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
136
+ }
137
+
138
+ return runInkApp({ cfg, resolved, system, session, openapiInfo });
139
+ }
140
+
141
+ async function printHistoryList() {
142
+ const list = await listSessions();
143
+ if (list.length === 0) {
144
+ console.log(chalk.dim('\n 저장된 세션이 없습니다.\n'));
145
+ return;
146
+ }
147
+ console.log(chalk.bold.cyan('\n 저장된 챗 세션'));
148
+ console.log(chalk.dim(' ─────────────────────────────────────────────────'));
149
+ for (const s of list) {
150
+ const when = s.updatedAt?.replace('T', ' ').slice(0, 19) ?? '';
151
+ console.log(
152
+ ` ${chalk.cyan(s.id)} ${chalk.dim(when)} ${s.turns} turns ${chalk.dim(s.model ?? '')}`,
153
+ );
154
+ console.log(` ${chalk.dim('└')} ${s.preview}`);
155
+ }
156
+ console.log();
157
+ console.log(chalk.dim(' 이어가기: bc chat --resume <id>\n'));
158
+ }
159
+
160
+ /* ───────────────────────── ink 모드 ───────────────────────── */
161
+
162
+ async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
163
+ // ink/React 는 무겁고 비-TTY 환경에서 import 만으로도 종종 문제 일으키므로
164
+ // 여기서 늦게 import 한다 (--once / pipe 모드에 영향 없도록).
165
+ const { render } = await import('ink');
166
+ const { ChatApp } = await import('../ui/ChatApp.js');
167
+ const React = (await import('react')).default;
168
+
169
+ const initialConfig = { ...cfg, system, openapiInfo };
170
+
171
+ const onSessionUpdate = async (messages) => {
172
+ session.messages = messages;
173
+ await saveSession(session);
174
+ };
175
+
176
+ const { waitUntilExit } = render(
177
+ React.createElement(ChatApp, {
178
+ initialConfig,
179
+ initialResolved: resolved,
180
+ session,
181
+ onSessionUpdate,
182
+ }),
183
+ { exitOnCtrlC: false },
184
+ );
185
+ await waitUntilExit();
186
+ }
187
+
188
+ /* ───────────────────────── --once 모드 ───────────────────────── */
189
+
190
+ async function runOnce({ cfg, resolved, system, prompt }) {
191
+ const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
192
+ const result = streamText({
193
+ model: resolved.model,
194
+ system,
195
+ messages: [{ role: 'user', content: prompt }],
196
+ onError: ({ error }) => {
197
+ console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
198
+ },
199
+ });
200
+ for await (const delta of result.textStream) process.stdout.write(delta);
201
+ process.stdout.write('\n');
202
+ try {
203
+ meter.add(await result.usage);
204
+ console.log(chalk.dim(' · ' + meter.format()));
205
+ } catch {
206
+ /* noop */
207
+ }
208
+ }
209
+
210
+ /* ──────────────────── 비-TTY / --plain 폴백 ──────────────────── */
211
+
212
+ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo }) {
213
+ const readline = await import('node:readline');
214
+ const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
215
+
216
+ console.log();
217
+ console.log(chalk.bold.cyan(' bc chat ') + chalk.dim('(plain mode)'));
218
+ console.log(chalk.dim(' 모델: ' + resolved.meta.label));
219
+ if (openapiInfo) {
220
+ console.log(
221
+ chalk.dim(' openapi: ' + openapiInfo.source + (openapiInfo.cached ? ' (cached)' : ' (live)')),
222
+ );
223
+ }
224
+ if (session?.messages?.length) {
225
+ console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
226
+ }
227
+ console.log(chalk.dim(' /exit 또는 Ctrl+D 로 종료\n'));
228
+
229
+ const rl = readline.createInterface({
230
+ input: process.stdin,
231
+ output: process.stdout,
232
+ terminal: !!process.stdin.isTTY,
233
+ prompt: chalk.bold.magenta(' you › '),
234
+ });
235
+ // 세션에 들어있던 메시지를 히스토리에 그대로 적재 (attachments 는 버림 — readline 모드에선 첨부 미지원).
236
+ const history = (session?.messages ?? [])
237
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
238
+ .map((m) => ({ role: m.role, content: m.text ?? '' }));
239
+ const ask = () => rl.prompt();
240
+
241
+ rl.on('close', () => {
242
+ console.log(chalk.dim('\n bye 👋\n'));
243
+ process.exit(0);
244
+ });
245
+ ask();
246
+
247
+ for await (const raw of rl) {
248
+ const line = raw.trim();
249
+ if (!line) {
250
+ ask();
251
+ continue;
252
+ }
253
+ if (line === '/exit' || line === '/quit') {
254
+ rl.close();
255
+ return;
256
+ }
257
+ history.push({ role: 'user', content: line });
258
+ rl.pause();
259
+
260
+ const result = streamText({
261
+ model: resolved.model,
262
+ system,
263
+ messages: history,
264
+ onError: ({ error }) => {
265
+ console.error(chalk.red('\n AI 호출 에러: ') + (error?.message ?? error));
266
+ },
267
+ });
268
+ process.stdout.write(chalk.bold.green('\n bc › '));
269
+ let acc = '';
270
+ try {
271
+ for await (const delta of result.textStream) {
272
+ acc += delta;
273
+ process.stdout.write(delta);
274
+ }
275
+ } catch (err) {
276
+ console.error('\n' + chalk.red(' 스트리밍 중단: ') + (err?.message ?? err));
277
+ }
278
+ process.stdout.write('\n');
279
+ try {
280
+ meter.add(await result.usage);
281
+ console.log(chalk.dim(' · ' + meter.format()));
282
+ } catch {
283
+ /* noop */
284
+ }
285
+ if (acc) history.push({ role: 'assistant', content: acc });
286
+
287
+ // 세션 자동 저장 — readline 모드에서도 끊김 대비.
288
+ if (session) {
289
+ session.messages = history.map((h) => ({ role: h.role, text: h.content }));
290
+ try {
291
+ await saveSession(session);
292
+ } catch {
293
+ /* noop */
294
+ }
295
+ }
296
+
297
+ rl.resume();
298
+ ask();
299
+ }
300
+ }