byuckchon-frontend-cli 1.9.0 → 1.9.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/README.md CHANGED
@@ -193,6 +193,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
193
193
 
194
194
  - **OpenAPI**: chat 시작 시 자동 fetch + 1시간 디스크 캐시 → 엔드포인트 요약을 시스템 프롬프트에 박음.
195
195
  - 헤더에 `openapi` 줄로 표시. 캐시 hit 면 `(cached)`, fresh fetch 면 `(live)`.
196
+ - **세션 중 서버가 스펙을 바꿔도 자동 대응 (v1.10+)**: `search_openapi` / `get_openapi_endpoint` 가
197
+ 캐시에서 엔드포인트를 못 찾으면 **딱 한 번 최신본을 다시 받아 재검색**합니다 (`🔄 OpenAPI 스펙 새로고침`).
198
+ 남용 방지를 위해 세션당 횟수·간격이 제한됩니다. "방금 스웨거 업데이트했어, 다시 읽어줘" 라고 하면
199
+ 즉시 강제 새로고침(`refresh_openapi`)합니다.
196
200
  - **코드 인덱스**: chat 시작 시 인덱스 파일이 없으면 **백그라운드에서 자동 빌드**.
197
201
  - 빌드 중에는 화면에 `📚 인덱싱 중 ...` 진행 표시. 끝나면 `✓` 메시지 한 줄.
198
202
  - OpenAI 키가 없으면 빌드를 건너뛰고 도움 메시지를 띄움 (Anthropic 은 임베딩 API 미제공).
@@ -284,6 +288,8 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
284
288
  | ------------------- | ------------------------------------------ |
285
289
  | `/help` | 도움말 |
286
290
  | `/clear` | 대화 컨텍스트 초기화 |
291
+ | `/history` | 이전 대화 선택 후 해당 컨텍스트 이어가기 |
292
+ | `/retry` | 마지막 사용자 요청 다시 실행 |
287
293
  | `/model [id]` | 세션 모델 변경 (인자 없으면 목록) |
288
294
  | `/cost` | 누적 토큰/비용 |
289
295
  | `/image <path>` | 다음 메시지에 이미지 첨부 (Vision 모델 권장) |
@@ -294,6 +300,10 @@ TTY 안에서 자동으로 ink 모드로 뜨고, 파이프/CI 같은 비-TTY 환
294
300
  | `/rag on\|off` | RAG 컨텍스트 주입 즉석 토글 |
295
301
  | `/exit` | 종료 (`Ctrl+C` 도 가능) |
296
302
 
303
+ Ink 모드에서 `/history`를 실행하면 `↑↓`로 세션을 선택하고 `Enter`로 불러올 수 있습니다.
304
+ 선택한 세션에서 `d`를 누른 뒤 `y`로 확인하면 해당 기록을 삭제합니다. 메시지를 한 번도
305
+ 보내지 않고 종료한 빈 세션은 저장되거나 목록에 표시되지 않습니다.
306
+
297
307
  이미지 첨부는 png / jpg / jpeg / gif / webp 만 지원하며,
298
308
  Claude / GPT 비전 모델에 멀티파트 메시지로 전달됩니다.
299
309
 
package/bin/index.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.9.0');
31
+ .version('1.10.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
package/src/ai/tools.js CHANGED
@@ -44,6 +44,39 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
44
44
  return _openapiDocPromise;
45
45
  }
46
46
 
