@kybird/llm-wiki 0.2.1 → 0.3.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.
@@ -1,369 +1,373 @@
1
- // wiki-compile — raw 로그에서 지식 추출/합성/비활성화 + index.md 재구축 + qmd 동기화.
2
- // 원본: TTSTextViewer/.agents/skills/wiki-compile/scripts/compile.js (2026-07-24 loose 파일 분류 + CRLF fix 포함,
3
- // 2026-08-21 list 판정 mtime→헤더 날짜 fix 역동기화)
4
- // 변경점:
5
- // - __dirname '../../../../' → findDocRoot()
6
- // - 하드코딩 qmdCli 경로 → findQmd()
7
- // - "TTSTextViewer 프로젝트의..." 문자열 → config.projectName (null이면 제네릭 문구)
8
- // - COLLECTION/ COLLECTION_RAW → config.collections
9
- // - 2026-08-29 (개선계획 1-6): compile list/index가 --json 봉투를 지원.
10
- // {schemaVersion: 1, kind: 'compile-list'|'compile-index', ...}
11
- // - 2026-08-29 (사각지대 수정): 같은 날 append가 미컴파일로 감지되지 않던 결함을
12
- // 컴파일 상태 해시로 고친다. subagent 설계 리뷰(채택+수정 3건) 반영:
13
- // ① list는 읽기전용 — 상태의 유일한 쓰기점은 compile index(전체 재생성, seed 특례 없음)
14
- // ② lastCompiled 단조 가드(max) — 시계 오차 머신이 미래로 점프시키지 않게
15
- // ③ 키 정렬 + LF 직렬화 — 머신 간 자동 머지 극대화. 충돌 해상도는 자명:
16
- // 아무 쪽이나 남기고 `compile index` 1회면 현재 raw로부터 결정적으로 재생성된다.
17
- // 날짜 절(date > lastCompiled)의 역할은 1차 탐지가 아니라 상태 유실 시의 안전망이다.
18
- const crypto = require('crypto');
19
- const fs = require('fs');
20
- const path = require('path');
21
- const { execSync } = require('child_process');
22
- const { findDocRoot, loadConfig } = require('./find-doc-root');
23
- const { findQmd } = require('./find-qmd');
24
-
25
- const STATE_VERSION = 1;
26
-
27
- // 정규화+해시는 읽기(list)와 쓰기(index)가 반드시 이 헬퍼 하나만 쓴다 —
28
- // 구현 불일치로 인한 영구 오탐/누락을 원천 차단한다.
29
- function contentHash(content) {
30
- return crypto.createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
31
- }
32
-
33
- function loadCompileState(wikiRoot) {
34
- const p = path.join(wikiRoot, 'compile-state.json');
35
- if (!fs.existsSync(p)) return null;
36
- try {
37
- const state = JSON.parse(fs.readFileSync(p, 'utf8').replace(/\r/g, ''));
38
- if (!state || state.schemaVersion !== STATE_VERSION) return null;
39
- return state;
40
- } catch {
41
- return null; // 깨진 상태는 없음 취급 — 날짜 규칙(안전망)으로 폴백
42
- }
43
- }
44
-
45
- // "오늘"은 현지 날짜로 — raw 로그 헤더(# YYYY-MM-DD)가 현지 날짜로 쓰이므로
46
- // UTC 기준과 섞으면 KST 자정~9시 사이에 전부 "new"로 재보고된다(2026-08-30 실측).
47
- function localToday(date = new Date()) {
48
- const pad = n => String(n).padStart(2, '0');
49
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
50
- }
51
-
52
- // 상태 재생성 — compile index 말미에서만 호출된다 (컴파일 완료 선언의 기록).
53
- function writeCompileState(wikiRoot, rawRoot, lastCompiled, previous) {
54
- const files = {};
55
- for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
56
- files[f] = contentHash(fs.readFileSync(path.join(rawRoot, f), 'utf8'));
57
- }
58
- const state = { schemaVersion: STATE_VERSION, lastCompiled, files };
59
- fs.writeFileSync(path.join(wikiRoot, 'compile-state.json'), JSON.stringify(state, null, 2) + '\n');
60
- if (!previous) {
61
- console.log(`컴파일 상태를 생성했다 — 기존 ${Object.keys(files).length}개 파일을 컴파일된 것으로 간주 (lint가 의미 기반 백스톱).`);
62
- }
63
- return state;
64
- }
65
-
66
- // "새 로그" 데이터 수집 판정: 날짜(안전망) OR 해시 불일치(정밀).
67
- // 날짜 판정은 파일 mtime 아니라 로그 헤더 날짜(# YYYY-MM-DD) 한다
68
- // git checkout이 mtime을 머신마다 갈리는 문제(fd01a03) 재발 방지.
69
- function collectNewLogs(docRoot) {
70
- const wikiRoot = path.join(docRoot, 'wiki');
71
- const rawRoot = path.join(docRoot, 'raw');
72
- const state = loadCompileState(wikiRoot);
73
- const stateMissing = state === null;
74
-
75
- let lastUpdated = '0000-00-00';
76
- if (state) {
77
- lastUpdated = state.lastCompiled;
78
- } else {
79
- const indexPath = path.join(wikiRoot, 'index.md');
80
- if (fs.existsSync(indexPath)) {
81
- const match = fs.readFileSync(indexPath, 'utf8').match(/Last updated: (\d{4}-\d{2}-\d{2})/);
82
- if (match) lastUpdated = match[1];
83
- }
84
- }
85
-
86
- const newLogs = [];
87
- if (fs.existsSync(rawRoot)) {
88
- for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
89
- const filePath = path.join(rawRoot, f);
90
- const content = fs.readFileSync(filePath, 'utf8');
91
- const header = content.match(/^#\s+(\d{4}-\d{2}-\d{2})/m);
92
- const date = header ? header[1] : fs.statSync(filePath).mtime.toISOString().split('T')[0];
93
-
94
- let reason = null;
95
- if (date > lastUpdated) {
96
- reason = 'new-date';
97
- } else if (!stateMissing) {
98
- const stored = state.files[f];
99
- if (stored === undefined) reason = 'no-entry';
100
- else if (stored !== contentHash(content)) reason = 'modified';
101
- }
102
- if (reason) newLogs.push({ name: f, date, reason });
103
- }
104
- }
105
-
106
- newLogs.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
107
- return { schemaVersion: 1, kind: 'compile-list', lastCompiled: lastUpdated, state: stateMissing ? 'missing' : 'ok', newLogs };
108
- }
109
-
110
- function renderList(data) {
111
- console.log(`Last compiled date: ${data.lastCompiled}`);
112
- if (data.state === 'missing') {
113
- console.log('상태 파일 없음 — 날짜 규칙으로 판정한다 (compile index가 생성).');
114
- }
115
- console.log('Scanning for new raw logs...');
116
-
117
- if (data.newLogs.length === 0) {
118
- console.log('No new logs found.');
119
- return;
120
- }
121
-
122
- console.log('\nNew/Modified logs to process:');
123
- data.newLogs.forEach(f => console.log(`- ${f.name} (${f.date})${f.reason !== 'new-date' ? ` [${f.reason}]` : ''}`));
124
- }
125
-
126
- function rebuildIndex() {
127
- const docRoot = findDocRoot();
128
- const wikiRoot = path.join(docRoot, 'wiki');
129
- const rawRoot = path.join(docRoot, 'raw');
130
- const indexPath = path.join(wikiRoot, 'index.md');
131
- const config = loadConfig(docRoot);
132
-
133
- if (!fs.existsSync(wikiRoot)) {
134
- console.error(`Wiki directory not found: ${wikiRoot}`);
135
- process.exit(1);
136
- }
137
-
138
- console.log('Rebuilding Wiki Index...');
139
-
140
- const conceptsDir = path.join(wikiRoot, 'concepts');
141
- const patternsDir = path.join(wikiRoot, 'patterns');
142
- const antipatternsDir = path.join(wikiRoot, 'antipatterns');
143
- const answersDir = path.join(wikiRoot, 'answers');
144
-
145
- // 서브디렉토리 분류는 디렉토리 자체가 분류 판정 (이미 물리적으로 정리됨).
146
- let concepts = scanDirectory(conceptsDir);
147
- let patterns = scanDirectory(patternsDir);
148
- let antipatterns = scanDirectory(antipatternsDir);
149
- // answers/ 검색해서 종합한 유용한 답변의 아카이브 (개선계획 1-4).
150
- let answers = scanDirectory(answersDir);
151
-
152
- // loose 파일(루트 *.md)은 tags 기반으로 분류 파일 이동 없이 인덱스/검색에 반영.
153
- // pattern 태그 → Patterns, anti-pattern 태그 Anti-Patterns, 그 외 → Concepts.
154
- // index.md, log.md는 메타 파일이므로 제외.
155
- const looseFiles = fs.existsSync(wikiRoot)
156
- ? fs.readdirSync(wikiRoot)
157
- .filter(f => f.endsWith('.md') && f !== 'index.md' && f !== 'log.md')
158
- : [];
159
- const looseEntries = looseFiles.map(f => scanEntry(path.join(wikiRoot, f)));
160
- looseEntries.forEach(e => {
161
- const tagList = e.tags || [];
162
- if (tagList.includes('anti-pattern') || tagList.includes('antipattern')) {
163
- antipatterns.push(e);
164
- } else if (tagList.includes('pattern')) {
165
- patterns.push(e);
166
- } else {
167
- concepts.push(e);
168
- }
169
- });
170
-
171
- // 알파벳 정렬로 인덱스 안정성 확보 (재실행마다 동일 순서).
172
- const sortById = (a, b) => a.id.localeCompare(b.id);
173
- concepts.sort(sortById);
174
- patterns.sort(sortById);
175
- antipatterns.sort(sortById);
176
- answers.sort(sortById);
177
-
178
- // index.md 헤더 projectName이 설정되면 포함, 없으면 제네릭 문구.
179
- const headerLine = config.projectName
180
- ? `${config.projectName} 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`
181
- : `이 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`;
182
-
183
- let indexContent = `---\ntags: [index]\n\n# Wiki Index\n\n${headerLine}\n`;
184
-
185
- indexContent = addSection(indexContent, 'Concepts', '개념', concepts);
186
- indexContent = addSection(indexContent, 'Patterns', '패턴', patterns);
187
- indexContent = addSection(indexContent, 'Anti-Patterns', '안티패턴', antipatterns);
188
- indexContent = addSection(indexContent, 'Answers', '답변', answers);
189
-
190
- indexContent += `\n---\n\n## Statistics\n\n`;
191
- indexContent += `- Total concepts: ${concepts.length}\n`;
192
- indexContent += `- Total patterns: ${patterns.length}\n`;
193
- indexContent += `- Total anti-patterns: ${antipatterns.length}\n`;
194
- indexContent += `- Total answers: ${answers.length}\n`;
195
-
196
- // 컴파일 상태 여기가 유일한 쓰기점. lastCompiled 단조 가드(max)로 시계 오차
197
- // 머신이 날짜를 미래로 점프시키지 않게 하고, index.md의 Last updated와 같은 값을 쓴다.
198
- const previous = loadCompileState(wikiRoot);
199
- const today = localToday();
200
- const lastCompiled = previous && previous.lastCompiled > today ? previous.lastCompiled : today;
201
- indexContent += `- Last updated: ${lastCompiled}\n`;
202
-
203
- fs.writeFileSync(indexPath, indexContent);
204
- console.log(`Index successfully rebuilt at ${indexPath}`);
205
- writeCompileState(wikiRoot, rawRoot, lastCompiled, previous);
206
-
207
- return { wikiRoot, rawRoot, config };
208
- }
209
-
210
- // 인덱스 섹션 하나. 별칭 열은 검색 어휘 그물 frontmatter aliases가 여기 실려
211
- // index.md 전체가 grep/QMD 검색 대상이 된다 (개선계획 1-2).
212
- function addSection(indexContent, title, headerLabel, entries) {
213
- indexContent += `\n---\n\n## ${title}\n\n| ${headerLabel} | 설명 | 별칭 |\n|------|------|------|\n`;
214
- entries.forEach(e => {
215
- indexContent += `| [[${e.id}]] | ${e.description} | ${(e.aliases || []).join(', ')} |\n`;
216
- });
217
- return indexContent;
218
- }
219
-
220
- // 문장 추출 inline code(백틱) 안의 마침표는 무시.
221
- // 끝 마침표 = 마침표 뒤에 공백+문자(다음 문장 시작) 오거나, 마침표가 줄 끝.
222
- function firstSentence(line) {
223
- let result = '';
224
- let inBacktick = false;
225
- for (let i = 0; i < line.length; i++) {
226
- const ch = line[i];
227
- if (ch === '`') { inBacktick = !inBacktick; result += ch; continue; }
228
- result += ch;
229
- if (ch === '.' && !inBacktick) {
230
- const rest = line.slice(i + 1);
231
- if (/^\s+[A-Z가-힣]/.test(rest) || i === line.length - 1) {
232
- return result.trim();
233
- }
234
- }
235
- }
236
- return result.trim();
237
- }
238
-
239
- function scanDirectory(dir) {
240
- if (!fs.existsSync(dir)) return [];
241
- return fs.readdirSync(dir)
242
- .filter(f => f.endsWith('.md'))
243
- .map(f => scanEntry(path.join(dir, f)));
244
- }
245
-
246
- // 단일 파일에서 {id, description, tags, aliases} 추출.
247
- // description은 YAML description blockquote 요약 문장 순서로.
248
- // tags는 loose 파일 분류를 위해, aliases는 인덱스 별칭 열을 위해 frontmatter에서 파싱.
249
- function scanEntry(filePath) {
250
- const content = fs.readFileSync(filePath, 'utf8');
251
- const id = path.basename(filePath, '.md');
252
- let description = 'No description available.';
253
- let tags = [];
254
- let aliases = [];
255
-
256
- // YAML frontmatter 추출 (description + tags 모두 이 블록에서).
257
- // CRLF 대응: \r?\n 으로 줄바꿈 매칭 (윈도우 체크아웃 시 파일이 CRLF).
258
- const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
259
- if (yamlMatch) {
260
- const yaml = yamlMatch[1];
261
-
262
- // description (명시적이면 가장 정확)
263
- const descMatch = yaml.match(/^description:\s*(.+?)\r?$/m);
264
- if (descMatch) {
265
- description = descMatch[1].trim().replace(/^["']|["']$/g, '');
266
- } else {
267
- // description이 없으면 title 다음 첫 paragraph/blockquote에서 첫 문장.
268
- const lines = content.split('\n');
269
- const titleIndex = lines.findIndex(l => l.startsWith('# '));
270
- if (titleIndex !== -1) {
271
- for (let i = titleIndex + 1; i < lines.length; i++) {
272
- const line = lines[i].trim();
273
- if (line.startsWith('> ')) {
274
- description = firstSentence(line.slice(2).trim());
275
- break;
276
- }
277
- if (line && !line.startsWith('#') && !line.startsWith('---')) {
278
- description = firstSentence(line);
279
- break;
280
- }
281
- }
282
- }
283
- }
284
-
285
- // tags 파싱 — `[a, b, c]` 형태를 배열로.
286
- const tagsMatch = yaml.match(/^tags:\s*\[(.*)\]\r?$/m);
287
- if (tagsMatch) {
288
- tags = tagsMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
289
- }
290
-
291
- // aliases 파싱 같은 개념의 다른 이름들. 인덱스 표에 실려 grep/QMD 검색 어휘가 된다
292
- // (쓸 때와 찾을 때 어휘가 달라 못 찾는 문제의 두 번째 그물, 개선계획 1-2).
293
- const aliasesMatch = yaml.match(/^aliases:\s*\[(.*)\]\r?$/m);
294
- if (aliasesMatch) {
295
- aliases = aliasesMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
296
- }
297
- }
298
-
299
- return { id, description, tags, aliases };
300
- }
301
-
302
- function syncQmd(ctx) {
303
- const { wikiRoot, rawRoot, config } = ctx;
304
- const qmdCli = findQmd();
305
-
306
- if (!qmdCli) {
307
- console.log('QMD not found, skipping search index sync.');
308
- console.log('(Optional) Install qmd for semantic search: npm i @tobilu/qmd');
309
- return false;
310
- }
311
-
312
- try {
313
- const listOutput = execSync(`node "${qmdCli}" collection list`, { encoding: 'utf8' });
314
-
315
- // wiki 콜렉션: 컴파일된 위키. raw 콜렉션: 일일 raw 로그.
316
- // search.js는 콜렉션을 모두 조회하므로, 둘 다 프로비저닝되어 있어야
317
- // semantic search가 wiki + raw 양쪽을 커버함.
318
- const collections = [
319
- { name: config.collections.wiki, path: wikiRoot },
320
- { name: config.collections.raw, path: rawRoot },
321
- ];
322
-
323
- for (const { name, path: collPath } of collections) {
324
- if (!listOutput.includes(name)) {
325
- console.log(`Creating QMD collection '${name}'...`);
326
- execSync(`node "${qmdCli}" collection add "${collPath}" --name ${name}`, { encoding: 'utf8', stdio: 'inherit' });
327
- }
328
- }
329
-
330
- console.log('Updating QMD index...');
331
- execSync(`node "${qmdCli}" update`, { encoding: 'utf8', stdio: 'inherit' });
332
-
333
- console.log('Refreshing QMD embeddings...');
334
- execSync(`node "${qmdCli}" embed`, { encoding: 'utf8', stdio: 'inherit' });
335
-
336
- console.log(' QMD search index synced.');
337
- return true;
338
- } catch (error) {
339
- console.error('QMD sync failed:', error.message);
340
- console.error('Wiki index rebuilt successfully, but search index may be stale.');
341
- return false;
342
- }
343
- }
344
-
345
- function compile(command, options = {}) {
346
- if (command === 'list') {
347
- const data = collectNewLogs(findDocRoot());
348
- // json 모드에서는 텍스트 렌더를 하지 않는다 — 봉투만이 계약이다.
349
- if (options.json) console.log(JSON.stringify(data, null, 2));
350
- else renderList(data);
351
- } else if (command === 'index') {
352
- const ctx = rebuildIndex();
353
- const qmdSynced = syncQmd(ctx);
354
- if (options.json) {
355
- console.log(JSON.stringify({
356
- schemaVersion: 1,
357
- kind: 'compile-index',
358
- indexPath: path.join(ctx.wikiRoot, 'index.md'),
359
- qmdSynced,
360
- }, null, 2));
361
- }
362
- } else {
363
- console.log('Usage: llm-wiki compile <list|index>');
364
- console.log(' list - show raw logs modified since last compile');
365
- console.log(' index - rebuild wiki index.md and sync QMD search index');
366
- }
367
- }
368
-
369
- module.exports = { compile };
1
+ // wiki-compile — raw 로그에서 지식 추출/합성/비활성화 + index.md 재구축 + qmd 동기화.
2
+ // 원본: TTSTextViewer/.agents/skills/wiki-compile/scripts/compile.js (2026-07-24 loose 파일 분류 + CRLF fix 포함,
3
+ // 2026-08-21 list 판정 mtime→헤더 날짜 fix 역동기화)
4
+ // 변경점:
5
+ // - __dirname '../../../../' → findDocRoot()
6
+ // - 하드코딩 qmdCli 경로 → findQmd()
7
+ // - "TTSTextViewer 프로젝트의..." 문자열 → config.projectName (null이면 제네릭 문구)
8
+ // - COLLECTION/ COLLECTION_RAW → config.collections
9
+ // - 2026-08-29 (개선계획 1-6): compile list/index가 --json 봉투를 지원.
10
+ // {schemaVersion: 1, kind: 'compile-list'|'compile-index', ...}
11
+ // - 2026-08-29 (사각지대 수정): 같은 날 append가 미컴파일로 감지되지 않던 결함을
12
+ // 컴파일 상태 해시로 고친다. subagent 설계 리뷰(채택+수정 3건) 반영:
13
+ // ① list는 읽기전용 — 상태의 유일한 쓰기점은 compile index(전체 재생성, seed 특례 없음)
14
+ // ② lastCompiled 단조 가드(max) — 시계 오차 머신이 미래로 점프시키지 않게
15
+ // ③ 키 정렬 + LF 직렬화 — 머신 간 자동 머지 극대화. 충돌 해상도는 자명:
16
+ // 아무 쪽이나 남기고 `compile index` 1회면 현재 raw로부터 결정적으로 재생성된다.
17
+ // 날짜 절(date > lastCompiled)의 역할은 1차 탐지가 아니라 상태 유실 시의 안전망이다.
18
+ const crypto = require('crypto');
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { localToday } = require('./local-today');
22
+ const { execFileSync } = require('child_process');
23
+ const { findDocRoot, loadConfig } = require('./find-doc-root');
24
+ const { findQmd } = require('./find-qmd');
25
+
26
+ const STATE_VERSION = 1;
27
+
28
+ // 정규화+해시는 읽기(list)와 쓰기(index)가 반드시 헬퍼 하나만 쓴다 —
29
+ // 구현 간 불일치로 인한 영구 오탐/누락을 원천 차단한다.
30
+ function contentHash(content) {
31
+ return crypto.createHash('sha256').update(content.replace(/\r\n/g, '\n')).digest('hex');
32
+ }
33
+
34
+ function loadCompileState(wikiRoot) {
35
+ const p = path.join(wikiRoot, 'compile-state.json');
36
+ if (!fs.existsSync(p)) return null;
37
+ try {
38
+ const state = JSON.parse(fs.readFileSync(p, 'utf8').replace(/\r/g, ''));
39
+ if (!state || state.schemaVersion !== STATE_VERSION) return null;
40
+ return state;
41
+ } catch {
42
+ return null; // 깨진 상태는 없음 취급 — 날짜 규칙(안전망)으로 폴백
43
+ }
44
+ }
45
+
46
+ // "오늘" 판정은 공유 유틸(lib/local-today.js) 칸반 게이트·보드 뷰와 같은 규칙을 쓴다.
47
+
48
+ // 상태 재생성 compile index 말미에서만 호출된다 (컴파일 완료 선언의 기록).
49
+ function writeCompileState(wikiRoot, rawRoot, lastCompiled, previous) {
50
+ const files = {};
51
+ // raw/가 아직 없어도(초기 스캐폴드 직후 등) 빈 files로 기록한다 — 크래시 없이(5-2).
52
+ if (fs.existsSync(rawRoot)) {
53
+ for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
54
+ files[f] = contentHash(fs.readFileSync(path.join(rawRoot, f), 'utf8'));
55
+ }
56
+ }
57
+ const state = { schemaVersion: STATE_VERSION, lastCompiled, files };
58
+ fs.writeFileSync(path.join(wikiRoot, 'compile-state.json'), JSON.stringify(state, null, 2) + '\n');
59
+ if (!previous) {
60
+ console.log(`컴파일 상태를 생성했다 — 기존 ${Object.keys(files).length}개 파일을 컴파일된 것으로 간주 (lint가 의미 기반 백스톱).`);
61
+ }
62
+ return state;
63
+ }
64
+
65
+ // "새 로그" 데이터 수집 — 판정: 날짜(안전망) OR 해시 불일치(정밀).
66
+ // 날짜 판정은 파일 mtime이 아니라 로그 헤더 날짜(# YYYY-MM-DD) 한다
67
+ // git checkout이 mtime 머신마다 갈리는 문제(fd01a03) 재발 방지.
68
+ function collectNewLogs(docRoot) {
69
+ const wikiRoot = path.join(docRoot, 'wiki');
70
+ const rawRoot = path.join(docRoot, 'raw');
71
+ const state = loadCompileState(wikiRoot);
72
+ const stateMissing = state === null;
73
+
74
+ let lastUpdated = '0000-00-00';
75
+ if (state) {
76
+ lastUpdated = state.lastCompiled;
77
+ } else {
78
+ const indexPath = path.join(wikiRoot, 'index.md');
79
+ if (fs.existsSync(indexPath)) {
80
+ const match = fs.readFileSync(indexPath, 'utf8').match(/Last updated: (\d{4}-\d{2}-\d{2})/);
81
+ if (match) lastUpdated = match[1];
82
+ }
83
+ }
84
+
85
+ const newLogs = [];
86
+ if (fs.existsSync(rawRoot)) {
87
+ for (const f of fs.readdirSync(rawRoot).filter(f => f.endsWith('.md')).sort()) {
88
+ const filePath = path.join(rawRoot, f);
89
+ const content = fs.readFileSync(filePath, 'utf8');
90
+ const header = content.match(/^#\s+(\d{4}-\d{2}-\d{2})/m);
91
+ const date = header ? header[1] : localToday(fs.statSync(filePath).mtime);
92
+
93
+ let reason = null;
94
+ if (date > lastUpdated) {
95
+ reason = 'new-date';
96
+ } else if (!stateMissing) {
97
+ const stored = state.files[f];
98
+ if (stored === undefined) reason = 'no-entry';
99
+ else if (stored !== contentHash(content)) reason = 'modified';
100
+ }
101
+ if (reason) newLogs.push({ name: f, date, reason });
102
+ }
103
+ }
104
+
105
+ newLogs.sort((a, b) => a.date.localeCompare(b.date) || a.name.localeCompare(b.name));
106
+ return { schemaVersion: 1, kind: 'compile-list', lastCompiled: lastUpdated, state: stateMissing ? 'missing' : 'ok', newLogs };
107
+ }
108
+
109
+ function renderList(data) {
110
+ console.log(`Last compiled date: ${data.lastCompiled}`);
111
+ if (data.state === 'missing') {
112
+ console.log('상태 파일 없음 — 날짜 규칙으로 판정한다 (compile index가 생성).');
113
+ }
114
+ console.log('Scanning for new raw logs...');
115
+
116
+ if (data.newLogs.length === 0) {
117
+ console.log('No new logs found.');
118
+ return;
119
+ }
120
+
121
+ console.log('\nNew/Modified logs to process:');
122
+ data.newLogs.forEach(f => console.log(`- ${f.name} (${f.date})${f.reason !== 'new-date' ? ` [${f.reason}]` : ''}`));
123
+ }
124
+
125
+ function rebuildIndex() {
126
+ const docRoot = findDocRoot();
127
+ const wikiRoot = path.join(docRoot, 'wiki');
128
+ const rawRoot = path.join(docRoot, 'raw');
129
+ const indexPath = path.join(wikiRoot, 'index.md');
130
+ const config = loadConfig(docRoot);
131
+
132
+ if (!fs.existsSync(wikiRoot)) {
133
+ console.error(`Wiki directory not found: ${wikiRoot}`);
134
+ process.exit(1);
135
+ }
136
+
137
+ console.log('Rebuilding Wiki Index...');
138
+
139
+ const conceptsDir = path.join(wikiRoot, 'concepts');
140
+ const patternsDir = path.join(wikiRoot, 'patterns');
141
+ const antipatternsDir = path.join(wikiRoot, 'antipatterns');
142
+ const answersDir = path.join(wikiRoot, 'answers');
143
+
144
+ // 서브디렉토리 분류는 디렉토리 자체가 분류 판정 (이미 물리적으로 정리됨).
145
+ let concepts = scanDirectory(conceptsDir);
146
+ let patterns = scanDirectory(patternsDir);
147
+ let antipatterns = scanDirectory(antipatternsDir);
148
+ // answers/ 검색해서 종합한 유용한 답변의 아카이브 (개선계획 1-4).
149
+ let answers = scanDirectory(answersDir);
150
+
151
+ // loose 파일(루트 *.md)은 tags 기반으로 분류 — 파일 이동 없이 인덱스/검색에 반영.
152
+ // pattern 태그 Patterns, anti-pattern 태그 Anti-Patterns, Concepts.
153
+ // index.md, log.md는 메타 파일이므로 제외.
154
+ const looseFiles = fs.existsSync(wikiRoot)
155
+ ? fs.readdirSync(wikiRoot)
156
+ .filter(f => f.endsWith('.md') && f !== 'index.md' && f !== 'log.md')
157
+ : [];
158
+ const looseEntries = looseFiles.map(f => scanEntry(path.join(wikiRoot, f)));
159
+ looseEntries.forEach(e => {
160
+ const tagList = e.tags || [];
161
+ if (tagList.includes('anti-pattern') || tagList.includes('antipattern')) {
162
+ antipatterns.push(e);
163
+ } else if (tagList.includes('pattern')) {
164
+ patterns.push(e);
165
+ } else {
166
+ concepts.push(e);
167
+ }
168
+ });
169
+
170
+ // 알파벳 정렬로 인덱스 안정성 확보 (재실행마다 동일 순서).
171
+ const sortById = (a, b) => a.id.localeCompare(b.id);
172
+ concepts.sort(sortById);
173
+ patterns.sort(sortById);
174
+ antipatterns.sort(sortById);
175
+ answers.sort(sortById);
176
+
177
+ // index.md 헤더 — projectName이 설정되면 포함, 없으면 제네릭 문구.
178
+ const headerLine = config.projectName
179
+ ? `${config.projectName} 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`
180
+ : `이 프로젝트의 구조화된 지식 베이스입니다. \`doc/raw/\` 로그에서 추출한 핵심 개념과 패턴을 정리했습니다.`;
181
+
182
+ let indexContent = `---\ntags: [index]\n\n# Wiki Index\n\n${headerLine}\n`;
183
+
184
+ indexContent = addSection(indexContent, 'Concepts', '개념', concepts);
185
+ indexContent = addSection(indexContent, 'Patterns', '패턴', patterns);
186
+ indexContent = addSection(indexContent, 'Anti-Patterns', '안티패턴', antipatterns);
187
+ indexContent = addSection(indexContent, 'Answers', '답변', answers);
188
+
189
+ indexContent += `\n---\n\n## Statistics\n\n`;
190
+ indexContent += `- Total concepts: ${concepts.length}\n`;
191
+ indexContent += `- Total patterns: ${patterns.length}\n`;
192
+ indexContent += `- Total anti-patterns: ${antipatterns.length}\n`;
193
+ indexContent += `- Total answers: ${answers.length}\n`;
194
+
195
+ // 컴파일 상태 — 여기가 유일한 쓰기점. lastCompiled 단조 가드(max)로 시계 오차
196
+ // 머신이 날짜를 미래로 점프시키지 않게 하고, index.md의 Last updated와 같은 값을 쓴다.
197
+ const previous = loadCompileState(wikiRoot);
198
+ const today = localToday();
199
+ const lastCompiled = previous && previous.lastCompiled > today ? previous.lastCompiled : today;
200
+ indexContent += `- Last updated: ${lastCompiled}\n`;
201
+
202
+ fs.writeFileSync(indexPath, indexContent);
203
+ console.log(`Index successfully rebuilt at ${indexPath}`);
204
+ writeCompileState(wikiRoot, rawRoot, lastCompiled, previous);
205
+
206
+ return { wikiRoot, rawRoot, config };
207
+ }
208
+
209
+ // 인덱스 섹션 표 하나. 별칭 열은 검색 어휘 그물 — frontmatter aliases가 여기 실려
210
+ // index.md 전체가 grep/QMD 검색 대상이 된다 (개선계획 1-2).
211
+ function addSection(indexContent, title, headerLabel, entries) {
212
+ indexContent += `\n---\n\n## ${title}\n\n| ${headerLabel} | 설명 | 별칭 |\n|------|------|------|\n`;
213
+ entries.forEach(e => {
214
+ indexContent += `| [[${e.id}]] | ${e.description} | ${(e.aliases || []).join(', ')} |\n`;
215
+ });
216
+ return indexContent;
217
+ }
218
+
219
+ // 첫 문장 추출 — inline code(백틱) 안의 마침표는 무시.
220
+ // 마침표 = 마침표 뒤에 공백+문자(다음 문장 시작) 오거나, 마침표가 줄 끝.
221
+ function firstSentence(line) {
222
+ let result = '';
223
+ let inBacktick = false;
224
+ for (let i = 0; i < line.length; i++) {
225
+ const ch = line[i];
226
+ if (ch === '`') { inBacktick = !inBacktick; result += ch; continue; }
227
+ result += ch;
228
+ if (ch === '.' && !inBacktick) {
229
+ const rest = line.slice(i + 1);
230
+ if (/^\s+[A-Z가-힣]/.test(rest) || i === line.length - 1) {
231
+ return result.trim();
232
+ }
233
+ }
234
+ }
235
+ return result.trim();
236
+ }
237
+
238
+ function scanDirectory(dir) {
239
+ if (!fs.existsSync(dir)) return [];
240
+ return fs.readdirSync(dir)
241
+ .filter(f => f.endsWith('.md'))
242
+ .map(f => scanEntry(path.join(dir, f)));
243
+ }
244
+
245
+ // 단일 파일에서 {id, description, tags, aliases} 추출.
246
+ // description은 YAML description blockquote 요약 → 첫 문장 순서로.
247
+ // tags는 loose 파일 분류를 위해, aliases는 인덱스 별칭 열을 위해 frontmatter에서 파싱.
248
+ function scanEntry(filePath) {
249
+ const content = fs.readFileSync(filePath, 'utf8');
250
+ const id = path.basename(filePath, '.md');
251
+ let description = 'No description available.';
252
+ let tags = [];
253
+ let aliases = [];
254
+
255
+ // YAML frontmatter 추출 (description + tags 모두 이 블록에서).
256
+ // CRLF 대응: \r?\n 으로 줄바꿈 매칭 (윈도우 체크아웃 파일이 CRLF).
257
+ const yamlMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m);
258
+ if (yamlMatch) {
259
+ const yaml = yamlMatch[1];
260
+
261
+ // description (명시적이면 가장 정확)
262
+ const descMatch = yaml.match(/^description:\s*(.+?)\r?$/m);
263
+ if (descMatch) {
264
+ description = descMatch[1].trim().replace(/^["']|["']$/g, '');
265
+ } else {
266
+ // description이 없으면 title 다음 첫 paragraph/blockquote에서 첫 문장.
267
+ const lines = content.split('\n');
268
+ const titleIndex = lines.findIndex(l => l.startsWith('# '));
269
+ if (titleIndex !== -1) {
270
+ for (let i = titleIndex + 1; i < lines.length; i++) {
271
+ const line = lines[i].trim();
272
+ if (line.startsWith('> ')) {
273
+ description = firstSentence(line.slice(2).trim());
274
+ break;
275
+ }
276
+ if (line && !line.startsWith('#') && !line.startsWith('---')) {
277
+ description = firstSentence(line);
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ // tags 파싱 — `[a, b, c]` 형태를 배열로.
285
+ const tagsMatch = yaml.match(/^tags:\s*\[(.*)\]\r?$/m);
286
+ if (tagsMatch) {
287
+ tags = tagsMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
288
+ }
289
+
290
+ // aliases 파싱 — 같은 개념의 다른 이름들. 인덱스 표에 실려 grep/QMD 검색 어휘가 된다
291
+ // (쓸 때와 찾을 어휘가 달라 찾는 문제의 번째 그물, 개선계획 1-2).
292
+ const aliasesMatch = yaml.match(/^aliases:\s*\[(.*)\]\r?$/m);
293
+ if (aliasesMatch) {
294
+ aliases = aliasesMatch[1].split(',').map(t => t.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
295
+ }
296
+ }
297
+
298
+ return { id, description, tags, aliases };
299
+ }
300
+
301
+ function syncQmd(ctx) {
302
+ const { wikiRoot, rawRoot, config } = ctx;
303
+ const qmdCli = findQmd();
304
+
305
+ if (!qmdCli) {
306
+ console.log('QMD not found, skipping search index sync.');
307
+ console.log('(Optional) Install qmd for semantic search: npm i @tobilu/qmd');
308
+ return false;
309
+ }
310
+
311
+ try {
312
+ const listOutput = execFileSync('node', [qmdCli, 'collection', 'list'], { encoding: 'utf8' });
313
+
314
+ // wiki 콜렉션: 컴파일된 위키. raw 콜렉션: 일일 raw 로그.
315
+ // search.js는 콜렉션을 모두 조회하므로, 프로비저닝되어 있어야
316
+ // semantic search wiki + raw 양쪽을 커버함.
317
+ const collections = [
318
+ { name: config.collections.wiki, path: wikiRoot },
319
+ { name: config.collections.raw, path: rawRoot },
320
+ ];
321
+
322
+ for (const { name, path: collPath } of collections) {
323
+ // 단위 정확 매칭 includes는 'x-wiki'가 'x-wiki-raw' 줄에도 걸려 미등록 오판(5-2).
324
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
325
+ const listed = new RegExp(`^${escaped} \\(qmd://`, 'm').test(listOutput);
326
+ if (!listed) {
327
+ console.log(`Creating QMD collection '${name}'...`);
328
+ execFileSync('node', [qmdCli, 'collection', 'add', collPath, '--name', name], { stdio: 'inherit' });
329
+ }
330
+ }
331
+
332
+ console.log('Updating QMD index...');
333
+ execFileSync('node', [qmdCli, 'update'], { stdio: 'inherit' });
334
+
335
+ console.log('Refreshing QMD embeddings...');
336
+ execFileSync('node', [qmdCli, 'embed'], { stdio: 'inherit' });
337
+
338
+ console.log('✓ QMD search index synced.');
339
+ return true;
340
+ } catch (error) {
341
+ console.error('QMD sync failed:', error.message);
342
+ console.error('Wiki index rebuilt successfully, but search index may be stale.');
343
+ return false;
344
+ }
345
+ }
346
+
347
+ function compile(command, options = {}) {
348
+ if (command === 'list') {
349
+ const data = collectNewLogs(findDocRoot());
350
+ // json 모드에서는 텍스트 렌더를 하지 않는다 — 봉투만이 계약이다.
351
+ if (options.json) console.log(JSON.stringify(data, null, 2));
352
+ else renderList(data);
353
+ } else if (command === 'index') {
354
+ const ctx = rebuildIndex();
355
+ const qmdSynced = syncQmd(ctx);
356
+ if (options.json) {
357
+ console.log(JSON.stringify({
358
+ schemaVersion: 1,
359
+ kind: 'compile-index',
360
+ indexPath: path.join(ctx.wikiRoot, 'index.md'),
361
+ qmdSynced,
362
+ }, null, 2));
363
+ }
364
+ } else {
365
+ console.error('Usage: llm-wiki compile <list|index>');
366
+ console.error(' list - show raw logs modified since last compile');
367
+ console.error(' index - rebuild wiki index.md and sync QMD search index');
368
+ // 잘못된 서브커맨드는 실패다 — exit 0이면 LLM 호출자가 성공으로 오판한다(5-2).
369
+ process.exitCode = 1;
370
+ }
371
+ }
372
+
373
+ module.exports = { compile };