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.
- package/README.md +186 -8
- package/bin/index.js +157 -1
- package/package.json +32 -6
- package/src/ai/messageContent.js +84 -0
- package/src/ai/models.js +66 -0
- package/src/ai/provider.js +54 -0
- package/src/ai/systemPrompt.js +75 -0
- package/src/ai/tokenMeter.js +65 -0
- package/src/commands/adopt.js +146 -0
- package/src/commands/chat.js +300 -0
- package/src/commands/config.js +142 -0
- package/src/commands/genCmd.js +61 -0
- package/src/commands/indexCmd.js +89 -0
- package/src/commands/init.js +19 -0
- package/src/config/index.js +185 -0
- package/src/context/detect.js +228 -0
- package/src/generators/createBcConfig.js +58 -0
- package/src/generators/createProject.js +2 -0
- package/src/history/store.js +117 -0
- package/src/indexer/chunker.js +42 -0
- package/src/indexer/embed.js +57 -0
- package/src/indexer/search.js +41 -0
- package/src/indexer/store.js +137 -0
- package/src/indexer/walker.js +45 -0
- package/src/openapi/cache.js +60 -0
- package/src/openapi/codegen.js +45 -0
- package/src/openapi/fetch.js +69 -0
- package/src/openapi/summary.js +75 -0
- package/src/prompts/initPrompts.js +24 -0
- package/src/ui/ChatApp.js +812 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 프로젝트 디렉터리를 분석해서 "AI 가 알아야 할 사실들" 을 뽑아낸다.
|
|
6
|
+
*
|
|
7
|
+
* 입력: projectDir (절대 경로)
|
|
8
|
+
* 출력:
|
|
9
|
+
* {
|
|
10
|
+
* framework: 'next' | 'expo' | 'electron' | 'vite-react' | 'cra' | 'react' | 'remix' | 'unknown',
|
|
11
|
+
* language: 'ts' | 'js',
|
|
12
|
+
* styling: { tailwind, cssModules, styledComponents, emotion, vanillaExtract },
|
|
13
|
+
* packageManager: 'npm' | 'yarn' | 'pnpm' | 'bun' | 'unknown',
|
|
14
|
+
* routing: 'app-router' | 'pages-router' | 'expo-router' | 'react-router' | null,
|
|
15
|
+
* componentDirs: string[], // 후보 컴포넌트 폴더 (있는 것만)
|
|
16
|
+
* designTokensFiles: string[], // tailwind.config / theme 토큰 파일들
|
|
17
|
+
* hasStorybook: boolean,
|
|
18
|
+
* hasTests: 'jest' | 'vitest' | 'playwright' | null,
|
|
19
|
+
* pkg: { name, version, dependencies, devDependencies },
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* 모든 값은 best-effort. 못 찾으면 보수적으로 unknown / null / [] 로 채운다.
|
|
23
|
+
*/
|
|
24
|
+
export async function detectProjectContext(projectDir = process.cwd()) {
|
|
25
|
+
const root = path.resolve(projectDir);
|
|
26
|
+
|
|
27
|
+
const pkg = await readJsonSafe(path.join(root, 'package.json'));
|
|
28
|
+
if (!pkg) {
|
|
29
|
+
return {
|
|
30
|
+
framework: 'unknown',
|
|
31
|
+
language: 'js',
|
|
32
|
+
styling: emptyStyling(),
|
|
33
|
+
packageManager: await detectPackageManager(root),
|
|
34
|
+
routing: null,
|
|
35
|
+
componentDirs: [],
|
|
36
|
+
designTokensFiles: [],
|
|
37
|
+
hasStorybook: false,
|
|
38
|
+
hasTests: null,
|
|
39
|
+
pkg: null,
|
|
40
|
+
isProject: false,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
45
|
+
const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
46
|
+
|
|
47
|
+
// ── 프레임워크 ─────────────────────────────────────────────
|
|
48
|
+
let framework = 'unknown';
|
|
49
|
+
if (has('expo') || has('expo-router')) framework = 'expo';
|
|
50
|
+
else if (has('electron')) framework = 'electron';
|
|
51
|
+
else if (has('next')) framework = 'next';
|
|
52
|
+
else if (has('@remix-run/react') || has('@remix-run/dev')) framework = 'remix';
|
|
53
|
+
else if (has('vite') && has('react')) framework = 'vite-react';
|
|
54
|
+
else if (has('react-scripts')) framework = 'cra';
|
|
55
|
+
else if (has('react')) framework = 'react';
|
|
56
|
+
|
|
57
|
+
// ── 언어 ────────────────────────────────────────────────
|
|
58
|
+
const hasTsConfig = await exists(path.join(root, 'tsconfig.json'));
|
|
59
|
+
const language = hasTsConfig || has('typescript') ? 'ts' : 'js';
|
|
60
|
+
|
|
61
|
+
// ── 스타일 ─────────────────────────────────────────────
|
|
62
|
+
const styling = {
|
|
63
|
+
tailwind:
|
|
64
|
+
has('tailwindcss') ||
|
|
65
|
+
(await anyExists(root, [
|
|
66
|
+
'tailwind.config.js',
|
|
67
|
+
'tailwind.config.ts',
|
|
68
|
+
'tailwind.config.cjs',
|
|
69
|
+
'tailwind.config.mjs',
|
|
70
|
+
])),
|
|
71
|
+
cssModules: false, // 모듈 css 는 파일 패턴으로 추정 (아래에서 처리)
|
|
72
|
+
styledComponents: has('styled-components'),
|
|
73
|
+
emotion: has('@emotion/react') || has('@emotion/styled'),
|
|
74
|
+
vanillaExtract: has('@vanilla-extract/css'),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── 라우팅 ─────────────────────────────────────────────
|
|
78
|
+
let routing = null;
|
|
79
|
+
if (framework === 'next') {
|
|
80
|
+
if (await exists(path.join(root, 'app'))) routing = 'app-router';
|
|
81
|
+
else if (await exists(path.join(root, 'src/app'))) routing = 'app-router';
|
|
82
|
+
else if (await exists(path.join(root, 'pages'))) routing = 'pages-router';
|
|
83
|
+
else if (await exists(path.join(root, 'src/pages'))) routing = 'pages-router';
|
|
84
|
+
} else if (framework === 'expo') {
|
|
85
|
+
routing = (await exists(path.join(root, 'app'))) ? 'expo-router' : null;
|
|
86
|
+
} else if (has('react-router-dom') || has('react-router')) {
|
|
87
|
+
routing = 'react-router';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── 컴포넌트 디렉터리 후보 ─────────────────────────────
|
|
91
|
+
const componentCandidates = [
|
|
92
|
+
'src/components',
|
|
93
|
+
'src/ui',
|
|
94
|
+
'app/components',
|
|
95
|
+
'app/_components',
|
|
96
|
+
'components',
|
|
97
|
+
'ui',
|
|
98
|
+
];
|
|
99
|
+
const componentDirs = [];
|
|
100
|
+
for (const c of componentCandidates) {
|
|
101
|
+
if (await exists(path.join(root, c))) componentDirs.push(c);
|
|
102
|
+
}
|
|
103
|
+
// .module.css 파일이 한 개라도 있으면 cssModules = true 로 추정.
|
|
104
|
+
if (componentDirs.length) {
|
|
105
|
+
styling.cssModules = await hasFileWithExt(
|
|
106
|
+
path.join(root, componentDirs[0]),
|
|
107
|
+
'.module.css',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 디자인 토큰 후보 파일 ─────────────────────────────
|
|
112
|
+
const tokenCandidates = [
|
|
113
|
+
'tailwind.config.js',
|
|
114
|
+
'tailwind.config.ts',
|
|
115
|
+
'tailwind.config.cjs',
|
|
116
|
+
'tailwind.config.mjs',
|
|
117
|
+
'src/styles/tokens.ts',
|
|
118
|
+
'src/styles/tokens.css',
|
|
119
|
+
'src/styles/theme.ts',
|
|
120
|
+
'src/theme.ts',
|
|
121
|
+
'theme.config.ts',
|
|
122
|
+
'token.config.js',
|
|
123
|
+
];
|
|
124
|
+
const designTokensFiles = [];
|
|
125
|
+
for (const f of tokenCandidates) {
|
|
126
|
+
if (await exists(path.join(root, f))) designTokensFiles.push(f);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── 테스트/스토리북 ─────────────────────────────────
|
|
130
|
+
const hasStorybook = has('@storybook/react') || has('@storybook/nextjs') || has('storybook');
|
|
131
|
+
let hasTests = null;
|
|
132
|
+
if (has('vitest')) hasTests = 'vitest';
|
|
133
|
+
else if (has('jest') || has('@testing-library/react')) hasTests = 'jest';
|
|
134
|
+
if (has('@playwright/test')) hasTests = hasTests ? hasTests : 'playwright';
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
framework,
|
|
138
|
+
language,
|
|
139
|
+
styling,
|
|
140
|
+
packageManager: await detectPackageManager(root),
|
|
141
|
+
routing,
|
|
142
|
+
componentDirs,
|
|
143
|
+
designTokensFiles,
|
|
144
|
+
hasStorybook,
|
|
145
|
+
hasTests,
|
|
146
|
+
pkg: {
|
|
147
|
+
name: pkg.name,
|
|
148
|
+
version: pkg.version,
|
|
149
|
+
dependencies: pkg.dependencies ?? {},
|
|
150
|
+
devDependencies: pkg.devDependencies ?? {},
|
|
151
|
+
},
|
|
152
|
+
isProject: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function emptyStyling() {
|
|
157
|
+
return {
|
|
158
|
+
tailwind: false,
|
|
159
|
+
cssModules: false,
|
|
160
|
+
styledComponents: false,
|
|
161
|
+
emotion: false,
|
|
162
|
+
vanillaExtract: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function exists(p) {
|
|
167
|
+
try {
|
|
168
|
+
await fs.access(p);
|
|
169
|
+
return true;
|
|
170
|
+
} catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function anyExists(root, files) {
|
|
176
|
+
for (const f of files) {
|
|
177
|
+
if (await exists(path.join(root, f))) return true;
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readJsonSafe(file) {
|
|
183
|
+
try {
|
|
184
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
185
|
+
return JSON.parse(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function detectPackageManager(root) {
|
|
192
|
+
if (await exists(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
193
|
+
if (await exists(path.join(root, 'yarn.lock'))) return 'yarn';
|
|
194
|
+
if (await exists(path.join(root, 'bun.lockb'))) return 'bun';
|
|
195
|
+
if (await exists(path.join(root, 'package-lock.json'))) return 'npm';
|
|
196
|
+
return 'unknown';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function hasFileWithExt(dir, ext) {
|
|
200
|
+
try {
|
|
201
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
202
|
+
for (const e of entries) {
|
|
203
|
+
if (e.isFile() && e.name.endsWith(ext)) return true;
|
|
204
|
+
if (e.isDirectory()) {
|
|
205
|
+
if (await hasFileWithExt(path.join(dir, e.name), ext)) return true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* noop */
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 사람이 읽기 좋은 라벨로 압축. */
|
|
215
|
+
export function summarizeContext(ctx) {
|
|
216
|
+
if (!ctx?.isProject) return '(package.json 없음 — 프로젝트 외부)';
|
|
217
|
+
const styling = Object.entries(ctx.styling)
|
|
218
|
+
.filter(([, v]) => v)
|
|
219
|
+
.map(([k]) => k);
|
|
220
|
+
const parts = [
|
|
221
|
+
ctx.framework,
|
|
222
|
+
ctx.language === 'ts' ? 'TypeScript' : 'JavaScript',
|
|
223
|
+
styling.length ? styling.join('+') : 'no styling detected',
|
|
224
|
+
ctx.packageManager,
|
|
225
|
+
];
|
|
226
|
+
if (ctx.routing) parts.push(ctx.routing);
|
|
227
|
+
return parts.join(' · ');
|
|
228
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 새 프로젝트 루트에 `bc.config.json` 을 만든다.
|
|
6
|
+
*
|
|
7
|
+
* - bc chat / bc gen 등이 이 파일을 읽어 프로젝트 컨텍스트(Figma, API, 컨벤션)를
|
|
8
|
+
* AI 에게 자동 주입한다.
|
|
9
|
+
* - API 키 같은 비밀값은 절대 여기 두지 말 것 (글로벌 설정 또는 .env 사용).
|
|
10
|
+
*/
|
|
11
|
+
export async function createBcConfig(rootDir, config) {
|
|
12
|
+
const bcConfig = {
|
|
13
|
+
$schema: 'https://byuckchon.dev/bc.schema.json',
|
|
14
|
+
ai: {
|
|
15
|
+
// null 이면 글로벌 기본 모델(=~/.bc/config.json) 을 따름.
|
|
16
|
+
model: config.aiModel ?? null,
|
|
17
|
+
},
|
|
18
|
+
design: {
|
|
19
|
+
figma: config.figmaUrl?.trim() || null,
|
|
20
|
+
figmaTokenEnv: 'FIGMA_TOKEN',
|
|
21
|
+
},
|
|
22
|
+
api: {
|
|
23
|
+
openapi: config.openapiUrl?.trim() || null,
|
|
24
|
+
baseUrl: null,
|
|
25
|
+
},
|
|
26
|
+
context: {
|
|
27
|
+
include: ['src/**/*.{ts,tsx,js,jsx}', 'bc.config.json'],
|
|
28
|
+
exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
|
|
29
|
+
maxFiles: 20,
|
|
30
|
+
},
|
|
31
|
+
framework: config.framework,
|
|
32
|
+
// init 단계에선 사용자가 React/Next 중 골랐고 TS/Tailwind 가 항상 들어가니
|
|
33
|
+
// 감지 결과를 미리 채워둔다 (bc adopt 의 detected 와 같은 모양).
|
|
34
|
+
detected: {
|
|
35
|
+
language: 'ts',
|
|
36
|
+
styling: {
|
|
37
|
+
tailwind: true,
|
|
38
|
+
cssModules: false,
|
|
39
|
+
styledComponents: false,
|
|
40
|
+
emotion: false,
|
|
41
|
+
vanillaExtract: false,
|
|
42
|
+
},
|
|
43
|
+
packageManager: 'npm',
|
|
44
|
+
routing: config.framework === 'next' ? 'app-router' : null,
|
|
45
|
+
componentDirs: ['src/components'],
|
|
46
|
+
designTokensFiles: [],
|
|
47
|
+
hasStorybook: false,
|
|
48
|
+
hasTests: null,
|
|
49
|
+
detectedAt: new Date().toISOString(),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(rootDir, 'bc.config.json'),
|
|
55
|
+
JSON.stringify(bcConfig, null, 2) + '\n',
|
|
56
|
+
'utf8',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -4,6 +4,7 @@ import { exec as execCallback } from 'child_process';
|
|
|
4
4
|
import { promisify } from 'util';
|
|
5
5
|
|
|
6
6
|
import { createBaseFiles } from './createBaseFiles.js';
|
|
7
|
+
import { createBcConfig } from './createBcConfig.js';
|
|
7
8
|
import { createFolders } from './createFolders.js';
|
|
8
9
|
import { createPackageJson } from './createPackageJson.js';
|
|
9
10
|
import { createReadme } from './createReadme.js';
|
|
@@ -24,6 +25,7 @@ export async function createProject(config) {
|
|
|
24
25
|
await createPackageJson(rootDir, config);
|
|
25
26
|
await createBaseFiles(rootDir, config);
|
|
26
27
|
await createReadme(rootDir, config);
|
|
28
|
+
await createBcConfig(rootDir, config);
|
|
27
29
|
|
|
28
30
|
// 최신 버전(latest 포함) 의존성을 실제로 설치해 lockfile까지 생성
|
|
29
31
|
await exec('npm install', { cwd: rootDir });
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { findProjectConfigPath } from '../config/index.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 챗 세션을 디스크에 저장/복구한다.
|
|
10
|
+
*
|
|
11
|
+
* 저장 위치 우선순위:
|
|
12
|
+
* 1) 프로젝트 안: <projectRoot>/.bc/history/<id>.json
|
|
13
|
+
* - bc.config.json 이 발견된 경우 그 옆 .bc 폴더 사용
|
|
14
|
+
* - 자동으로 .gitignore 에 들어감 (사용자 .gitignore 에 .bc/ 박혀있다는 가정)
|
|
15
|
+
* 2) 글로벌: ~/.bc/history/<cwdHash>/<id>.json
|
|
16
|
+
* - bc.config.json 이 없는 경우 (예: 빈 폴더에서 bc chat 실행)
|
|
17
|
+
*
|
|
18
|
+
* 동기 디스크 IO 는 ink 렌더에 끼지 않게 모두 await.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
function cwdHash(cwd) {
|
|
22
|
+
return crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeId(date = new Date()) {
|
|
26
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
27
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_${pad(
|
|
28
|
+
date.getHours(),
|
|
29
|
+
)}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getHistoryDir(cwd = process.cwd()) {
|
|
33
|
+
const projectConfig = await findProjectConfigPath(cwd);
|
|
34
|
+
if (projectConfig) {
|
|
35
|
+
return path.join(path.dirname(projectConfig), '.bc', 'history');
|
|
36
|
+
}
|
|
37
|
+
return path.join(os.homedir(), '.bc', 'history', cwdHash(cwd));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createSession({ model, cwd = process.cwd() } = {}) {
|
|
41
|
+
const dir = await getHistoryDir(cwd);
|
|
42
|
+
await fs.mkdir(dir, { recursive: true });
|
|
43
|
+
const id = makeId();
|
|
44
|
+
const session = {
|
|
45
|
+
id,
|
|
46
|
+
startedAt: new Date().toISOString(),
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
model,
|
|
49
|
+
cwd,
|
|
50
|
+
messages: [],
|
|
51
|
+
};
|
|
52
|
+
return { ...session, _file: path.join(dir, `${id}.json`) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function saveSession(session) {
|
|
56
|
+
const file = session._file;
|
|
57
|
+
if (!file) throw new Error('session._file 누락');
|
|
58
|
+
const data = {
|
|
59
|
+
id: session.id,
|
|
60
|
+
startedAt: session.startedAt,
|
|
61
|
+
updatedAt: new Date().toISOString(),
|
|
62
|
+
model: session.model,
|
|
63
|
+
cwd: session.cwd,
|
|
64
|
+
messages: session.messages,
|
|
65
|
+
};
|
|
66
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function listSessions(cwd = process.cwd(), { limit = 20 } = {}) {
|
|
70
|
+
const dir = await getHistoryDir(cwd);
|
|
71
|
+
let entries = [];
|
|
72
|
+
try {
|
|
73
|
+
entries = await fs.readdir(dir);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
if (e.code === 'ENOENT') return [];
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
const files = entries.filter((f) => f.endsWith('.json')).sort().reverse();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const name of files.slice(0, limit)) {
|
|
81
|
+
const file = path.join(dir, name);
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
84
|
+
const data = JSON.parse(raw);
|
|
85
|
+
const firstUser = data.messages?.find((m) => m.role === 'user');
|
|
86
|
+
out.push({
|
|
87
|
+
id: data.id,
|
|
88
|
+
file,
|
|
89
|
+
startedAt: data.startedAt,
|
|
90
|
+
updatedAt: data.updatedAt,
|
|
91
|
+
model: data.model,
|
|
92
|
+
turns: data.messages?.length ?? 0,
|
|
93
|
+
preview: firstUser?.text?.slice(0, 60) ?? '(빈 세션)',
|
|
94
|
+
});
|
|
95
|
+
} catch {
|
|
96
|
+
/* 잘못된 파일 스킵 */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function loadSession(idOrFile, cwd = process.cwd()) {
|
|
103
|
+
let file = idOrFile;
|
|
104
|
+
if (!file.endsWith('.json') || !path.isAbsolute(file)) {
|
|
105
|
+
const dir = await getHistoryDir(cwd);
|
|
106
|
+
file = path.join(dir, idOrFile.endsWith('.json') ? idOrFile : `${idOrFile}.json`);
|
|
107
|
+
}
|
|
108
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
109
|
+
const data = JSON.parse(raw);
|
|
110
|
+
return { ...data, _file: file };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function loadLatestSession(cwd = process.cwd()) {
|
|
114
|
+
const list = await listSessions(cwd, { limit: 1 });
|
|
115
|
+
if (list.length === 0) return null;
|
|
116
|
+
return loadSession(list[0].id, cwd);
|
|
117
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 파일을 라인 기반 슬라이딩 윈도우로 청크 분할.
|
|
5
|
+
*
|
|
6
|
+
* v1 정책 — 단순하고 결정론적:
|
|
7
|
+
* - 한 청크 ≈ 80 라인, 인접 청크끼리 20 라인 겹침.
|
|
8
|
+
* - 너무 짧은 파일(80라인 이하)은 통째로 1청크.
|
|
9
|
+
* - 청크 텍스트 앞에 "// FILE: <relpath>:start-end" 헤더를 붙여서
|
|
10
|
+
* 임베딩이 파일 정체성에 가중치를 두도록 한다.
|
|
11
|
+
*
|
|
12
|
+
* 더 똑똑한 AST 기반 청킹은 v2 (ts-morph 도입 후) 에서.
|
|
13
|
+
*/
|
|
14
|
+
export function chunkFile({ relPath, content, chunkSize = 80, overlap = 20 }) {
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
const total = lines.length;
|
|
17
|
+
if (total === 0) return [];
|
|
18
|
+
|
|
19
|
+
const chunks = [];
|
|
20
|
+
let start = 0;
|
|
21
|
+
while (start < total) {
|
|
22
|
+
const end = Math.min(start + chunkSize, total);
|
|
23
|
+
const slice = lines.slice(start, end).join('\n');
|
|
24
|
+
const text = `// FILE: ${relPath}:${start + 1}-${end}\n${slice}`;
|
|
25
|
+
chunks.push({
|
|
26
|
+
id: hashId(`${relPath}:${start + 1}-${end}`),
|
|
27
|
+
file: relPath,
|
|
28
|
+
startLine: start + 1,
|
|
29
|
+
endLine: end,
|
|
30
|
+
text,
|
|
31
|
+
hash: crypto.createHash('sha1').update(text).digest('hex'),
|
|
32
|
+
});
|
|
33
|
+
if (end >= total) break;
|
|
34
|
+
start = end - overlap;
|
|
35
|
+
if (start < 0) start = 0;
|
|
36
|
+
}
|
|
37
|
+
return chunks;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hashId(s) {
|
|
41
|
+
return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { embed, embedMany } from 'ai';
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_EMBED_MODEL = 'text-embedding-3-small';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 임베딩은 OpenAI 만 지원 (Anthropic 은 임베딩 API 가 없다).
|
|
8
|
+
*
|
|
9
|
+
* effective.apiKeys.openai 또는 ENV(OPENAI_API_KEY)/gateway 를 사용.
|
|
10
|
+
* 키 없으면 명확한 에러로 종료.
|
|
11
|
+
*/
|
|
12
|
+
export function makeEmbeddingModel(effective) {
|
|
13
|
+
const apiKey = effective.apiKeys?.openai;
|
|
14
|
+
if (!apiKey && !effective.gateway) {
|
|
15
|
+
const err = new Error(
|
|
16
|
+
'OpenAI API 키가 필요합니다 (임베딩 전용).\n' +
|
|
17
|
+
' - Anthropic 은 임베딩 API 를 제공하지 않습니다.\n' +
|
|
18
|
+
' - `bc config set-key openai <key>` 또는 OPENAI_API_KEY 환경변수로 등록하세요.\n' +
|
|
19
|
+
' - text-embedding-3-small 은 1M 토큰당 약 $0.02 로 매우 저렴합니다.',
|
|
20
|
+
);
|
|
21
|
+
err.code = 'BC_NO_OPENAI_KEY';
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
const openai = createOpenAI({
|
|
25
|
+
apiKey: apiKey ?? 'gateway',
|
|
26
|
+
baseURL: effective.gateway ?? undefined,
|
|
27
|
+
});
|
|
28
|
+
return openai.embedding(DEFAULT_EMBED_MODEL);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 청크 배열을 받아 임베딩 벡터를 추가해서 돌려준다.
|
|
33
|
+
* 100개 단위 배치, 각 배치 내부에선 SDK 가 다시 OpenAI 한도에 맞춰 처리.
|
|
34
|
+
*/
|
|
35
|
+
export async function embedChunks(model, chunks, { batchSize = 100, onBatch } = {}) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (let i = 0; i < chunks.length; i += batchSize) {
|
|
38
|
+
const batch = chunks.slice(i, i + batchSize);
|
|
39
|
+
const { embeddings } = await embedMany({
|
|
40
|
+
model,
|
|
41
|
+
values: batch.map((c) => c.text),
|
|
42
|
+
});
|
|
43
|
+
for (let j = 0; j < batch.length; j++) {
|
|
44
|
+
out.push({ ...batch[j], embedding: embeddings[j] });
|
|
45
|
+
}
|
|
46
|
+
if (onBatch) onBatch({ done: out.length, total: chunks.length });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 단건 — 검색 쿼리 임베딩. */
|
|
52
|
+
export async function embedQuery(model, query) {
|
|
53
|
+
const { embedding } = await embed({ model, value: query });
|
|
54
|
+
return embedding;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { DEFAULT_EMBED_MODEL };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { embedQuery, makeEmbeddingModel } from './embed.js';
|
|
2
|
+
import { loadIndex } from './store.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 쿼리 텍스트와 모든 청크 사이 cosine similarity 를 계산해 top-K 를 돌려준다.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} query
|
|
8
|
+
* @param {object} effective
|
|
9
|
+
* @param {object} [opts]
|
|
10
|
+
* @param {number} [opts.topK=5]
|
|
11
|
+
* @param {number} [opts.minScore=0] 너무 관련 없는 청크 컷오프 (0~1 스케일)
|
|
12
|
+
*/
|
|
13
|
+
export async function searchIndex(query, effective, { topK = 5, minScore = 0 } = {}) {
|
|
14
|
+
const idx = await loadIndex();
|
|
15
|
+
if (!idx || !idx.chunks?.length) return { ok: false, reason: 'no-index' };
|
|
16
|
+
|
|
17
|
+
const model = makeEmbeddingModel(effective);
|
|
18
|
+
const qVec = await embedQuery(model, query);
|
|
19
|
+
|
|
20
|
+
const scored = idx.chunks.map((c) => ({
|
|
21
|
+
chunk: c,
|
|
22
|
+
score: cosine(qVec, c.embedding),
|
|
23
|
+
}));
|
|
24
|
+
scored.sort((a, b) => b.score - a.score);
|
|
25
|
+
const top = scored.filter((s) => s.score >= minScore).slice(0, topK);
|
|
26
|
+
return { ok: true, results: top, total: idx.chunks.length };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cosine(a, b) {
|
|
30
|
+
if (!a || !b || a.length !== b.length) return 0;
|
|
31
|
+
let dot = 0;
|
|
32
|
+
let na = 0;
|
|
33
|
+
let nb = 0;
|
|
34
|
+
for (let i = 0; i < a.length; i++) {
|
|
35
|
+
dot += a[i] * b[i];
|
|
36
|
+
na += a[i] * a[i];
|
|
37
|
+
nb += b[i] * b[i];
|
|
38
|
+
}
|
|
39
|
+
if (na === 0 || nb === 0) return 0;
|
|
40
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
41
|
+
}
|