47
+ // ── live refetch (서버가 세션 도중 스펙을 바꾼 경우 대비) ──
48
+ // 남용 방지: 세션당 최대 횟수 + 최소 간격 throttle.
49
+ const REFRESH_MAX = 6;
50
+ const REFRESH_MIN_INTERVAL_MS = 10_000;
51
+ let _refreshCount = 0;
52
+ let _lastRefreshAt = 0;
53
+
54
+ /**
55
+ * 캐시를 무시하고 OpenAPI 스펙을 다시 fetch 한다.
56
+ * @returns {Promise<{ doc: object|null, refreshed: boolean, reason?: string }>}
57
+ */
58
+ async function refreshOpenApiDoc() {
59
+ if (!openapiSource) return { doc: null, refreshed: false, reason: 'no-source' };
60
+
61
+ const now = Date.now();
62
+ if (_refreshCount >= REFRESH_MAX) {
63
+ return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'limit' };
64
+ }
65
+ if (now - _lastRefreshAt < REFRESH_MIN_INTERVAL_MS) {
66
+ return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'throttled' };
67
+ }
68
+
69
+ _refreshCount += 1;
70
+ _lastRefreshAt = now;
71
+ _openapiDocPromise = getCachedOpenApi(openapiSource, { force: true })
72
+ .then((res) => res.doc ?? null)
73
+ .catch(() => null);
74
+
75
+ const doc = await _openapiDocPromise;
76
+ onEvent({ kind: 'openapi_refreshed', ok: !!doc });
77
+ return { doc, refreshed: true };
78
+ }
79
+
47
80
  function safePath(p) {
48
81
  if (!p || typeof p !== 'string') {
49
82
  throw new Error('path 가 비어있습니다');
@@ -188,7 +221,11 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
188
221
  // ─────────── OpenAPI 툴 ───────────
189
222
 
190
223
  async function searchOpenApi({ query, limit = 40 }) {
191
- const doc = await loadOpenApiDoc();
224
+ let doc = await loadOpenApiDoc();
225
+ if (!doc) {
226
+ // 첫 로드 실패면 한 번 live refetch 시도.
227
+ ({ doc } = await refreshOpenApiDoc());
228
+ }
192
229
  if (!doc) {
193
230
  return {
194
231
  ok: false,
@@ -197,11 +234,25 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
197
234
  '(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
198
235
  };
199
236
  }
200
- const hits = searchEndpoints(doc, query, { limit });
237
+
238
+ let hits = searchEndpoints(doc, query, { limit });
239
+ let refreshed = false;
240
+
241
+ // 캐시된 스펙에서 못 찾으면 → 서버에서 방금 추가됐을 수 있으니 딱 한 번 live refetch 후 재검색.
242
+ if (hits.length === 0) {
243
+ const r = await refreshOpenApiDoc();
244
+ if (r.refreshed && r.doc) {
245
+ refreshed = true;
246
+ doc = r.doc;
247
+ hits = searchEndpoints(doc, query, { limit });
248
+ }
249
+ }
250
+
201
251
  return {
202
252
  ok: true,
203
253
  query,
204
254
  count: hits.length,
255
+ refreshed,
205
256
  endpoints: hits.map((e) => ({
206
257
  method: e.method,
207
258
  path: e.path,
@@ -210,17 +261,63 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
210
261
  })),
211
262
  hint:
212
263
  hits.length === 0
213
- ? '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.'
214
- : '상세 스키마가 필요하면 get_openapi_endpoint(path, method)호출하세요.',
264
+ ? (refreshed
265
+ ? '최신 스펙을 다시 받아왔는데도 매치가 없습니다. 다른 키워드로 재시도하거나 query 비워 전체 목록을 확인하세요.'
266
+ : '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.')
267
+ : (refreshed
268
+ ? '캐시엔 없던 항목을 최신 스펙에서 찾았습니다. 상세는 get_openapi_endpoint(path, method).'
269
+ : '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.'),
215
270
  };
216
271
  }
217
272
 
218
273
  async function getOpenApiEndpoint({ path: epPath, method }) {
219
- const doc = await loadOpenApiDoc();
274
+ let doc = await loadOpenApiDoc();
275
+ if (!doc) {
276
+ ({ doc } = await refreshOpenApiDoc());
277
+ }
220
278
  if (!doc) {
221
279
  return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
222
280
  }
223
- return getEndpoint(doc, epPath, method);
281
+
282
+ let result = getEndpoint(doc, epPath, method);
283
+
284
+ // 못 찾으면 → 최신 스펙으로 한 번 더.
285
+ if (!result.ok) {
286
+ const r = await refreshOpenApiDoc();
287
+ if (r.refreshed && r.doc) {
288
+ const retry = getEndpoint(r.doc, epPath, method);
289
+ if (retry.ok) result = { ...retry, refreshed: true };
290
+ else result = { ...retry, refreshed: true };
291
+ }
292
+ }
293
+ return result;
294
+ }
295
+
296
+ async function refreshOpenApi() {
297
+ if (!openapiSource) {
298
+ return { ok: false, error: 'bc.config.json 에 api.openapi 가 설정되어 있지 않습니다.' };
299
+ }
300
+ const r = await refreshOpenApiDoc();
301
+ if (!r.doc) {
302
+ return {
303
+ ok: false,
304
+ refreshed: r.refreshed,
305
+ error:
306
+ r.reason === 'throttled'
307
+ ? '방금 새로고침했습니다. 잠시 후 다시 시도하세요.'
308
+ : '스펙을 다시 받아오지 못했습니다 (네트워크/URL 확인).',
309
+ };
310
+ }
311
+ const count = (r.doc.paths ? Object.keys(r.doc.paths).length : 0);
312
+ return {
313
+ ok: true,
314
+ refreshed: r.refreshed,
315
+ reason: r.refreshed ? undefined : r.reason,
316
+ paths: count,
317
+ message: r.refreshed
318
+ ? `최신 OpenAPI 스펙을 다시 받아왔습니다 (path ${count}개).`
319
+ : '최근에 이미 새로고침되어 캐시를 재사용했습니다.',
320
+ };
224
321
  }
225
322
 
226
323
  // ─────────── Figma 툴 ───────────
@@ -381,6 +478,20 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
381
478
  }),
