stellavault 0.4.0 → 0.4.1

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.
@@ -474,6 +474,115 @@ export function createApiServer(options) {
474
474
  res.status(500).json({ error: 'Ingest failed' });
475
475
  }
476
476
  });
477
+ // POST /api/ingest/file — 웹 UI에서 파일 드래그앤드롭 인제스트
478
+ app.post('/api/ingest/file', async (req, res) => {
479
+ try {
480
+ const multer = (await import('multer')).default;
481
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
482
+ upload.single('file')(req, res, async (uploadErr) => {
483
+ if (uploadErr) {
484
+ res.status(400).json({ error: uploadErr.message || 'Upload failed' });
485
+ return;
486
+ }
487
+ const file = req.file;
488
+ if (!file) {
489
+ res.status(400).json({ error: 'No file provided' });
490
+ return;
491
+ }
492
+ // MIME 화이트리스트
493
+ const allowedMimes = new Set([
494
+ 'application/pdf',
495
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
496
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
497
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
498
+ 'application/vnd.ms-excel',
499
+ 'text/plain', 'text/markdown', 'text/csv',
500
+ ]);
501
+ if (!allowedMimes.has(file.mimetype)) {
502
+ res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
503
+ return;
504
+ }
505
+ try {
506
+ const { writeFileSync, unlinkSync } = await import('node:fs');
507
+ const { join } = await import('node:path');
508
+ const { tmpdir } = await import('node:os');
509
+ const tmpPath = join(tmpdir(), `sv-upload-${Date.now()}-${file.originalname}`);
510
+ // 임시 파일 저장 → 파서가 파일 경로 필요
511
+ writeFileSync(tmpPath, file.buffer);
512
+ const { extractFileContent, isBinaryFormat } = await import('../intelligence/file-extractors.js');
513
+ const ext = file.originalname.split('.').pop()?.toLowerCase() ?? '';
514
+ const binaryExts = new Set(['pdf', 'docx', 'pptx', 'xlsx', 'xls']);
515
+ let content;
516
+ let extractedTitle;
517
+ let formatTag = ext;
518
+ if (binaryExts.has(ext)) {
519
+ const extracted = await extractFileContent(tmpPath);
520
+ content = extracted.text;
521
+ extractedTitle = extracted.metadata.title;
522
+ formatTag = extracted.sourceFormat;
523
+ }
524
+ else {
525
+ content = file.buffer.toString('utf-8');
526
+ }
527
+ // 임시 파일 삭제
528
+ try {
529
+ unlinkSync(tmpPath);
530
+ }
531
+ catch { /* ok */ }
532
+ // locale 설정
533
+ const locale = req.body?.locale;
534
+ if (locale) {
535
+ const { setNoteLocale } = await import('../i18n/note-strings.js');
536
+ setNoteLocale(locale);
537
+ }
538
+ const tags = req.body?.tags ? (Array.isArray(req.body.tags) ? req.body.tags : req.body.tags.split(',').map((t) => t.trim())) : [];
539
+ const { ingest } = await import('../intelligence/ingest-pipeline.js');
540
+ const result = ingest(vaultPath, {
541
+ type: formatTag,
542
+ content,
543
+ tags: [...tags, formatTag],
544
+ title: req.body?.title ?? extractedTitle,
545
+ stage: 'fleeting',
546
+ source: file.originalname,
547
+ });
548
+ // 자동 인덱싱
549
+ try {
550
+ const doc = {
551
+ id: require('node:crypto').createHash('sha256').update(result.savedTo).digest('hex').slice(0, 16),
552
+ filePath: result.savedTo,
553
+ title: result.title,
554
+ content,
555
+ frontmatter: {},
556
+ tags: result.tags,
557
+ lastModified: new Date().toISOString(),
558
+ contentHash: '',
559
+ source: 'upload',
560
+ type: result.stage,
561
+ };
562
+ await store.upsertDocument(doc);
563
+ }
564
+ catch (indexErr) {
565
+ console.error('[ingest/file] Auto-index failed:', indexErr instanceof Error ? indexErr.message : indexErr);
566
+ }
567
+ res.json({
568
+ success: true,
569
+ savedTo: result.savedTo,
570
+ stage: result.stage,
571
+ title: result.title,
572
+ indexCode: result.indexCode,
573
+ tags: result.tags,
574
+ wordCount: result.wordCount,
575
+ });
576
+ }
577
+ catch (err) {
578
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Processing failed' });
579
+ }
580
+ });
581
+ }
582
+ catch (err) {
583
+ res.status(500).json({ error: 'File upload initialization failed' });
584
+ }
585
+ });
477
586
  // GET /api/recent — 최근 저장된 노트 목록
