tycono-server 0.1.0-beta.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.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. package/templates/teams/startup.json +58 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * knowledge-importer.ts — AKB-aware document import service
3
+ *
4
+ * Scans directories for documents and creates AKB-formatted knowledge files.
5
+ * Processing priority: frontmatter → LLMProvider → claude -p CLI → simple fallback
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { execFile, execFileSync } from 'node:child_process';
10
+ import matter from 'gray-matter';
11
+ import type { LLMProvider } from '../engine/llm-adapter.js';
12
+
13
+ /* ─── Types ──────────────────────────────────── */
14
+
15
+ export interface ImportCallbacks {
16
+ onScanning: (scanPath: string, fileCount: number) => void;
17
+ onProcessing: (file: string, index: number, total: number) => void;
18
+ onCreated: (filePath: string, title: string, summary: string) => void;
19
+ onSkipped: (file: string, reason: string) => void;
20
+ onDone: (stats: { imported: number; created: number; skipped: number }) => void;
21
+ onError: (message: string) => void;
22
+ }
23
+
24
+ interface DocumentResult {
25
+ category: string;
26
+ title: string;
27
+ summary: string;
28
+ content: string;
29
+ akbType: 'hub' | 'node';
30
+ tags: string[];
31
+ }
32
+
33
+ /* ─── Constants ──────────────────────────────── */
34
+
35
+ const SUPPORTED_EXTENSIONS = new Set(['.md', '.txt', '.json', '.yaml', '.yml', '.csv']);
36
+ const MAX_FILE_SIZE = 100_000; // 100KB
37
+ const MAX_CONTENT_FOR_LLM = 8_000; // chars to send to LLM
38
+
39
+ const CLASSIFY_PROMPT = `You are a knowledge organizer. Given a document, respond ONLY in JSON (no markdown fences):
40
+ {
41
+ "category": "market|tech|process|domain|competitor|financial|general",
42
+ "title": "Short Title (max 60 chars)",
43
+ "summary": "One-line TL;DR (max 120 chars)",
44
+ "content": "# Title\\n\\n> TL;DR summary\\n\\n---\\n\\n(reformatted content in markdown)",
45
+ "akb_type": "hub|node",
46
+ "tags": ["tag1", "tag2"]
47
+ }`;
48
+
49
+ /* ─── File Collection ────────────────────────── */
50
+
51
+ function collectFiles(dirPath: string): string[] {
52
+ const files: string[] = [];
53
+
54
+ function walk(dir: string) {
55
+ let entries: fs.Dirent[];
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ } catch {
59
+ return;
60
+ }
61
+ for (const entry of entries) {
62
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
63
+ const full = path.join(dir, entry.name);
64
+ if (entry.isDirectory()) {
65
+ walk(full);
66
+ } else if (entry.isFile()) {
67
+ const ext = path.extname(entry.name).toLowerCase();
68
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
69
+ try {
70
+ const stat = fs.statSync(full);
71
+ if (stat.size <= MAX_FILE_SIZE && stat.size > 0) {
72
+ files.push(full);
73
+ }
74
+ } catch { /* skip */ }
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ walk(dirPath);
81
+ return files;
82
+ }
83
+
84
+ /* ─── Frontmatter-based processing (highest priority) ─── */
85
+
86
+ function extractTldr(content: string): string {
87
+ const blockquote = content.match(/^>\s+(.+)/m);
88
+ if (blockquote) return blockquote[1].trim().slice(0, 120);
89
+
90
+ const tldrSection = content.match(/##\s+TL;DR\s*\n+([^\n#]+)/i);
91
+ if (tldrSection) return tldrSection[1].trim().slice(0, 120);
92
+
93
+ const firstLine = content.split('\n').find(
94
+ (l) => l.trim().length > 0 && !l.startsWith('#') && !l.startsWith('---') && !l.startsWith('|')
95
+ );
96
+ return firstLine?.trim().slice(0, 120) ?? '';
97
+ }
98
+
99
+ function extractFrontmatterCategory(filePath: string): DocumentResult | null {
100
+ let raw: string;
101
+ try {
102
+ raw = fs.readFileSync(filePath, 'utf-8');
103
+ } catch {
104
+ return null;
105
+ }
106
+
107
+ if (raw.trim().length < 20) return null;
108
+
109
+ // Only parse if has frontmatter
110
+ if (!raw.startsWith('---')) return null;
111
+
112
+ const { data, content } = matter(raw);
113
+
114
+ // Need at least one AKB field
115
+ if (!data.title && !data.akb_type && !data.tags && !data.domain) return null;
116
+
117
+ const tags: string[] = Array.isArray(data.tags) ? data.tags : [];
118
+
119
+ // Determine category from tags or domain field
120
+ const domainTag = tags.find((t: string) => t.startsWith('domain/'));
121
+ const category: string = domainTag
122
+ ? domainTag.replace('domain/', '')
123
+ : (data.domain as string) || 'general';
124
+
125
+ // Title from frontmatter > first heading
126
+ let title: string = (data.title as string) ?? '';
127
+ if (!title) {
128
+ const match = content.match(/^#\s+(.+)/m);
129
+ title = match ? match[1].trim() : path.basename(filePath, path.extname(filePath));
130
+ }
131
+
132
+ const akbType: 'hub' | 'node' = data.akb_type === 'hub' ? 'hub' : 'node';
133
+ const summary = (data.summary as string) || (data.tldr as string) || extractTldr(content);
134
+
135
+ return {
136
+ category,
137
+ title,
138
+ summary,
139
+ content: raw, // preserve original content with frontmatter
140
+ akbType,
141
+ tags,
142
+ };
143
+ }
144
+
145
+ /* ─── LLM Processing via claude -p ───────────── */
146
+
147
+ async function processDocumentWithCli(filePath: string): Promise<DocumentResult | null> {
148
+ let content: string;
149
+ try {
150
+ content = fs.readFileSync(filePath, 'utf-8');
151
+ } catch {
152
+ return null;
153
+ }
154
+
155
+ if (content.trim().length < 20) return null;
156
+
157
+ const truncated = content.length > MAX_CONTENT_FOR_LLM
158
+ ? content.slice(0, MAX_CONTENT_FOR_LLM) + '\n\n[... truncated]'
159
+ : content;
160
+
161
+ const fileName = path.basename(filePath);
162
+ const userMessage = `File: ${fileName}\n\n${truncated}`;
163
+
164
+ try {
165
+ const env = { ...process.env };
166
+ delete env.CLAUDECODE;
167
+
168
+ const result = await new Promise<string>((resolve, reject) => {
169
+ execFile('claude', [
170
+ '-p',
171
+ '--system-prompt', CLASSIFY_PROMPT,
172
+ '--output-format', 'text',
173
+ '--model', 'claude-haiku-4-5-20251001',
174
+ '--max-turns', '1',
175
+ userMessage,
176
+ ], {
177
+ timeout: 30_000,
178
+ env,
179
+ encoding: 'utf-8',
180
+ maxBuffer: 1024 * 1024,
181
+ }, (err, stdout) => {
182
+ if (err) reject(err);
183
+ else resolve(stdout);
184
+ });
185
+ });
186
+
187
+ const jsonMatch = result.match(/\{[\s\S]*\}/);
188
+ if (!jsonMatch) return null;
189
+
190
+ const parsed = JSON.parse(jsonMatch[0]);
191
+ const akbType: 'hub' | 'node' = parsed.akb_type === 'hub' ? 'hub' : 'node';
192
+ const tags: string[] = Array.isArray(parsed.tags) ? parsed.tags : [];
193
+
194
+ return {
195
+ category: parsed.category || 'general',
196
+ title: parsed.title || fileName.replace(/\.[^.]+$/, ''),
197
+ summary: parsed.summary || '',
198
+ content: parsed.content || content,
199
+ akbType,
200
+ tags,
201
+ };
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /* ─── LLM Processing via LLMProvider interface ── */
208
+
209
+ async function processDocumentWithLLM(filePath: string, llm: LLMProvider): Promise<DocumentResult | null> {
210
+ let content: string;
211
+ try {
212
+ content = fs.readFileSync(filePath, 'utf-8');
213
+ } catch {
214
+ return null;
215
+ }
216
+
217
+ if (content.trim().length < 20) return null;
218
+
219
+ const truncated = content.length > MAX_CONTENT_FOR_LLM
220
+ ? content.slice(0, MAX_CONTENT_FOR_LLM) + '\n\n[... truncated]'
221
+ : content;
222
+
223
+ const fileName = path.basename(filePath);
224
+ const userMessage = `File: ${fileName}\n\n${truncated}`;
225
+
226
+ try {
227
+ const response = await llm.chat(
228
+ CLASSIFY_PROMPT,
229
+ [{ role: 'user', content: userMessage }],
230
+ undefined,
231
+ );
232
+
233
+ const textBlock = response.content.find((b) => b.type === 'text');
234
+ if (!textBlock || textBlock.type !== 'text') return null;
235
+
236
+ const jsonMatch = (textBlock as { type: 'text'; text: string }).text.match(/\{[\s\S]*\}/);
237
+ if (!jsonMatch) return null;
238
+
239
+ const parsed = JSON.parse(jsonMatch[0]);
240
+ const akbType: 'hub' | 'node' = parsed.akb_type === 'hub' ? 'hub' : 'node';
241
+ const tags: string[] = Array.isArray(parsed.tags) ? parsed.tags : [];
242
+
243
+ return {
244
+ category: parsed.category || 'general',
245
+ title: parsed.title || fileName.replace(/\.[^.]+$/, ''),
246
+ summary: parsed.summary || '',
247
+ content: parsed.content || content,
248
+ akbType,
249
+ tags,
250
+ };
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
256
+ /** Fallback: import without LLM classification */
257
+ function processDocumentSimple(filePath: string): DocumentResult | null {
258
+ let content: string;
259
+ try {
260
+ content = fs.readFileSync(filePath, 'utf-8');
261
+ } catch {
262
+ return null;
263
+ }
264
+
265
+ if (content.trim().length < 20) return null;
266
+
267
+ const fileName = path.basename(filePath, path.extname(filePath));
268
+ const title = fileName.replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
269
+ const summary = extractTldr(content);
270
+
271
+ return {
272
+ category: 'general',
273
+ title,
274
+ summary,
275
+ content,
276
+ akbType: 'node',
277
+ tags: [],
278
+ };
279
+ }
280
+
281
+ /* ─── Build AKB frontmatter content ─────────── */
282
+
283
+ function buildAkbContent(result: DocumentResult, sourceFile: string): string {
284
+ const date = new Date().toISOString().slice(0, 10);
285
+
286
+ // If content already has frontmatter, keep it as-is (just add source footer)
287
+ if (result.content.startsWith('---')) {
288
+ return result.content + `\n\n---\n\n*Source: ${path.basename(sourceFile)}*\n*Imported: ${date}*\n`;
289
+ }
290
+
291
+ // Build AKB frontmatter
292
+ const fm = [
293
+ '---',
294
+ `title: "${result.title.replace(/"/g, "'")}"`,
295
+ `akb_type: ${result.akbType}`,
296
+ `status: active`,
297
+ `tags: [${result.tags.map(t => `"${t}"`).join(', ')}]`,
298
+ `domain: ${result.category}`,
299
+ '---',
300
+ '',
301
+ ].join('\n');
302
+
303
+ const body = result.content.startsWith('#')
304
+ ? result.content
305
+ : `# ${result.title}\n\n${result.summary ? `> ${result.summary}\n\n` : ''}${result.content}`;
306
+
307
+ return fm + body + `\n\n---\n\n*Source: ${path.basename(sourceFile)}*\n*Imported: ${date}*\n`;
308
+ }
309
+
310
+ /* ─── Check if claude CLI is available ───────── */
311
+
312
+ function isClaudeCliAvailable(): boolean {
313
+ try {
314
+ const env = { ...process.env };
315
+ delete env.CLAUDECODE;
316
+ execFileSync('claude', ['--version'], { timeout: 5000, env, encoding: 'utf-8' });
317
+ return true;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ /* ─── Main Import Function ───────────────────── */
324
+
325
+ export async function importKnowledge(
326
+ paths: string[],
327
+ companyRoot: string,
328
+ callbacks: ImportCallbacks,
329
+ llm?: LLMProvider,
330
+ ): Promise<void> {
331
+ const useCli = !llm && isClaudeCliAvailable();
332
+ const allFiles: string[] = [];
333
+
334
+ // Phase 1: Scan
335
+ for (const p of paths) {
336
+ const resolved = path.resolve(p);
337
+ if (!fs.existsSync(resolved)) {
338
+ callbacks.onError(`Path not found: ${p}`);
339
+ continue;
340
+ }
341
+
342
+ const stat = fs.statSync(resolved);
343
+ if (stat.isDirectory()) {
344
+ const found = collectFiles(resolved);
345
+ allFiles.push(...found);
346
+ callbacks.onScanning(p, found.length);
347
+ } else if (stat.isFile()) {
348
+ allFiles.push(resolved);
349
+ callbacks.onScanning(p, 1);
350
+ }
351
+ }
352
+
353
+ if (allFiles.length === 0) {
354
+ callbacks.onDone({ imported: 0, created: 0, skipped: 0 });
355
+ return;
356
+ }
357
+
358
+ // Phase 2: Process each file
359
+ const knowledgeDir = path.join(companyRoot, 'knowledge');
360
+ fs.mkdirSync(knowledgeDir, { recursive: true });
361
+
362
+ // Load existing hub to skip duplicates
363
+ const hubPath = path.join(knowledgeDir, 'knowledge.md');
364
+ const existingHubContent = fs.existsSync(hubPath) ? fs.readFileSync(hubPath, 'utf-8') : '';
365
+
366
+ let created = 0;
367
+ let skipped = 0;
368
+ const hubEntries: { category: string; title: string; summary: string; filePath: string }[] = [];
369
+
370
+ for (let i = 0; i < allFiles.length; i++) {
371
+ const file = allFiles[i];
372
+ callbacks.onProcessing(path.basename(file), i + 1, allFiles.length);
373
+
374
+ // Processing priority: frontmatter → LLMProvider → CLI → simple fallback
375
+ let result: DocumentResult | null = extractFrontmatterCategory(file);
376
+ if (!result && llm) {
377
+ result = await processDocumentWithLLM(file, llm);
378
+ }
379
+ if (!result && useCli) {
380
+ result = await processDocumentWithCli(file);
381
+ }
382
+ if (!result) {
383
+ result = processDocumentSimple(file);
384
+ }
385
+
386
+ if (!result) {
387
+ skipped++;
388
+ callbacks.onSkipped(path.basename(file), 'Too short or unreadable');
389
+ continue;
390
+ }
391
+
392
+ // Ensure category directory exists
393
+ const categoryDir = path.join(knowledgeDir, result.category);
394
+ fs.mkdirSync(categoryDir, { recursive: true });
395
+
396
+ // Generate safe filename
397
+ const baseName = path.basename(file, path.extname(file));
398
+ const safeName = baseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
399
+ const outPath = path.join(categoryDir, `${safeName}.md`);
400
+ const relativePath = `knowledge/${result.category}/${safeName}.md`;
401
+
402
+ // Skip if already linked in hub (duplicate prevention)
403
+ if (existingHubContent.includes(relativePath)) {
404
+ skipped++;
405
+ callbacks.onSkipped(path.basename(file), 'Already imported');
406
+ continue;
407
+ }
408
+
409
+ const finalContent = buildAkbContent(result, file);
410
+ fs.writeFileSync(outPath, finalContent);
411
+ created++;
412
+
413
+ hubEntries.push({
414
+ category: result.category,
415
+ title: result.title,
416
+ summary: result.summary,
417
+ filePath: relativePath,
418
+ });
419
+
420
+ callbacks.onCreated(relativePath, result.title, result.summary);
421
+ }
422
+
423
+ // Phase 3: Update knowledge.md hub
424
+ updateKnowledgeHub(companyRoot, hubEntries, existingHubContent);
425
+
426
+ callbacks.onDone({ imported: allFiles.length, created, skipped });
427
+ }
428
+
429
+ /* ─── Hub Updater ────────────────────────────── */
430
+
431
+ function updateKnowledgeHub(
432
+ companyRoot: string,
433
+ entries: { category: string; title: string; summary: string; filePath: string }[],
434
+ existingContent: string,
435
+ ) {
436
+ if (entries.length === 0) return;
437
+
438
+ const hubPath = path.join(companyRoot, 'knowledge', 'knowledge.md');
439
+ let content = existingContent || '# Knowledge Base\n\nDomain knowledge.\n';
440
+
441
+ // Remove previous "## Imported Knowledge" section to avoid duplication
442
+ const importedIdx = content.indexOf('\n## Imported Knowledge');
443
+ if (importedIdx !== -1) {
444
+ content = content.slice(0, importedIdx);
445
+ }
446
+
447
+ // Group by category
448
+ const byCategory = new Map<string, typeof entries>();
449
+ for (const entry of entries) {
450
+ const list = byCategory.get(entry.category) || [];
451
+ list.push(entry);
452
+ byCategory.set(entry.category, list);
453
+ }
454
+
455
+ content += '\n## Imported Knowledge\n\n';
456
+
457
+ for (const [category, items] of byCategory) {
458
+ content += `### ${category.charAt(0).toUpperCase() + category.slice(1)}\n\n`;
459
+ for (const item of items) {
460
+ content += `- [${item.title}](${item.filePath})${item.summary ? ` — ${item.summary}` : ''}\n`;
461
+ }
462
+ content += '\n';
463
+ }
464
+
465
+ fs.writeFileSync(hubPath, content);
466
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Markdown 테이블을 JSON 배열로 변환하는 파서.
3
+ *
4
+ * roles.md, projects.md, tasks.md 등의 테이블 구조를 파싱한다.
5
+ * 헤더 행 + 구분선(---|---) + 데이터 행 패턴 처리.
6
+ */
7
+
8
+ /**
9
+ * Markdown 문자열에서 첫 번째 테이블을 찾아 객체 배열로 변환한다.
10
+ */
11
+ export function parseMarkdownTable(content: string): Record<string, string>[] {
12
+ const lines = content.split('\n');
13
+ const result: Record<string, string>[] = [];
14
+
15
+ let headers: string[] | null = null;
16
+ let inTable = false;
17
+
18
+ for (const line of lines) {
19
+ const trimmed = line.trim();
20
+ if (!trimmed.startsWith('|')) {
21
+ if (inTable) break; // 테이블 끝
22
+ continue;
23
+ }
24
+
25
+ const cells = parsePipeLine(trimmed);
26
+
27
+ // 구분선 (|---|---|---| 패턴) 건너뛰기
28
+ if (cells.every(c => /^[-: ]+$/.test(c))) {
29
+ inTable = true;
30
+ continue;
31
+ }
32
+
33
+ if (!headers) {
34
+ headers = cells.map(normalizeHeader);
35
+ continue;
36
+ }
37
+
38
+ if (!inTable) continue;
39
+
40
+ const row: Record<string, string> = {};
41
+ headers.forEach((header, i) => {
42
+ row[header] = cleanCellValue(cells[i] ?? '');
43
+ });
44
+ result.push(row);
45
+ }
46
+
47
+ return result;
48
+ }
49
+
50
+ /**
51
+ * Markdown 문자열에서 모든 테이블을 객체 배열로 변환한다.
52
+ */
53
+ export function parseAllMarkdownTables(content: string): Record<string, string>[][] {
54
+ const lines = content.split('\n');
55
+ const tables: Record<string, string>[][] = [];
56
+
57
+ let headers: string[] | null = null;
58
+ let currentTable: Record<string, string>[] = [];
59
+ let inTable = false;
60
+
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed.startsWith('|')) {
64
+ if (inTable) {
65
+ tables.push(currentTable);
66
+ currentTable = [];
67
+ headers = null;
68
+ inTable = false;
69
+ }
70
+ continue;
71
+ }
72
+
73
+ const cells = parsePipeLine(trimmed);
74
+
75
+ if (cells.every(c => /^[-: ]+$/.test(c))) {
76
+ inTable = true;
77
+ continue;
78
+ }
79
+
80
+ if (!headers) {
81
+ headers = cells.map(normalizeHeader);
82
+ continue;
83
+ }
84
+
85
+ if (!inTable) continue;
86
+
87
+ const row: Record<string, string> = {};
88
+ headers.forEach((header, i) => {
89
+ row[header] = cleanCellValue(cells[i] ?? '');
90
+ });
91
+ currentTable.push(row);
92
+ }
93
+
94
+ if (inTable && currentTable.length > 0) {
95
+ tables.push(currentTable);
96
+ }
97
+
98
+ return tables;
99
+ }
100
+
101
+ /**
102
+ * Markdown 본문에서 특정 ## 섹션의 내용을 추출한다.
103
+ */
104
+ export function extractSection(content: string, sectionName: string): string | null {
105
+ const lines = content.split('\n');
106
+ let capturing = false;
107
+ const captured: string[] = [];
108
+
109
+ for (const line of lines) {
110
+ if (line.match(new RegExp(`^##\\s+${escapeRegex(sectionName)}`, 'i'))) {
111
+ capturing = true;
112
+ continue;
113
+ }
114
+ if (capturing && /^##\s+/.test(line)) {
115
+ break;
116
+ }
117
+ if (capturing) {
118
+ captured.push(line);
119
+ }
120
+ }
121
+
122
+ if (captured.length === 0) return null;
123
+ return captured.join('\n').trim();
124
+ }
125
+
126
+ /**
127
+ * Markdown 리스트 아이템을 문자열 배열로 추출한다.
128
+ */
129
+ export function extractListItems(content: string): string[] {
130
+ return content
131
+ .split('\n')
132
+ .filter(line => /^\s*[-*]\s+/.test(line))
133
+ .map(line => line.replace(/^\s*[-*]\s+/, '').trim());
134
+ }
135
+
136
+ /**
137
+ * Markdown bold 패턴에서 key-value를 추출한다.
138
+ * 예: "**도메인**: AI SaaS" → { key: "도메인", value: "AI SaaS" }
139
+ */
140
+ export function extractBoldKeyValues(content: string): Record<string, string> {
141
+ const result: Record<string, string> = {};
142
+ const regex = /\*\*(.+?)\*\*:\s*(.+)/g;
143
+ let match: RegExpExecArray | null;
144
+ while ((match = regex.exec(content)) !== null) {
145
+ result[match[1].trim()] = match[2].trim();
146
+ }
147
+ return result;
148
+ }
149
+
150
+ // --- Internal helpers ---
151
+
152
+ function parsePipeLine(line: string): string[] {
153
+ return line
154
+ .split('|')
155
+ .slice(1, -1) // 양끝 빈 문자열 제거
156
+ .map(cell => cell.trim());
157
+ }
158
+
159
+ function normalizeHeader(header: string): string {
160
+ return header
161
+ .toLowerCase()
162
+ .replace(/\s+/g, '_')
163
+ .replace(/[^a-z0-9_가-힣]/g, '');
164
+ }
165
+
166
+ function cleanCellValue(value: string): string {
167
+ // Markdown 링크 [text](url) → text 추출
168
+ return value.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
169
+ }
170
+
171
+ function escapeRegex(str: string): string {
172
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
173
+ }