382
479
  execute: getOpenApiEndpoint,
383
480
  }),
481
+ refresh_openapi: tool({
482
+ description:
483
+ '연결된 OpenAPI(Swagger) 스펙을 캐시 무시하고 서버에서 다시 받아온다. ' +
484
+ '사용자가 "방금 스웨거(백엔드 API) 를 업데이트했다 / 다시 읽어라" 라고 하거나, ' +
485
+ 'search_openapi 가 분명히 있어야 할 엔드포인트를 못 찾을 때 호출. ' +
486
+ '(search_openapi / get_openapi_endpoint 는 못 찾으면 자동으로 한 번 새로고침하므로, ' +
487
+ '명시적 요청이 있을 때만 직접 부르면 된다.)',
488
+ inputSchema: jsonSchema({
489
+ type: 'object',
490
+ properties: {},
491
+ additionalProperties: false,
492
+ }),
493
+ execute: refreshOpenApi,
494
+ }),
384
495
  write_file: tool({
385
496
  description:
386
497
  '새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
@@ -17,10 +17,12 @@ import { printInlineThumbnail } from '../ai/imagePreview.js';
17
17
  import {
18
18
  createSession,
19
19
  saveSession,
20
+ deleteSession,
20
21
  listSessions,
21
22
  loadSession,
22
23
  loadLatestSession,
23
24
  } from '../history/store.js';
25
+ import { findLastRetryableUser, formatSessionList } from '../history/management.js';
24
26
  import { getCachedOpenApi } from '../openapi/cache.js';
25
27
  import { summarizeOpenApi } from '../openapi/summary.js';
26
28
 
@@ -119,6 +121,9 @@ export async function chatCommand(opts = {}) {
119
121
  '가져와서 zod/타입/요청 함수를 만든다. ' +
120
122
  '예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
121
123
  '`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
124
+ '스펙은 캐시(최대 1시간)라 서버가 방금 바꿨으면 오래됐을 수 있다 — ' +
125
+ 'search_openapi/get_openapi_endpoint 는 못 찾으면 자동으로 한 번 최신본을 다시 받아온다. ' +
126
+ '사용자가 "방금 스웨거 업데이트했어/다시 읽어" 라고 하면 `refresh_openapi()` 를 먼저 호출한다. ' +
122
127
  '이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
123
128
  }
124
129
  } catch {
@@ -162,8 +167,6 @@ export async function chatCommand(opts = {}) {
162
167
  // 모델은 사용자가 명시했거나 글로벌 설정으로 갱신 가능 — 세션의 model 은 표시용.
163
168
  session.model = resolved.meta.id;
164
169
  }
165
- await saveSession(session); // 빈 파일이라도 디스크에 만들어둠
166
-
167
170
  // ink 는 stdin/stdout 둘 다 TTY 이어야 정상 동작.
168
171
  // - --plain 플래그가 명시되거나 비-TTY 면 readline 폴백.
169
172
  // - 글로벌 ui.mode 가 "plain" 이면 한글 IME 가 깨지는 케이스를 자동 회피.
@@ -206,9 +209,36 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
206
209
 
207
210
  const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
208
211
 
212
+ let activeSession = session;
213
+
209
214
  const onSessionUpdate = async (messages) => {
210
- session.messages = messages;
211
- await saveSession(session);
215
+ const target = activeSession;
216
+ const hasConversation = messages.some(
217
+ (message) =>
218
+ (message.role === 'user' || message.role === 'assistant') &&
219
+ typeof message.text === 'string' &&
220
+ message.text.trim(),
221
+ );
222
+ if (!hasConversation && !target._persisted) return;
223
+ target.messages = messages;
224
+ await saveSession(target);
225
+ };
226
+
227
+ const onSessionSwitch = async (id) => {
228
+ const loaded = await loadSession(id);
229
+ loaded.model = resolved.meta.id;
230
+ activeSession = loaded;
231
+ return loaded;
232
+ };
233
+
234
+ const onSessionDelete = async (id) => {
235
+ const deletingActive = activeSession.id === id;
236
+ const result = await deleteSession(id);
237
+ if (!deletingActive) return { ...result, replacementSession: null };
238
+
239
+ const replacementSession = await createSession({ model: resolved.meta.id });
240
+ activeSession = replacementSession;
241
+ return { ...result, replacementSession };
212
242
  };
213
243
 
214
244
  const { waitUntilExit } = render(
@@ -217,6 +247,8 @@ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventi
217
247
  initialResolved: resolved,
218
248
  session,
219
249
  onSessionUpdate,
250
+ onSessionSwitch,
251
+ onSessionDelete,
220
252
  }),
221
253
  { exitOnCtrlC: false },
222
254
  );
