import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; import { findBrainFiles, getActiveBrainProfile, openInEditorGroup } from '../utils'; import { getBrainTokenIndex } from '../retrieval'; import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from '../retrieval/lessonHelpers'; /** * Experience Memory — lesson card 의 *대화형* 명령 (create / manage). * * 옛 코드: extension.ts 의 `activate()` 안 nested function 3개 (listLessonFiles, * createLessonCard, manageLessons). 그러나 셋 다 `activate()` 클로저에서 캡처하는 * 게 하나도 없었음 (모두 module-level import 만 사용) — nested 인 이유 없음. * 분리해 (a) extension.ts 의 activate() 길이 축소, (b) lesson 워크플로우의 단위 * 테스트 가능, (c) 다른 곳 (예: chat handler) 에서도 직접 호출 가능. */ /** All lesson/playbook/qa-finding cards in the active brain. Uses the brain index for the lesson-kind * filter (cheap when warm), then reads only those few files for their frontmatter title/occurrences. */ function listLessonFiles(brainDir: string): Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> { const out: Array<{ filePath: string; rel: string; title: string; kind: string; occurrences: number }> = []; let files: string[] = []; try { files = findBrainFiles(brainDir); } catch { return out; } for (const d of getBrainTokenIndex(brainDir, files)) { if (!d.kind) continue; let content = ''; try { content = fs.readFileSync(d.filePath, 'utf8').slice(0, 4000); } catch { continue; } const fm = parseLessonFrontmatter(content); out.push({ filePath: d.filePath, rel: d.relativePath, title: (fm.title || d.title).trim(), kind: d.kind, occurrences: fm.occurrences ?? 1 }); } return out.sort((a, b) => a.rel.localeCompare(b.rel)); } /** Shared lesson-card creator used by the lesson commands. Dedup-merges into an existing lesson with the same title. */ export async function createLessonCard(situation?: string): Promise { const brain = getActiveBrainProfile(); const brainDir = brain?.localBrainPath; if (!brainDir || !path.isAbsolute(brainDir)) { vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다. Settings에서 localBrainPath / brainProfiles를 먼저 설정하세요.'); return; } const title = (await vscode.window.showInputBox({ title: 'New Lesson — Experience Memory', prompt: '이 교훈의 제목 (예: "Telegram 원격 실행은 allowlist 필수")', placeHolder: '한 줄 요약 — 다음에 같은 실수를 안 하려면 뭘 기억해야 하나', ignoreFocusOut: true, }))?.trim(); if (!title) return; const today = new Date().toISOString().slice(0, 10); // Dedup-merge: a recurring mistake should get LOUDER (occurrences++), not spawn a duplicate card. const norm = normalizeLessonTitle(title); const existing = norm ? listLessonFiles(brainDir).find((l) => normalizeLessonTitle(l.title) === norm) : undefined; if (existing) { const pick = await vscode.window.showInformationMessage( `이미 같은 제목의 교훈이 있습니다: "${existing.title}" (occurrences: ${existing.occurrences}). 갱신할까요?`, { modal: false }, '갱신 (occurrences +1)', '새로 만들기', ); if (!pick) return; if (pick === '갱신 (occurrences +1)') { try { const cur = fs.readFileSync(existing.filePath, 'utf8'); fs.writeFileSync(existing.filePath, bumpLessonOccurrences(cur, today), 'utf8'); } catch (e: any) { vscode.window.showErrorMessage(`교훈 갱신 실패: ${e?.message ?? e}`); return; } await openInEditorGroup(existing.filePath); vscode.window.showInformationMessage(`기존 교훈을 갱신했습니다 (occurrences: ${existing.occurrences + 1}). 필요하면 내용을 보강하세요.`); return; } // else fall through and create a new one } const dir = path.join(brainDir, 'lessons'); try { fs.mkdirSync(dir, { recursive: true }); } catch { /* fall through to error below */ } let filePath = path.join(dir, `${today}-${lessonSlug(title)}.md`); let n = 2; while (fs.existsSync(filePath)) { filePath = path.join(dir, `${today}-${lessonSlug(title)}-${n++}.md`); } try { fs.writeFileSync(filePath, lessonTemplate(title, today, situation), 'utf8'); // A-MEM 레슨 네트워크 — 비슷한 과거 교훈과 상호 링크 (실패 무해). try { const { linkRelatedLessons } = await import('../intelligence/lessonNetwork'); linkRelatedLessons(brainDir, filePath); } catch { /* 링크 실패가 생성을 막지 않음 */ } } catch (e: any) { vscode.window.showErrorMessage(`교훈 파일 생성 실패: ${e?.message ?? e}`); return; } await openInEditorGroup(filePath); vscode.window.showInformationMessage('교훈 카드를 만들었습니다. 내용을 채워 저장하면 다음 유사 작업 시 자동으로 체크리스트에 들어갑니다.'); } /** Browse lesson cards: open one, or delete one (trash button). Also the "manage" surface for ignoring bad lessons. */ export async function manageLessons(): Promise { const brain = getActiveBrainProfile(); const brainDir = brain?.localBrainPath; if (!brainDir || !path.isAbsolute(brainDir)) { vscode.window.showErrorMessage('활성 Second Brain 경로가 설정되지 않았습니다.'); return; } const lessons = listLessonFiles(brainDir); if (lessons.length === 0) { const make = await vscode.window.showInformationMessage('아직 교훈 카드가 없습니다.', '새 교훈 만들기'); if (make) await createLessonCard(); return; } const deleteBtn: vscode.QuickInputButton = { iconPath: new vscode.ThemeIcon('trash'), tooltip: '이 교훈 삭제' }; const qp = vscode.window.createQuickPick(); qp.title = 'Lessons — Experience Memory'; qp.placeholder = '교훈을 선택하면 열립니다. 휴지통 아이콘으로 삭제. (삭제 = 더 이상 주입 안 됨)'; qp.items = lessons.map((l) => ({ label: `$(${l.kind === 'playbook' ? 'book' : l.kind === 'qa-finding' ? 'bug' : 'lightbulb'}) ${l.title}`, description: l.occurrences > 1 ? `×${l.occurrences}` : '', detail: l.rel, buttons: [deleteBtn], _file: l.filePath, })); qp.onDidTriggerItemButton(async (e) => { const file = e.item._file; const ok = await vscode.window.showWarningMessage(`교훈 "${e.item.label}" 을(를) 삭제할까요?`, { modal: true }, '삭제'); if (ok === '삭제') { try { fs.unlinkSync(file); } catch (err: any) { vscode.window.showErrorMessage(`삭제 실패: ${err?.message ?? err}`); return; } qp.items = qp.items.filter((it) => it._file !== file); vscode.window.showInformationMessage('교훈을 삭제했습니다.'); if (qp.items.length === 0) qp.hide(); } }); qp.onDidAccept(async () => { const sel = qp.selectedItems[0]; qp.hide(); if (sel) { await openInEditorGroup(sel._file); } }); qp.onDidHide(() => qp.dispose()); qp.show(); }