478
587
  app.get('/api/recent', async (_req, res) => {
479
588
  try {
@@ -1,6 +1,13 @@
1
+ export interface FolderNames {
2
+ fleeting: string;
3
+ literature: string;
4
+ permanent: string;
5
+ wiki: string;
6
+ }
1
7
  export interface StellavaultConfig {
2
8
  vaultPath: string;
3
9
  dbPath: string;
10
+ folders: FolderNames;
4
11
  embedding: {
5
12
  model: 'local' | 'openai';
6
13
  localModel: string;
@@ -19,6 +26,7 @@ export interface StellavaultConfig {
19
26
  port: number;
20
27
  };
21
28
  }
29
+ export declare const DEFAULT_FOLDERS: FolderNames;
22
30
  /**
23
31
  * .stellavault.json 파일을 찾아 로드합니다.
24
32
  * 탐색 순서: cwd → home directory → defaults
@@ -2,12 +2,19 @@
2
2
  import { readFileSync, existsSync } from 'node:fs';
3
3
  import { resolve, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
+ export const DEFAULT_FOLDERS = {
6
+ fleeting: 'raw',
7
+ literature: '_literature',
8
+ permanent: '_permanent',
9
+ wiki: '_wiki',
10
+ };
5
11
  const DEFAULT_CONFIG = {
6
12
  vaultPath: '',
7
13
  dbPath: join(homedir(), '.stellavault', 'index.db'),
14
+ folders: { ...DEFAULT_FOLDERS },
8
15
  embedding: {
9
16
  model: 'local',
10
- localModel: 'all-MiniLM-L6-v2',
17
+ localModel: 'paraphrase-multilingual-MiniLM-L12-v2',
11
18
  },
12
19
  chunking: {
13
20
  maxTokens: 300,
@@ -46,6 +53,7 @@ function mergeConfig(defaults, overrides) {
46
53
  return {
47
54
  vaultPath: overrides.vaultPath ?? defaults.vaultPath,
48
55
  dbPath: overrides.dbPath ?? defaults.dbPath,
56
+ folders: { ...defaults.folders, ...overrides.folders },
49
57
  embedding: { ...defaults.embedding, ...overrides.embedding },
50
58
  chunking: { ...defaults.chunking, ...overrides.chunking },
51
59
  search: { ...defaults.search, ...overrides.search },
@@ -1,5 +1,5 @@
1
- export { loadConfig } from './config.js';
2
- export type { StellavaultConfig } from './config.js';
1
+ export { loadConfig, DEFAULT_FOLDERS } from './config.js';
2
+ export type { StellavaultConfig, FolderNames } from './config.js';
3
3
  export type { Document } from './types/document.js';
4
4
  export type { Chunk, ScoredChunk } from './types/chunk.js';
5
5
  export type { SearchResult, SearchOptions, TopicInfo, StoreStats, } from './types/search.js';
@@ -1,6 +1,6 @@
1
1
  // Design Ref: §4.2 — Core Internal API (Facade)
2
2
  // Design Ref: §9.3 — Dependency Injection Pattern
3
- export { loadConfig } from './config.js';
3
+ export { loadConfig, DEFAULT_FOLDERS } from './config.js';
4
4
  // Store
5
5
  export { createSqliteVecStore } from './store/index.js';
6
6
  // Indexer
@@ -0,0 +1,19 @@
1
+ import { type FolderNames } from '../config.js';
2
+ export interface DraftOptions {
3
+ topic?: string;
4
+ format?: 'blog' | 'report' | 'outline';
5
+ maxSections?: number;
6
+ }
7
+ export interface DraftResult {
8
+ title: string;
9
+ filePath: string;
10
+ wordCount: number;
11
+ sourceCount: number;
12
+ concepts: string[];
13
+ }
14
+ /**
15
+ * vault의 wiki 데이터를 기반으로 초안(draft)을 생성.
16
+ * Express 단계: 지식이 vault에서 나가는 출구.
17
+ */
18
+ export declare function generateDraft(vaultPath: string, options?: DraftOptions, folders?: FolderNames): DraftResult;
19
+ //# sourceMappingURL=draft-generator.d.ts.map
@@ -0,0 +1,161 @@
1
+ // Express 단계: stellavault draft — wiki 기반 초안 생성
2
+ // Plan SC: SC3 (Express)
3
+ // 카파시의 "자가 컴파일" 결과물을 외부로 표현하는 출구
4
+ import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join, resolve, basename, extname } from 'node:path';
6
+ import { scanRawDirectory, extractConcepts } from './wiki-compiler.js';
7
+ import { DEFAULT_FOLDERS } from '../config.js';
8
+ /**
9
+ * vault의 wiki 데이터를 기반으로 초안(draft)을 생성.
10
+ * Express 단계: 지식이 vault에서 나가는 출구.
11
+ */
12
+ export function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
13
+ const { topic, format = 'blog', maxSections = 8 } = options;
14
+ // raw + wiki 문서 스캔
15
+ const rawDir = resolve(vaultPath, folders.fleeting);
16
+ const wikiDir = resolve(vaultPath, folders.wiki);
17
+ const litDir = resolve(vaultPath, folders.literature);
18
+ const allDocs = [];
19
+ for (const dir of [rawDir, wikiDir, litDir]) {
20
+ if (existsSync(dir)) {
21
+ allDocs.push(...scanRawDirectory(dir));
22
+ }
23
+ }
24
+ if (allDocs.length === 0) {
25
+ throw new Error('No documents found in vault. Run `stellavault ingest` first.');
26
+ }
27
+ // 토픽 필터
28
+ const filteredDocs = topic
29
+ ? allDocs.filter(d => d.tags.some(t => t.toLowerCase().includes(topic.toLowerCase())) ||
30
+ d.title.toLowerCase().includes(topic.toLowerCase()) ||
31
+ d.content.toLowerCase().includes(topic.toLowerCase()))
32
+ : allDocs;
33
+ if (filteredDocs.length === 0) {
34
+ throw new Error(`No documents found for topic "${topic}". Try a broader term.`);
35
+ }
36
+ // 개념 추출
37
+ const concepts = extractConcepts(filteredDocs);
38
+ const topConcepts = [...concepts.entries()]
39
+ .sort((a, b) => b[1].length - a[1].length)
40
+ .slice(0, maxSections);
41
+ // 초안 생성
42
+ const draftTitle = topic
43
+ ? `Draft: ${topic}`
44
+ : `Knowledge Draft — ${new Date().toISOString().split('T')[0]}`;
45
+ let body;
46
+ switch (format) {
47
+ case 'outline':
48
+ body = generateOutline(draftTitle, topConcepts, filteredDocs);
49
+ break;
50
+ case 'report':
51
+ body = generateReport(draftTitle, topConcepts, filteredDocs);
52
+ break;
53
+ case 'blog':
54
+ default:
55
+ body = generateBlog(draftTitle, topConcepts, filteredDocs);
56
+ break;
57
+ }
58
+ const wordCount = body.split(/\s+/).filter(Boolean).length;
59
+ // _drafts/ 폴더에 저장
60
+ const draftsDir = resolve(vaultPath, '_drafts');
61
+ if (!existsSync(draftsDir))
62
+ mkdirSync(draftsDir, { recursive: true });
63
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
64
+ const slug = (topic ?? 'knowledge').replace(/[^a-zA-Z0-9가-힣\s]/g, '').replace(/\s+/g, '-').toLowerCase().slice(0, 40);
65
+ const filename = `${timestamp}-${slug}.md`;
66
+ const filePath = join('_drafts', filename);
67
+ const fullPath = resolve(vaultPath, filePath);
68
+ writeFileSync(fullPath, body, 'utf-8');
69
+ return {
70
+ title: draftTitle,
71
+ filePath,
72
+ wordCount,
73
+ sourceCount: filteredDocs.length,
74
+ concepts: topConcepts.map(([c]) => c),
75
+ };
76
+ }
77
+ // ─── 포맷별 생성기 ───
78
+ function generateBlog(title, concepts, docs) {
79
+ const lines = [];
80
+ lines.push(`# ${title}`, '');
81
+ lines.push(`> Auto-generated from ${docs.length} knowledge notes`, '');
82
+ // 도입부: 전체 주제 요약
83
+ lines.push('## Introduction', '');
84
+ const topicWords = concepts.slice(0, 3).map(([c]) => c).join(', ');
85
+ lines.push(`This post explores ${topicWords} based on ${docs.length} curated knowledge notes.`, '');
86
+ // 섹션별: 개념 + 관련 문서 발췌
87
+ for (const [concept, docPaths] of concepts) {
88
+ lines.push(`## ${capitalize(concept)}`, '');
89
+ const relatedDocs = docs.filter(d => docPaths.includes(d.filePath) ||
90
+ d.tags.includes(concept) ||
91
+ d.title.toLowerCase().includes(concept.toLowerCase())).slice(0, 3);
92
+ for (const doc of relatedDocs) {
93
+ const excerpt = extractExcerpt(doc.content, 150);
94
+ if (excerpt) {
95
+ lines.push(`> ${excerpt}`, `> — *${doc.title}*`, '');
96
+ }
97
+ }
98
+ lines.push('<!-- TODO: Add your analysis and insights here -->', '');
99
+ }
100
+ // 참고 자료
101
+ lines.push('## References', '');
102
+ const uniqueDocs = [...new Map(docs.map(d => [d.filePath, d])).values()].slice(0, 20);
103
+ for (const doc of uniqueDocs) {
104
+ lines.push(`- [[${basename(doc.filePath, extname(doc.filePath))}|${doc.title}]]`);
105
+ }
106
+ lines.push('');
107
+ lines.push(`---`, `*Generated by \`stellavault draft\` at ${new Date().toISOString()}*`);
108
+ return lines.join('\n');
109
+ }
110
+ function generateReport(title, concepts, docs) {
111
+ const lines = [];
112
+ lines.push(`# ${title}`, '');
113
+ lines.push(`**Date:** ${new Date().toISOString().split('T')[0]}`);
114
+ lines.push(`**Sources:** ${docs.length} documents`);
115
+ lines.push(`**Key Topics:** ${concepts.map(([c]) => c).join(', ')}`, '');
116
+ lines.push('## Executive Summary', '');
117
+ lines.push('<!-- TODO: Write 2-3 sentence summary -->', '');
118
+ for (const [concept, docPaths] of concepts) {
119
+ lines.push(`## ${capitalize(concept)}`, '');
120
+ lines.push(`**Related documents:** ${docPaths.length}`, '');
121
+ const relatedDocs = docs.filter(d => docPaths.includes(d.filePath)).slice(0, 3);
122
+ for (const doc of relatedDocs) {
123
+ const excerpt = extractExcerpt(doc.content, 200);
124
+ if (excerpt)
125
+ lines.push(`- ${excerpt} *(${doc.title})*`);
126
+ }
127
+ lines.push('');
128
+ lines.push('**Analysis:** <!-- TODO -->', '');
129
+ }
130
+ lines.push('## Conclusion', '', '<!-- TODO -->', '');
131
+ lines.push(`---`, `*Generated by \`stellavault draft\` at ${new Date().toISOString()}*`);
132
+ return lines.join('\n');
133
+ }
134
+ function generateOutline(title, concepts, docs) {
135
+ const lines = [];
136
+ lines.push(`# ${title} — Outline`, '');
137
+ lines.push(`Sources: ${docs.length} documents`, '');
138
+ for (let i = 0; i < concepts.length; i++) {
139
+ const [concept, docPaths] = concepts[i];
140
+ lines.push(`${i + 1}. **${capitalize(concept)}** (${docPaths.length} sources)`);
141
+ const relatedDocs = docs.filter(d => docPaths.includes(d.filePath)).slice(0, 3);
142
+ for (const doc of relatedDocs) {
143
+ lines.push(` - ${doc.title}`);
144
+ }
145
+ }
146
+ lines.push('', `---`, `*Generated by \`stellavault draft\` at ${new Date().toISOString()}*`);
147
+ return lines.join('\n');
148
+ }
149
+ // ─── 유틸 ───
150
+ function extractExcerpt(content, maxLen) {
151
+ // frontmatter 제거
152
+ const body = content.replace(/^---[\s\S]*?---\n?/, '').replace(/^#+\s+.+\n/m, '').trim();
153
+ // 첫 의미 있는 문단
154
+ const paragraphs = body.split(/\n\n+/).filter(p => p.length > 20 && !p.startsWith('> ') && !p.startsWith('- '));
155
+ const first = paragraphs[0] ?? '';
156
+ return first.length > maxLen ? first.slice(0, maxLen) + '...' : first;
157
+ }
158
+ function capitalize(s) {
159
+ return s.charAt(0).toUpperCase() + s.slice(1);
160
+ }
161
+ //# sourceMappingURL=draft-generator.js.map
@@ -0,0 +1,18 @@
1
+ export interface ExtractedContent {
2
+ text: string;
3
+ metadata: {
4
+ title?: string;
5
+ author?: string;
6
+ pageCount?: number;
7
+ wordCount: number;
8
+ };
9
+ sourceFormat: 'pdf' | 'docx' | 'pptx' | 'xlsx' | 'xls' | 'text';
10
+ }
11
+ export declare function isBinaryFormat(filePath: string): boolean;
12
+ /**
13
+ * 파일 경로에서 텍스트 추출. 확장자 기반 파서 디스패치.
14
+ * 지원: .pdf, .docx, .pptx, .xlsx, .xls
15
+ * 미지원 확장자: utf-8 텍스트로 읽기
16
+ */
17
+ export declare function extractFileContent(filePath: string): Promise<ExtractedContent>;
18
+ //# sourceMappingURL=file-extractors.d.ts.map
@@ -0,0 +1,127 @@
1
+ // Design Ref: §file-ingest-v2 — Binary file text extraction dispatchers
2
+ // Plan SC: SC1-SC5 (format-specific extraction + fallback)
3
+ import { readFileSync } from 'node:fs';
4
+ import { extname, basename } from 'node:path';
5
+ const BINARY_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.xls']);
6
+ export function isBinaryFormat(filePath) {
7
+ return BINARY_EXTS.has(extname(filePath).toLowerCase());
8
+ }
9
+ /**
10
+ * 파일 경로에서 텍스트 추출. 확장자 기반 파서 디스패치.
11
+ * 지원: .pdf, .docx, .pptx, .xlsx, .xls
12
+ * 미지원 확장자: utf-8 텍스트로 읽기
13
+ */
14
+ export async function extractFileContent(filePath) {
15
+ const ext = extname(filePath).toLowerCase();
16
+ const buffer = readFileSync(filePath);
17
+ switch (ext) {
18
+ case '.pdf': return extractPdf(buffer, filePath);
19
+ case '.docx': return extractDocx(buffer, filePath);
20
+ case '.pptx': return extractPptx(buffer, filePath);
21
+ case '.xlsx': return extractXlsx(buffer, filePath);
22
+ case '.xls': return extractXlsx(buffer, filePath);
23
+ default: return extractText(filePath);
24
+ }
25
+ }
26
+ async function extractPdf(buffer, filePath) {
27
+ try {
28
+ const { extractText } = await import('unpdf');
29
+ const result = await extractText(new Uint8Array(buffer));
30
+ const text = Array.isArray(result.text) ? result.text.join('\n\n') : (result.text ?? '');
31
+ return {
32
+ text,
33
+ metadata: {
34
+ title: basename(filePath, '.pdf'),
35
+ pageCount: result.totalPages,
36
+ wordCount: text.split(/\s+/).filter(Boolean).length,
37
+ },
38
+ sourceFormat: 'pdf',
39
+ };
40
+ }
41
+ catch (err) {
42
+ console.error(`PDF extraction failed: ${err instanceof Error ? err.message : 'unknown'}`);
43
+ return fallback(filePath, 'pdf');
44
+ }
45
+ }
46
+ async function extractDocx(buffer, filePath) {
47
+ try {
48
+ const mammoth = await import('mammoth');
49
+ const result = await mammoth.default.extractRawText({ buffer });
50
+ const text = result.value ?? '';
51
+ return {
52
+ text,
53
+ metadata: {
54
+ title: basename(filePath, '.docx'),
55
+ wordCount: text.split(/\s+/).filter(Boolean).length,
56
+ },
57
+ sourceFormat: 'docx',
58
+ };
59
+ }
60
+ catch (err) {
61
+ console.error(`DOCX extraction failed: ${err instanceof Error ? err.message : 'unknown'}`);
62
+ return fallback(filePath, 'docx');
63
+ }
64
+ }
65
+ async function extractPptx(buffer, filePath) {
66
+ try {
67
+ const officeparser = await import('officeparser');
68
+ const text = String(await officeparser.default.parseOffice(buffer));
69
+ return {
70
+ text: text ?? '',
71
+ metadata: {
72
+ title: basename(filePath, '.pptx'),
73
+ wordCount: (text ?? '').split(/\s+/).filter(Boolean).length,
74
+ },
75
+ sourceFormat: 'pptx',
76
+ };
77
+ }
78
+ catch (err) {
79
+ console.error(`PPTX extraction failed: ${err instanceof Error ? err.message : 'unknown'}`);
80
+ return fallback(filePath, 'pptx');
81
+ }
82
+ }
83
+ async function extractXlsx(buffer, filePath) {
84
+ const ext = extname(filePath).toLowerCase();
85
+ const format = ext === '.xls' ? 'xls' : 'xlsx';
86
+ try {
87
+ const XLSX = await import('xlsx');
88
+ const workbook = XLSX.read(buffer);
89
+ const text = workbook.SheetNames
90
+ .map((name) => {
91
+ const csv = XLSX.utils.sheet_to_csv(workbook.Sheets[name]);
92
+ return `## ${name}\n\n${csv}`;
93
+ })
94
+ .join('\n\n');
95
+ return {
96
+ text,
97
+ metadata: {
98
+ title: basename(filePath, ext),
99
+ wordCount: text.split(/\s+/).filter(Boolean).length,
100
+ },
101
+ sourceFormat: format,
102
+ };
103
+ }
104
+ catch (err) {
105
+ console.error(`XLSX extraction failed: ${err instanceof Error ? err.message : 'unknown'}`);
106
+ return fallback(filePath, format);
107
+ }
108
+ }
109
+ function extractText(filePath) {
110
+ const text = readFileSync(filePath, 'utf-8');
111
+ return {
112
+ text,
113
+ metadata: {
114
+ title: basename(filePath),
115
+ wordCount: text.split(/\s+/).filter(Boolean).length,
116
+ },
117
+ sourceFormat: 'text',
118
+ };
119
+ }
120
+ function fallback(filePath, format) {
121
+ return {
122
+ text: `[Failed to extract text from ${basename(filePath)}. Install required parser or convert to a text format.]`,
123
+ metadata: { title: basename(filePath), wordCount: 0 },
124
+ sourceFormat: format,
125
+ };
126
+ }
127
+ //# sourceMappingURL=file-extractors.js.map
@@ -1,6 +1,7 @@
1
+ import { type FolderNames } from '../config.js';
1
2
  export type NoteStage = 'fleeting' | 'literature' | 'permanent';
2
3
  export interface IngestInput {
3
- type: 'url' | 'text' | 'file' | 'youtube' | 'pdf-text';
4
+ type: 'url' | 'text' | 'file' | 'youtube' | 'pdf-text' | 'pdf' | 'docx' | 'pptx' | 'xlsx' | 'xls';
4
5
  content: string;
5
6
  title?: string;
6
7
  tags?: string[];
@@ -18,7 +19,7 @@ export interface IngestResult {
18
19
  /**
19
20
  * 어떤 입력이든 Stellavault 표준 포맷으로 변환하여 저장.
20
21
  */
21
- export declare function ingest(vaultPath: string, input: IngestInput): IngestResult;
22
+ export declare function ingest(vaultPath: string, input: IngestInput, folders?: FolderNames): IngestResult;
22
23
  /**
23
24
  * 여러 입력을 배치 처리.
24
25
  */
@@ -27,5 +28,5 @@ export declare function ingestBatch(vaultPath: string, inputs: IngestInput[]): I
27
28
  * 노트 승격: fleeting → literature → permanent.
28
29
  * 내용이 충분히 정제되면 다음 단계로 이동.
29
30
  */
30
- export declare function promoteNote(vaultPath: string, filePath: string, targetStage: NoteStage): string;
31
+ export declare function promoteNote(vaultPath: string, filePath: string, targetStage: NoteStage, folders?: FolderNames): string;
31
32
  //# sourceMappingURL=ingest-pipeline.d.ts.map
@@ -6,6 +6,8 @@
6
6
  import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
7
7
  import { join, resolve, basename } from 'node:path';
8
8
  import { scanFrontmatter, assignIndexCodes, archiveFile } from './zettelkasten.js';
9
+ import { compileWiki } from './wiki-compiler.js';
10
+ import { DEFAULT_FOLDERS } from '../config.js';
9
11
  /** YAML 값에서 위험한 문자를 이스케이프 */
10
12
  function sanitizeYaml(val) {
11
13
  return val.replace(/["\\]/g, '\\$&').replace(/\n/g, ' ').slice(0, 200);
@@ -13,7 +15,7 @@ function sanitizeYaml(val) {
13
15
  /**
14
16
  * 어떤 입력이든 Stellavault 표준 포맷으로 변환하여 저장.
15
17
  */
16
- export function ingest(vaultPath, input) {
18
+ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
17
19
  const stage = input.stage ?? 'fleeting';
18
20
  const title = input.title ?? extractTitleFromContent(input.content, input.type);
19
21
  const tags = input.tags ?? extractAutoTags(input.content, input.type);
@@ -23,11 +25,11 @@ export function ingest(vaultPath, input) {
23
25
  const wordCount = body.split(/\s+/).length;
24
26
  // 자동 분류: 길이/구조에 따라 stage 결정
25
27
  const autoStage = classifyStage(body, stage, wordCount);
26
- // 폴더 결정
28
+ // 폴더 결정 (config-driven)
27
29
  const folderMap = {
28
- fleeting: 'raw',
29
- literature: '_literature',
30
- permanent: '_permanent',
30
+ fleeting: folders.fleeting,
31
+ literature: folders.literature,
32
+ permanent: folders.permanent,
31
33
  };
32
34
  const folder = folderMap[autoStage];
33
35
  const dir = resolve(vaultPath, folder);
@@ -67,6 +69,15 @@ export function ingest(vaultPath, input) {
67
69
  inputType: input.type,
68
70
  });
69
71
  writeFileSync(fullPath, md, 'utf-8');
72
+ // 자동 compile: fleeting → wiki (rule-based, <100ms)
73
+ try {
74
+ const rawDir = resolve(vaultPath, folders.fleeting);
75
+ const wikiDir = resolve(vaultPath, folders.wiki);
76
+ if (existsSync(rawDir)) {
77
+ compileWiki(rawDir, wikiDir);
78
+ }
79
+ }
80
+ catch { /* compile 실패해도 ingest 성공 */ }
70
81
  return {
71
82
  savedTo: filePath,
72
83
  stage: autoStage,
@@ -86,18 +97,18 @@ export function ingestBatch(vaultPath, inputs) {
86
97
  * 노트 승격: fleeting → literature → permanent.
87
98
  * 내용이 충분히 정제되면 다음 단계로 이동.
88
99
  */
89
- export function promoteNote(vaultPath, filePath, targetStage) {
100
+ export function promoteNote(vaultPath, filePath, targetStage, folders = DEFAULT_FOLDERS) {
90
101
  const fullPath = resolve(vaultPath, filePath);
91
102
  if (!existsSync(fullPath))
92
103
  throw new Error(`File not found: ${filePath}`);
93
104
  const content = readFileSync(fullPath, 'utf-8');
94
105
  // frontmatter의 type 변경
95
106
  const updated = content.replace(/^type:\s*.+$/m, `type: ${targetStage}`);
96
- // 대상 폴더로 이동
107
+ // 대상 폴더로 이동 (config-driven)
97
108
  const folderMap = {
98
- fleeting: 'raw',
99
- literature: '_literature',
100
- permanent: '_permanent',
109
+ fleeting: folders.fleeting,
110
+ literature: folders.literature,
111
+ permanent: folders.permanent,
101
112
  };
102
113
  const newDir = resolve(vaultPath, folderMap[targetStage]);
103
114
  if (!existsSync(newDir))
@@ -138,8 +149,14 @@ function extractAutoTags(content, type) {
138
149
  tags.add('web-clip');
139
150
  if (type === 'youtube')
140
151
  tags.add('youtube');
141
- if (type === 'pdf-text')
152
+ if (type === 'pdf-text' || type === 'pdf')
142
153
  tags.add('pdf');
154
+ if (type === 'docx')
155
+ tags.add('document');
156
+ if (type === 'pptx')
157
+ tags.add('presentation');
158
+ if (type === 'xlsx' || type === 'xls')
159
+ tags.add('spreadsheet');
143
160
  // 인라인 #태그 추출
144
161
  const inline = content.match(/#([a-zA-Z가-힣][a-zA-Z0-9가-힣_-]{2,})/g) ?? [];
145
162
  inline.forEach(t => tags.add(t.slice(1)));
@@ -18,6 +18,7 @@ import { createDetectGapsTool } from './tools/detect-gaps.js';
18
18
  import { createGetEvolutionTool } from './tools/get-evolution.js';
19
19
  import { createLinkCodeTool } from './tools/link-code.js';
20
20
  import { createAskTool } from './tools/ask.js';
21
+ import { createGenerateDraftTool } from './tools/generate-draft.js';
21
22
  import { createAgenticGraphTools } from './tools/agentic-graph.js';
22
23
  export function createMcpServer(options) {
23
24
  const { store, searchEngine, embedder, vaultPath = '', decayEngine } = options;
@@ -26,6 +27,7 @@ export function createMcpServer(options) {
26
27
  const getEvolutionTool = createGetEvolutionTool(store);
27
28
  const linkCodeTool = createLinkCodeTool(searchEngine);
28
29
  const askTool = createAskTool(searchEngine, vaultPath);
30
+ const generateDraftTool = createGenerateDraftTool(searchEngine, vaultPath);
29
31
  const agenticTools = embedder ? createAgenticGraphTools(store, embedder, vaultPath) : [];
30
32
  const server = new Server({ name: 'stellavault', version: '0.2.0' }, { capabilities: { tools: {} } });
31
33
  // List tools
@@ -40,6 +42,7 @@ export function createMcpServer(options) {
40
42
  { name: getEvolutionTool.name, description: getEvolutionTool.description, inputSchema: getEvolutionTool.inputSchema },
41
43
  { name: linkCodeTool.name, description: linkCodeTool.description, inputSchema: linkCodeTool.inputSchema },
42
44
  { name: askTool.name, description: askTool.description, inputSchema: askTool.inputSchema },
45
+ { name: generateDraftTool.name, description: generateDraftTool.description, inputSchema: generateDraftTool.inputSchema },
43
46
  ...agenticTools.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
44
47
  ],
45
48
  }));
@@ -120,6 +123,9 @@ export function createMcpServer(options) {
120
123
  case 'ask':
121
124
  result = await askTool.handler(args);
122
125
  return result;
126
+ case 'generate-draft':
127
+ result = await generateDraftTool.handler(args);
128
+ return result;
123
129
  default:
124
130
  {
125
131
  // Agentic graph tools (create-knowledge-node, create-knowledge-link)