@@ -235,6 +267,12 @@ async function runOnce({ cfg, resolved, system, prompt }) {
235
267
  effective: cfg.effective,
236
268
  openapiSource: cfg.effective.api?.openapi ?? null,
237
269
  onEvent: (ev) => {
270
+ if (ev.kind === 'openapi_refreshed') {
271
+ console.log(
272
+ chalk.dim(ev.ok ? ' 🔄 OpenAPI 스펙 새로고침' : ' ⚠️ OpenAPI 새로고침 실패'),
273
+ );
274
+ return;
275
+ }
238
276
  const label =
239
277
  ev.kind === 'write_created'
240
278
  ? '🆕'
@@ -291,7 +329,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
291
329
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
292
330
  }
293
331
  console.log(
294
- chalk.dim(' /image <경로> · /paste(클립보드 이미지) · /clear-attach · /exit\n'),
332
+ chalk.dim(
333
+ ' /history · /retry · /image <경로> · /paste · /clear-attach · /exit\n',
334
+ ),
295
335
  );
296
336
 
297
337
  // 다음 메시지에 함께 보낼 이미지 첨부 목록.
@@ -341,6 +381,21 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
341
381
  const history = (session?.messages ?? [])
342
382
  .filter((m) => m.role === 'user' || m.role === 'assistant')
343
383
  .map((m) => ({ role: m.role, content: m.text ?? '' }));
384
+ const persistHistory = async () => {
385
+ if (!session) return;
386
+ session.messages = history.map((message) => {
387
+ // 이미지 바이트는 세션 파일에 저장하지 않고 사용자 텍스트만 보존한다.
388
+ if (Array.isArray(message.content)) {
389
+ const textPart = message.content.find((part) => part.type === 'text');
390
+ return {
391
+ role: message.role,
392
+ text: textPart?.text ?? '[이미지 첨부]',
393
+ };
394
+ }
395
+ return { role: message.role, text: message.content };
396
+ });
397
+ await saveSession(session);
398
+ };
344
399
  const ask = () => rl.prompt();
345
400
 
346
401
  rl.on('close', () => {
@@ -359,6 +414,29 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
359
414
  rl.close();
360
415
  return;
361
416
  }
417
+ if (line === '/history') {
418
+ try {
419
+ const sessions = await listSessions();
420
+ console.log(chalk.dim('\n' + formatSessionList(sessions)));
421
+ console.log(chalk.dim('\n 이어가기: bc chat --resume <id>'));
422
+ } catch (err) {
423
+ console.log(chalk.red(' 대화 목록을 불러올 수 없습니다: ' + err.message));
424
+ }
425
+ ask();
426
+ continue;
427
+ }
428
+ let retryContent = null;
429
+ if (line === '/retry') {
430
+ const retry = findLastRetryableUser(history);
431
+ if (!retry) {
432
+ console.log(chalk.red(' 다시 실행할 사용자 요청이 없습니다.'));
433
+ ask();
434
+ continue;
435
+ }
436
+ retryContent = retry.message.content;
437
+ history.splice(retry.index);
438
+ console.log(chalk.dim(' 마지막 요청을 다시 실행합니다.'));
439
+ }
362
440
  // 이미지 첨부 관련 슬래시 명령 — plain 모드에서도 지원.
363
441
  if (line.startsWith('/image')) {
364
442
  await addImage(line.slice('/image'.length).trim());
@@ -388,7 +466,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
388
466
  }
389
467
 
390
468
  // 일반 메시지 — 첨부가 있으면 멀티모달 content 로 구성.
391
- if (pendingAttachments.length) {
469
+ if (retryContent != null) {
470
+ history.push({ role: 'user', content: retryContent });
471
+ } else if (pendingAttachments.length) {
392
472
  const parts = [{ type: 'text', text: line }];
393
473
  try {
394
474
  for (const att of pendingAttachments) {
@@ -403,6 +483,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
403
483
  } else {
404
484
  history.push({ role: 'user', content: line });
405
485
  }
486
+ try {
487
+ await persistHistory();
488
+ } catch {
489
+ /* 저장 실패가 AI 요청을 막지는 않게 한다. */
490
+ }
406
491
  rl.pause();
407
492
 
408
493
  const projectRoot = cfg.paths.projectFile
@@ -413,6 +498,12 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
413
498
  effective: cfg.effective,
414
499
  openapiSource: cfg.effective.api?.openapi ?? null,
415
500
  onEvent: (ev) => {
501
+ if (ev.kind === 'openapi_refreshed') {
502
+ console.log(
503
+ chalk.dim(ev.ok ? '\n 🔄 OpenAPI 스펙 새로고침' : '\n ⚠️ OpenAPI 새로고침 실패'),
504
+ );
505
+ return;
506
+ }
416
507
  const label =
417
508
  ev.kind === 'write_created'
418
509
  ? '🆕 생성'
@@ -457,21 +548,11 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
457
548
  }
458
549
  if (acc) history.push({ role: 'assistant', content: acc });
459
550
 
460
- // 세션 자동 저장 readline 모드에서도 끊김 대비.
461
- if (session) {
462
- session.messages = history.map((h) => {
463
- // 멀티모달(content 가 배열) 인 경우 text 파트만 추출해 저장 (이미지 바이트는 세션에 안 남김).
464
- if (Array.isArray(h.content)) {
465
- const textPart = h.content.find((p) => p.type === 'text');
466
- return { role: h.role, text: textPart?.text ?? '[이미지 첨부]' };
467
- }
468
- return { role: h.role, text: h.content };
469
- });
470
- try {
471
- await saveSession(session);
472
- } catch {
473
- /* noop */
474
- }
551
+ // 응답까지 포함한 최신 상태로 다시 저장한다.
552
+ try {
553
+ await persistHistory();
554
+ } catch {
555
+ /* noop */
475
556
  }
476
557
 
477
558
  rl.resume();
@@ -0,0 +1,30 @@
1
+ function messageText(message) {
2
+ if (typeof message?.text === 'string') return message.text;
3
+ if (typeof message?.content === 'string') return message.content;
4
+ if (Array.isArray(message?.content)) {
5
+ return message.content.find((part) => part.type === 'text')?.text ?? '[이미지 첨부]';
6
+ }
7
+ return '';
8
+ }
9
+
10
+ export function findLastRetryableUser(messages) {
11
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
12
+ const message = messages[index];
13
+ if (message.role === 'user' && messageText(message).trim()) {
14
+ return { index, message, text: messageText(message) };
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+
20
+ export function formatSessionList(sessions, { limit = 10 } = {}) {
21
+ if (!sessions.length) return '저장된 이전 세션이 없습니다.';
22
+ return sessions
23
+ .slice(0, limit)
24
+ .map((session) => {
25
+ const when = session.updatedAt?.replace('T', ' ').slice(0, 19) ?? '';
26
+ const preview = session.preview || '(빈 세션)';
27
+ return `${session.id} ${when} ${session.turns} turns\n ${preview}`;
28
+ })
29
+ .join('\n');
30
+ }
@@ -49,7 +49,7 @@ export async function createSession({ model, cwd = process.cwd() } = {}) {
49
49
  cwd,
50
50
  messages: [],
51
51
  };
52
- return { ...session, _file: path.join(dir, `${id}.json`) };
52
+ return { ...session, _file: path.join(dir, `${id}.json`), _persisted: false };
53
53
  }
54
54
 
55
55
  export async function saveSession(session) {
@@ -64,6 +64,7 @@ export async function saveSession(session) {
64
64
  messages: session.messages,
65
65
  };
66
66
  await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
67
+ session._persisted = true;
67
68
  }
68
69
 
69
70
  export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
@@ -77,19 +78,27 @@ export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
77
78
  }
78
79
  const files = entries.filter((f) => f.endsWith('.json')).sort().reverse();
79
80
  const out = [];
80
- for (const name of files.slice(0, limit)) {
81
+ for (const name of files) {
82
+ if (out.length >= limit) break;
81
83
  const file = path.join(dir, name);
82
84
  try {
83
85
  const raw = await fs.readFile(file, 'utf8');
84
86
  const data = JSON.parse(raw);
85
- const firstUser = data.messages?.find((m) => m.role === 'user');
87
+ const conversation = (data.messages ?? []).filter(
88
+ (message) =>
89
+ (message.role === 'user' || message.role === 'assistant') &&
90
+ typeof message.text === 'string' &&
91
+ message.text.trim(),
92
+ );
93
+ if (conversation.length === 0) continue;
94
+ const firstUser = conversation.find((message) => message.role === 'user');
86
95
  out.push({
87
96
  id: data.id,
88
97
  file,
89
98
  startedAt: data.startedAt,
90
99
  updatedAt: data.updatedAt,
91
100
  model: data.model,
92
- turns: data.messages?.length ?? 0,
101
+ turns: conversation.length,
93
102
  preview: firstUser?.text?.slice(0, 60) ?? '(빈 세션)',
94
103
  });
95
104
  } catch {
@@ -107,7 +116,7 @@ export async function loadSession(idOrFile, cwd = process.cwd()) {
107
116
  }
108
117
  const raw = await fs.readFile(file, 'utf8');
109
118
  const data = JSON.parse(raw);
110
- return { ...data, _file: file };
119
+ return { ...data, _file: file, _persisted: true };
111
120
  }
112
121
 
113
122
  export async function loadLatestSession(cwd = process.cwd()) {
@@ -115,3 +124,22 @@ export async function loadLatestSession(cwd = process.cwd()) {
115
124
  if (list.length === 0) return null;
116
125
  return loadSession(list[0].id, cwd);
117
126
  }
127
+
128
+ export async function deleteSession(id, cwd = process.cwd()) {
129
+ const normalized = String(id ?? '').replace(/\.json$/, '');
130
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
131
+ const error = new Error(`잘못된 세션 ID: ${id}`);
132
+ error.code = 'BC_INVALID_SESSION_ID';
133
+ throw error;
134
+ }
135
+
136
+ const dir = await getHistoryDir(cwd);
137
+ const file = path.join(dir, `${normalized}.json`);
138
+ try {
139
+ await fs.unlink(file);
140
+ return { id: normalized, file, deleted: true };
141
+ } catch (error) {
142
+ if (error.code === 'ENOENT') return { id: normalized, file, deleted: false };
143
+ throw error;
144
+ }
145
+ }
@@ -23,8 +23,13 @@ function keyFor(input) {
23
23
  *
24
24
  * - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
25
25
  * - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
26
+ *
27
+ * @param {string} input OpenAPI URL 또는 파일 경로
28
+ * @param {object} [opts]
29
+ * @param {boolean} [opts.force] true 면 TTL 무시하고 무조건 live refetch (캐시 갱신).
30
+ * 서버가 세션 도중 스펙을 바꿨을 때 사용.
26
31
  */
27
- export async function getCachedOpenApi(input) {
32
+ export async function getCachedOpenApi(input, { force = false } = {}) {
28
33
  const dir = await getCacheDir();
29
34
  const file = path.join(dir, `openapi-${keyFor(input)}.json`);
30
35
 
@@ -39,7 +44,7 @@ export async function getCachedOpenApi(input) {
39
44
  /* miss */
40
45
  }
41
46
 
42
- if (cachedFresh && cached) {
47
+ if (!force && cachedFresh && cached) {
43
48
  return { doc: cached, cached: true, source: input };
44
49
  }
45
50
 
package/src/ui/ChatApp.js CHANGED
@@ -14,6 +14,8 @@ import { toSdkMessages, isImagePath } from '../ai/messageContent.js';
14
14
  import { buildTools } from '../ai/tools.js';
15
15
  import { searchIndex } from '../indexer/search.js';
16
16
  import { loadIndex, buildIndex } from '../indexer/store.js';
17
+ import { listSessions } from '../history/store.js';
18
+ import { findLastRetryableUser } from '../history/management.js';
17
19
 
18
20
  const h = React.createElement;
19
21
 
@@ -287,6 +289,8 @@ function AttachBar({ pending }) {
287
289
  const SLASH_COMMANDS = [
288
290
  { cmd: '/help', hint: '', desc: '명령 도움말' },
289
291
  { cmd: '/clear', hint: '', desc: '대화 컨텍스트 비우기' },
292
+ { cmd: '/history', hint: '', desc: '프로젝트의 이전 대화 목록' },
293
+ { cmd: '/retry', hint: '', desc: '마지막 사용자 요청 다시 실행' },
290
294
  { cmd: '/model', hint: '<id>', desc: '세션 모델 변경 (인자 없으면 목록)' },
291
295
  { cmd: '/cost', hint: '', desc: '누적 토큰/비용' },
292
296
  { cmd: '/image', hint: '<path>', desc: '이미지 첨부 (Finder 에서 끌어다 놔도 됨)' },
@@ -350,7 +354,45 @@ function SlashMenu({ items, activeIndex }) {
350
354
  );
351
355
  }
352
356
 
353
- export function ChatApp({ initialConfig, initialResolved, session, onSessionUpdate }) {
357
+ function HistoryMenu({ items, activeIndex, activeSessionId, deleteId }) {
358
+ if (items.length === 0) return null;
359
+ return h(
360
+ Box,
361
+ {
362
+ flexDirection: 'column',
363
+ borderStyle: 'single',
364
+ borderColor: 'gray',
365
+ paddingX: 1,
366
+ },
367
+ h(Text, { bold: true }, '이전 대화'),
368
+ ...items.map((item, index) => {
369
+ const active = index === activeIndex;
370
+ const current = item.id === activeSessionId;
371
+ const when = item.updatedAt?.replace('T', ' ').slice(0, 16) ?? '';
372
+ return h(
373
+ Text,
374
+ {
375
+ key: item.id,
376
+ color: item.id === deleteId ? 'red' : active ? 'cyan' : undefined,
377
+ bold: active,
378
+ },
379
+ `${active ? '›' : ' '} ${item.id}${current ? ' (현재)' : ''} ${when} ${item.turns} turns\n ${item.preview}`,
380
+ );
381
+ }),
382
+ deleteId
383
+ ? h(Text, { color: 'red' }, `${deleteId} 세션을 삭제할까요? y/n`)
384
+ : h(Text, { dimColor: true }, '↑↓ 선택 · Enter 불러오기 · d 삭제 · Esc 취소'),
385
+ );
386
+ }
387
+
388
+ export function ChatApp({
389
+ initialConfig,
390
+ initialResolved,
391
+ session,
392
+ onSessionUpdate,
393
+ onSessionSwitch,
394
+ onSessionDelete,
395
+ }) {
354
396
  const app = useApp();
355
397
  const { stdout } = useStdout();
356
398
  const [cfg, setCfg] = useState(initialConfig);
@@ -380,12 +422,17 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
380
422
  const [indexProgress, setIndexProgress] = useState('');
381
423
  const [ragEnabled, setRagEnabled] = useState(true);
382
424
  const [menuIndex, setMenuIndex] = useState(0);
425
+ const [historyItems, setHistoryItems] = useState([]);
426
+ const [historyIndex, setHistoryIndex] = useState(0);
427
+ const [historyDeleteId, setHistoryDeleteId] = useState(null);
428
+ const [activeSessionId, setActiveSessionId] = useState(session?.id ?? null);
383
429
  const [, force] = useState(0);
384
430
  const rerender = useCallback(() => force((n) => n + 1), []);
385
431
 
386
432
  // 슬래시 메뉴: input 상태에 따라 동적으로 계산.
387
433
  const slashItems = state === 'idle' ? filterSlashCommands(input) : [];
388
434
  const slashOpen = slashItems.length > 0;
435
+ const historyOpen = state === 'idle' && historyItems.length > 0;
389
436
  // input 이 바뀌면 선택 인덱스를 0 으로 리셋 (필터 변경 시 자연스럽게).
390
437
  useEffect(() => {
391
438
  setMenuIndex(0);
@@ -507,6 +554,29 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
507
554
  app.exit();
508
555
  return;
509
556
  }
557
+ if (historyOpen) {
558
+ if (historyDeleteId) {
559
+ if (char.toLowerCase() === 'y') {
560
+ void deleteHistorySession();
561
+ } else if (char.toLowerCase() === 'n' || key.escape) {
562
+ setHistoryDeleteId(null);
563
+ }
564
+ return;
565
+ }
566
+ if (key.upArrow) {
567
+ setHistoryIndex((index) => Math.max(0, index - 1));
568
+ } else if (key.downArrow) {
569
+ setHistoryIndex((index) => Math.min(historyItems.length - 1, index + 1));
570
+ } else if (key.escape) {
571
+ setHistoryItems([]);
572
+ } else if (char.toLowerCase() === 'd') {
573
+ const selected = historyItems[historyIndex];
574
+ if (selected) setHistoryDeleteId(selected.id);
575
+ } else if (key.return) {
576
+ void selectHistorySession();
577
+ }
578
+ return;
579
+ }
510
580
  if (!slashOpen) return;
511
581
  if (key.upArrow) {
512
582
  setMenuIndex((i) => Math.max(0, i - 1));
@@ -552,6 +622,33 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
552
622
  pushSystemInfo('대화 컨텍스트를 비웠습니다.');
553
623
  return true;
554
624
  }
625
+ if (cmd === 'history') {
626
+ try {
627
+ const sessions = await listSessions();
628
+ if (!sessions.length) {
629
+ pushSystemInfo('저장된 이전 세션이 없습니다.');
630
+ return true;
631
+ }
632
+ setHistoryItems(sessions.slice(0, 10));
633
+ setHistoryIndex(0);
634
+ setHistoryDeleteId(null);
635
+ } catch (err) {
636
+ pushSystemError('대화 목록을 불러올 수 없습니다: ' + err.message);
637
+ }
638
+ return true;
639
+ }
640
+ if (cmd === 'retry') {
641
+ const retry = findLastRetryableUser(messages);
642
+ if (!retry) {
643
+ pushSystemError('다시 실행할 사용자 요청이 없습니다.');
644
+ return true;
645
+ }
646
+ await sendMessage(retry.text, {
647
+ baseMessages: messages.slice(0, retry.index),
648
+ attachments: retry.message.attachments ?? [],
649
+ });
650
+ return true;
651
+ }
555
652
  if (cmd === 'cost') {
556
653
  pushSystemInfo(meterRef.current.format());
557
654
  return true;
@@ -656,13 +753,70 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
656
753
  return true;
657
754
  };
658
755
 
659
- const sendMessage = async (text) => {
756
+ const selectHistorySession = async () => {
757
+ const selected = historyItems[historyIndex];
758
+ if (!selected || !onSessionSwitch) return;
759
+
760
+ setHistoryItems([]);
761
+ setState('thinking');
762
+ try {
763
+ const loaded = await onSessionSwitch(selected.id);
764
+ setActiveSessionId(loaded.id);
765
+ setPendingAttachments([]);
766
+ setMessages([
767
+ ...(loaded.messages ?? []),
768
+ {
769
+ role: 'system-info',
770
+ text: `세션 ${loaded.id} 컨텍스트를 불러왔습니다.`,
771
+ },
772
+ ]);
773
+ } catch (err) {
774
+ pushSystemError('세션을 불러올 수 없습니다: ' + (err?.message ?? String(err)));
775
+ } finally {
776
+ setState('idle');
777
+ }
778
+ };
779
+
780
+ const deleteHistorySession = async () => {
781
+ const id = historyDeleteId;
782
+ if (!id || !onSessionDelete) return;
783
+
784
+ setHistoryDeleteId(null);
785
+ try {
786
+ const result = await onSessionDelete(id);
787
+ const remaining = historyItems.filter((item) => item.id !== id);
788
+ setHistoryItems(remaining);
789
+ setHistoryIndex((index) => Math.min(index, Math.max(0, remaining.length - 1)));
790
+
791
+ if (result.replacementSession) {
792
+ setActiveSessionId(result.replacementSession.id);
793
+ setPendingAttachments([]);
794
+ setMessages([
795
+ {
796
+ role: 'system-info',
797
+ text: `세션 ${id}을 삭제하고 새 세션을 시작했습니다.`,
798
+ },
799
+ ]);
800
+ } else {
801
+ pushSystemInfo(
802
+ result.deleted ? `세션 ${id}을 삭제했습니다.` : `세션 ${id}을 찾을 수 없습니다.`,
803
+ );
804
+ }
805
+ } catch (err) {
806
+ pushSystemError('세션을 삭제할 수 없습니다: ' + (err?.message ?? String(err)));
807
+ }
808
+ };
809
+
810
+ const sendMessage = async (
811
+ text,
812
+ { baseMessages = messages, attachments = pendingAttachments } = {},
813
+ ) => {
660
814
  const userMsg = {
661
815
  role: 'user',
662
816
  text,
663
- attachments: pendingAttachments,
817
+ attachments,
664
818
  };
665
- const newMessages = [...messages, userMsg];
819
+ const newMessages = [...baseMessages, userMsg];
666
820
  setMessages(newMessages);
667
821
  setPendingAttachments([]);
668
822
  setState('thinking');
@@ -717,6 +871,18 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
717
871
 
718
872
  // 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
719
873
  const onToolEvent = (ev) => {
874
+ if (ev.kind === 'openapi_refreshed') {
875
+ setMessages((m) => [
876
+ ...m,
877
+ {
878
+ role: 'system-info',
879
+ text: ev.ok
880
+ ? '🔄 OpenAPI 스펙 새로고침 (서버에서 최신본 다시 받음)'
881
+ : '⚠️ OpenAPI 새로고침 실패 (네트워크/URL 확인)',
882
+ },
883
+ ]);
884
+ return;
885
+ }
720
886
  const labels = {
721
887
  write_created: '🆕 생성',
722
888
  write_overwritten: '✏️ 덮어씀',
@@ -896,12 +1062,20 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
896
1062
  }),
897
1063
  ),
898
1064
  h(AttachBar, { pending: pendingAttachments }),
1065
+ historyOpen
1066
+ ? h(HistoryMenu, {
1067
+ items: historyItems,
1068
+ activeIndex: historyIndex,
1069
+ activeSessionId,
1070
+ deleteId: historyDeleteId,
1071
+ })
1072
+ : null,
899
1073
  slashOpen ? h(SlashMenu, { items: slashItems, activeIndex: menuIndex }) : null,
900
1074
  h(
901
1075
  Box,
902
1076
  { marginTop: 0 },
903
1077
  h(Text, { color: 'magenta', bold: true }, 'you › '),
904
- state === 'idle'
1078
+ state === 'idle' && !historyOpen
905
1079
  ? h(TextInput, {
906
1080
  value: input,
907
1081
  onChange: setInput,
@@ -912,7 +1086,15 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
912
1086
  // 가짜 커서가 있으면 한글 IME 미리보기가 한 칸 어긋나 보일 수 있다.
913
1087
  showCursor: false,
914
1088
  })
915
- : h(Text, { dimColor: true }, '(응답 받는 중 — 잠시만)'),
1089
+ : h(
1090
+ Text,
1091
+ { dimColor: true },
1092
+ historyOpen
1093
+ ? historyDeleteId
1094
+ ? '(y 또는 n을 입력하세요)'
1095
+ : '(이전 대화를 선택하세요)'
1096
+ : '(응답 받는 중 — 잠시만)',
1097
+ ),
916
1098
  ),
917
1099
  );
918
1100
  }