stellavault 0.4.3 → 0.4.5

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 CHANGED
@@ -1,27 +1,27 @@
1
1
  # Stellavault
2
2
 
3
- > **Self-compiling knowledge MCP server** ingest anything, auto-organize into Zettelkasten wiki, and let Claude access your entire knowledge base.
3
+ > **Drop anything. It compiles itself into knowledge.** Claude remembers everything you know.
4
4
 
5
- Drop a PDF, paste a YouTube link, type a thought Stellavault compiles it into structured knowledge, connects the dots, and gives your AI agent full access through 21 MCP tools.
5
+ Self-compiling Zettelkasten MCP server. Ingest PDFs, YouTube, documentsauto-organized into linked wiki. Claude accesses your entire knowledge base. **Your vault files are never modified.**
6
6
 
7
7
  <p align="center">
8
- <img src="images/screenshots/graph-dark-full.png" alt="3D Knowledge Graph" width="800" />
9
- <br><em>Your vault as a neural network. Clusters form constellations.</em>
8
+ <img src="images/screenshots/graph-main-2.png" alt="3D Knowledge Graph" width="800" />
9
+ <br><em>Your vault as a neural network. Local-first, no cloud required.</em>
10
10
  </p>
11
11
 
12
12
  ## Two Core Ideas
13
13
 
14
- **1. "Drop it and forget it"** (Karpathy's Self-Compiling Knowledge)
14
+ **1. "Drop it and forget it"** (Inspired by Karpathy's Self-Compiling Knowledge)
15
15
  ```
16
16
  Any input → auto-classify → raw/ → compile → wiki → connected knowledge
17
17
  ```
18
- PDF, DOCX, YouTube, URL, text — everything goes through the same pipeline. You never manually organize.
18
+ PDF, DOCX, PPTX, XLSX, YouTube (with transcript), URL, text — everything goes through the same pipeline. You never manually organize.
19
19
 
20
- **2. "Claude knows what you know"** (MCP Integration)
20
+ **2. "Claude remembers what you know"** (MCP Integration)
21
21
  ```bash
22
22
  claude mcp add stellavault -- stellavault serve
23
23
  ```
24
- 21 MCP tools give Claude direct access to search, ask, draft, and navigate your entire knowledge base.
24
+ Claude searches, asks, drafts from your vault directly. Local-first no data leaves your machine.
25
25
 
26
26
  ## 5-Minute Setup
27
27
 
@@ -200,19 +200,28 @@ stellavault autopilot # Full cycle: inbox → compile
200
200
  | **Capture** | ingest (URL/YouTube/PDF/DOCX/PPTX/XLSX/text), fleeting, web drag & drop, mobile PWA |
201
201
  | **Organize** | Zettelkasten 3-stage, auto index codes, wikilink auto-connect, configurable folders |
202
202
  | **Distill** | compile (raw→wiki), lint (health score), gaps, contradictions, duplicates |
203
- | **Express** | draft (blog/report/outline), --ai (Claude API), MCP generate-draft (free) |
204
- | **Memory** | FSRS decay tracking, session-save (daily logs), flush (logs→wiki), compounding loop |
205
- | **Search** | hybrid (BM25+vector+RRF), multilingual (50+ langs), ask Q&A |
206
- | **Visualize** | 3D graph, constellation, heatmap, timeline, decay overlay, dark/light |
207
- | **AI Integration** | 21 MCP tools, Claude Code hooks, Anthropic SDK, generate-draft |
208
- | **CLI** | 39+ commands, `sv` alias |
203
+ | **Express** | draft (blog/report/outline/instagram/thread/script), blueprint, --ai, MCP generate-draft |
204
+ | **Memory** | FSRS decay, session-save, flush, compounding loop, ADR templates |
205
+ | **Search** | hybrid (BM25+vector+RRF), multilingual 50+, ask Q&A, quotes mode |
206
+ | **Visualize** | 3D graph, heatmap, timeline, right-click context menu, TipTap WYSIWYG editor |
207
+ | **AI Integration** | 21 MCP tools, Claude Code hooks, Anthropic SDK |
208
+ | **Security** | DOMPurify, YAML sanitize, 50MB guard, SSRF protection |
209
+ | **CLI** | 40+ commands, `sv` alias, batch ingest |
210
+
211
+ ## Security
212
+
213
+ Your vault files are never modified. Stellavault is local-first — no data leaves your machine unless you explicitly use `--ai` (Anthropic API).
214
+
215
+ See [SECURITY.md](SECURITY.md) for full details.
209
216
 
210
217
  ## License
211
218
 
212
- MIT
219
+ MIT — full source code available for audit.
213
220
 
214
221
  ## Links
215
222
 
223
+ - [Landing Page](https://evanciel.github.io/stellavault/)
216
224
  - [Obsidian Plugin](https://github.com/Evanciel/stellavault-obsidian)
217
225
  - [npm](https://www.npmjs.com/package/stellavault)
218
226
  - [GitHub Releases](https://github.com/Evanciel/stellavault/releases)
227
+ - [Security Policy](SECURITY.md)
package/SECURITY.md ADDED
@@ -0,0 +1,50 @@
1
+ # Security Policy
2
+
3
+ ## Data Access
4
+
5
+ Stellavault is **local-first**. Your knowledge stays on your machine.
6
+
7
+ ### What Stellavault reads
8
+ - `.md`, `.txt`, `.pdf`, `.docx`, `.pptx`, `.xlsx` files **inside your configured vault path only**
9
+ - Files are read to build a search index (SQLite-vec database stored in `~/.stellavault/`)
10
+ - **Vault original files are never modified by the indexer** — Stellavault creates its own files in `raw/`, `_wiki/`, `_drafts/` folders
11
+
12
+ ### When network requests occur
13
+ - **YouTube ingest**: fetches video metadata + captions from youtube.com (via yt-dlp)
14
+ - **URL ingest**: fetches the target URL to extract text
15
+ - **`stellavault draft --ai`**: sends vault excerpts to Anthropic API (requires explicit `ANTHROPIC_API_KEY` env var — opt-in only)
16
+ - **MCP serve**: local stdio/HTTP only — no external connections
17
+ - **Embedding model**: downloaded once from Hugging Face on first `stellavault index`, then cached locally
18
+
19
+ ### What never leaves your machine
20
+ - Your vault files
21
+ - Your search index database
22
+ - Your session logs and daily logs
23
+ - Your draft outputs
24
+ - All MCP tool responses
25
+
26
+ ## Vault Safety
27
+
28
+ - **Read-only default**: The search indexer reads files but does not modify them
29
+ - **New files only**: `ingest`, `session-save`, `compile`, `draft` create new `.md` files — they never overwrite existing vault notes
30
+ - **Edit is explicit**: The web UI edit feature and `PUT /api/document` require deliberate user action
31
+ - **Path traversal protection**: All file operations validate paths stay within vault root
32
+ - **Configurable folders**: `raw/`, `_wiki/`, `_literature/` names can be changed in `.stellavault.json`
33
+
34
+ ## Input Sanitization
35
+
36
+ - **DOMPurify**: All markdown rendered in the web UI is sanitized against XSS
37
+ - **YAML sanitization**: Frontmatter values are escaped to prevent injection
38
+ - **File size limit**: 50MB max for binary file extraction
39
+ - **URL validation**: Image URLs restricted to `https://` scheme
40
+ - **SSRF protection**: Private/local IP addresses blocked for URL ingest
41
+
42
+ ## Reporting Vulnerabilities
43
+
44
+ Please report security issues to: https://github.com/Evanciel/stellavault/issues (label: security)
45
+
46
+ Or email: [create a security@stellavault.dev when domain is registered]
47
+
48
+ ## License
49
+
50
+ MIT — full source code is available for audit at https://github.com/Evanciel/stellavault
package/index.html CHANGED
@@ -231,7 +231,7 @@ footer{padding:60px 0;text-align:center}
231
231
  <section class="hero">
232
232
  <div class="graph-visual" id="graphVisual"></div>
233
233
  <div class="hero-inner container">
234
- <div class="hero-badge">open source <span class="sep">&#9679;</span> local-first <span class="sep">&#9679;</span> MIT license</div>
234
+ <div class="hero-badge">local-first <span class="sep">&#9679;</span> vault files never modified <span class="sep">&#9679;</span> MIT</div>
235
235
  <h1>Drop anything.<br>It <em>compiles itself</em><br>into knowledge.</h1>
236
236
  <p class="hero-sub">Self-compiling Zettelkasten MCP server. Ingest PDFs, YouTube, documents&mdash;auto-organized into linked wiki. Claude accesses your entire knowledge base through 21 MCP tools.</p>
237
237
  <div class="hero-cta">
@@ -366,8 +366,8 @@ footer{padding:60px 0;text-align:center}
366
366
  <section class="s-mcp">
367
367
  <div class="container">
368
368
  <div class="section-label reveal">Claude Integration</div>
369
- <h2 class="section-title reveal">21 MCP tools. Claude knows<br>everything you know.</h2>
370
- <p class="section-desc reveal">One command connects your vault to Claude Code. No API key needed.</p>
369
+ <h2 class="section-title reveal">Claude remembers<br>everything you know.</h2>
370
+ <p class="section-desc reveal">One command connects your vault. Claude searches, asks, and drafts from your knowledge. Local-first, no data leaves your machine.</p>
371
371
  <p class="section-desc reveal">One command connects your vault to Claude Code. No API key needed.</p>
372
372
  <div class="mcp-connect reveal"><span class="prompt">$</span> <code>claude mcp add stellavault -- stellavault serve</code></div>
373
373
  <div class="mcp-grid reveal">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stellavault",
3
- "version": "0.4.3",
4
- "description": "Self-compiling knowledge MCP server ingest anything, auto-organize into Zettelkasten wiki, search with AI, and let Claude access your entire knowledge base.",
3
+ "version": "0.4.5",
4
+ "description": "Drop anything. It compiles itself into knowledge. Claude remembers everything you know. Local-first MCP server, vault files never modified.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/Evanciel/stellavault"
@@ -0,0 +1,7 @@
1
+ export declare function adrCommand(title: string, options: {
2
+ context?: string;
3
+ options?: string;
4
+ decision?: string;
5
+ consequences?: string;
6
+ }): Promise<void>;
7
+ //# sourceMappingURL=adr-cmd.d.ts.map
@@ -0,0 +1,45 @@
1
+ // stellavault adr — Architecture Decision Record 구조화 인제스트
2
+ // 의사결정 기록: 제목/맥락/옵션/결정/결과
3
+ import chalk from 'chalk';
4
+ import { loadConfig, ingest } from '@stellavault/core';
5
+ export async function adrCommand(title, options) {
6
+ if (!title) {
7
+ console.error(chalk.yellow('Usage: stellavault adr "Decision Title" --context "..." --options "..." --decision "..." --consequences "..."'));
8
+ process.exit(1);
9
+ }
10
+ const config = loadConfig();
11
+ const now = new Date().toISOString().split('T')[0];
12
+ const content = [
13
+ `# ADR: ${title}`,
14
+ '',
15
+ `**Date:** ${now}`,
16
+ `**Status:** Accepted`,
17
+ '',
18
+ '## Context',
19
+ options.context ?? '<!-- Why is this decision needed? -->',
20
+ '',
21
+ '## Options Considered',
22
+ options.options ?? '<!-- What alternatives were evaluated? -->',
23
+ '',
24
+ '## Decision',
25
+ options.decision ?? '<!-- What was decided and why? -->',
26
+ '',
27
+ '## Consequences',
28
+ options.consequences ?? '<!-- What are the implications? -->',
29
+ '',
30
+ ].join('\n');
31
+ const result = ingest(config.vaultPath, {
32
+ type: 'text',
33
+ content,
34
+ tags: ['adr', 'decision'],
35
+ title: `ADR: ${title}`,
36
+ stage: 'literature',
37
+ }, config.folders);
38
+ console.log(chalk.green(`ADR created: ${title}`));
39
+ console.log(chalk.dim(` Saved: ${result.savedTo}`));
40
+ console.log(chalk.dim(` Stage: literature`));
41
+ console.log(chalk.dim(` Tags: adr, decision`));
42
+ console.log('');
43
+ console.log(chalk.dim(`Find later: stellavault ask "why did we choose ${title}?"`));
44
+ }
45
+ //# sourceMappingURL=adr-cmd.js.map
@@ -1,4 +1,5 @@
1
1
  export declare function askCommand(question: string, options: {
2
2
  save?: boolean;
3
+ quotes?: boolean;
3
4
  }): Promise<void>;
4
5
  //# sourceMappingURL=ask-cmd.d.ts.map
@@ -17,6 +17,7 @@ export async function askCommand(question, options) {
17
17
  limit: 10,
18
18
  save: options.save ?? false,
19
19
  vaultPath: config.vaultPath,
20
+ mode: options.quotes ? 'quotes' : 'default',
20
21
  });
21
22
  // 출력
22
23
  console.log('');
@@ -1,5 +1,6 @@
1
1
  export declare function draftCommand(topic: string | undefined, options: {
2
2
  format?: string;
3
3
  ai?: boolean;
4
+ blueprint?: string;
4
5
  }): Promise<void>;
5
6
  //# sourceMappingURL=draft-cmd.d.ts.map
@@ -13,7 +13,15 @@ export async function draftCommand(topic, options) {
13
13
  const format = (options.format ?? 'blog');
14
14
  try {
15
15
  // Step 1: rule-based 초안 생성 (스캐폴딩 + 소스 수집)
16
- const result = generateDraft(config.vaultPath, { topic, format }, config.folders);
16
+ // Blueprint 파싱: "Chapter 1:tag1,tag2; Chapter 2:tag3" 형식
17
+ let blueprint;
18
+ if (options.blueprint) {
19
+ blueprint = options.blueprint.split(';').map(s => {
20
+ const [title, tagsStr] = s.split(':').map(p => p.trim());
21
+ return { title, tags: tagsStr?.split(',').map(t => t.trim()) };
22
+ });
23
+ }
24
+ const result = generateDraft(config.vaultPath, { topic, format, blueprint }, config.folders);
17
25
  if (options.ai) {
18
26
  // Step 2: --ai 모드 → Claude API로 실제 글 생성
19
27
  console.log(chalk.dim(' AI mode: generating with Claude...'));
@@ -17,21 +17,39 @@ export async function ingestCommand(input, options) {
17
17
  console.error(chalk.yellow(`No supported files found in ${input}`));
18
18
  process.exit(1);
19
19
  }
20
- console.log(chalk.dim(`Batch ingest: ${files.length} files from ${input}`));
20
+ console.log(chalk.dim(`Batch ingest: ${files.length} files from ${input}\n`));
21
21
  let success = 0;
22
- for (const file of files) {
22
+ const failed = [];
23
+ for (let i = 0; i < files.length; i++) {
24
+ const file = files[i];
25
+ const name = file.split(/[/\\]/).pop() ?? file;
26
+ const progress = `[${i + 1}/${files.length}]`;
27
+ process.stderr.write(`\r${chalk.dim(progress)} ${name}...`);
23
28
  try {
24
- await ingestCommand(file, { ...options, title: undefined });
29
+ await ingestSingleFile(file, options);
25
30
  success++;
26
31
  }
27
32
  catch (err) {
28
- console.error(chalk.yellow(` Failed: ${file} ${err instanceof Error ? err.message : 'error'}`));
33
+ failed.push(`${name}: ${err instanceof Error ? err.message : 'error'}`);
29
34
  }
30
35
  }
31
- console.log(chalk.green(`\nBatch complete: ${success}/${files.length} files ingested`));
36
+ process.stderr.write('\r' + ' '.repeat(80) + '\r');
37
+ console.log(chalk.green(`Batch complete: ${success}/${files.length} files ingested`));
38
+ if (failed.length > 0) {
39
+ console.log(chalk.yellow(`\nFailed (${failed.length}):`));
40
+ for (const f of failed)
41
+ console.log(chalk.yellow(` - ${f}`));
42
+ }
32
43
  return;
33
44
  }
34
- const config = loadConfig();
45
+ await ingestSingleFile(input, options);
46
+ }
47
+ /** 캐시된 config (배치 시 반복 로드 방지) */
48
+ let _configCache = null;
49
+ function getConfig() { return _configCache ?? (_configCache = loadConfig()); }
50
+ /** 단일 파일/URL/텍스트 인제스트 (배치에서 재사용) */
51
+ async function ingestSingleFile(input, options) {
52
+ const config = getConfig();
35
53
  const tags = options.tags?.split(',').map(t => t.trim()) ?? [];
36
54
  const stage = (options.stage ?? 'fleeting');
37
55
  // 입력 타입 감지
@@ -80,8 +98,8 @@ export async function ingestCommand(input, options) {
80
98
  // 일반 URL: HTML → 텍스트 변환
81
99
  let content = input + '\n';
82
100
  try {
83
- const resp = await fetch(input);
84
- const html = await resp.text();
101
+ const resp = await fetch(input, { signal: AbortSignal.timeout(15000) });
102
+ const html = (await resp.text()).slice(0, 500000); // 500KB max
85
103
  const text = html
86
104
  .replace(/<script[\s\S]*?<\/script>/gi, '')
87
105
  .replace(/<style[\s\S]*?<\/style>/gi, '')
@@ -26,6 +26,7 @@ import { compileCommand } from './commands/compile-cmd.js';
26
26
  import { draftCommand } from './commands/draft-cmd.js';
27
27
  import { sessionSaveCommand } from './commands/session-cmd.js';
28
28
  import { flushCommand } from './commands/flush-cmd.js';
29
+ import { adrCommand } from './commands/adr-cmd.js';
29
30
  import { lintCommand } from './commands/lint-cmd.js';
30
31
  import { fleetingCommand } from './commands/fleeting-cmd.js';
31
32
  import { ingestCommand, promoteCommand } from './commands/ingest-cmd.js';
@@ -134,6 +135,7 @@ program
134
135
  .command('ask <question>')
135
136
  .description('Ask a question about your knowledge base — search, compose answer, optionally save')
136
137
  .option('-s, --save', 'Save answer as a new note in your vault')
138
+ .option('-q, --quotes', 'Show direct quotes from sources (Insight Extraction mode)')
137
139
  .action((question, opts) => askCommand(question, opts));
138
140
  program
139
141
  .command('compile')
@@ -144,9 +146,10 @@ program
144
146
  .action((opts) => compileCommand(opts));
145
147
  program
146
148
  .command('draft [topic]')
147
- .description('Express: Generate a blog post, report, or outline draft from your knowledge')
148
- .option('--format <type>', 'Output format: blog, report, outline (default: blog)')
149
+ .description('Express: Generate draft from knowledge (blog/report/outline/instagram/thread/script)')
150
+ .option('--format <type>', 'Output format: blog, report, outline, instagram, thread, script')
149
151
  .option('--ai', 'Use Claude API for AI-enhanced draft (requires ANTHROPIC_API_KEY)')
152
+ .option('--blueprint <spec>', 'Chapter structure: "Ch1:tag1,tag2; Ch2:tag3"')
150
153
  .action((topic, opts) => draftCommand(topic, opts));
151
154
  program
152
155
  .command('session-save')
@@ -160,6 +163,14 @@ program
160
163
  .command('flush')
161
164
  .description('Flush daily logs → wiki: extract concepts, rebuild connections (Karpathy compile)')
162
165
  .action(() => flushCommand());
166
+ program
167
+ .command('adr <title>')
168
+ .description('Create an Architecture Decision Record (structured decision log)')
169
+ .option('--context <text>', 'Why is this decision needed?')
170
+ .option('--options <text>', 'What alternatives were considered?')
171
+ .option('--decision <text>', 'What was decided and why?')
172
+ .option('--consequences <text>', 'What are the implications?')
173
+ .action((title, opts) => adrCommand(title, opts));
163
174
  program
164
175
  .command('lint')
165
176
  .description('Knowledge health check — find gaps, duplicates, contradictions, stale notes')
@@ -19,5 +19,6 @@ export declare function askVault(searchEngine: SearchEngine, question: string, o
19
19
  save?: boolean;
20
20
  vaultPath?: string;
21
21
  outputDir?: string;
22
+ mode?: 'default' | 'quotes';
22
23
  }): Promise<AskResult>;
23
24
  //# sourceMappingURL=ask-engine.d.ts.map
@@ -7,7 +7,7 @@ import { join, resolve } from 'node:path';
7
7
  * LLM 없이 검색 결과를 구조화하는 버전 (LLM 연동은 MCP ask tool에서 처리).
8
8
  */
9
9
  export async function askVault(searchEngine, question, options = {}) {
10
- const { limit = 10, save = false, vaultPath, outputDir = '_stellavault/answers' } = options;
10
+ const { limit = 10, save = false, vaultPath, outputDir = '_stellavault/answers', mode = 'default' } = options;
11
11
  // 1. 검색
12
12
  const results = await searchEngine.search({ query: question, limit });
13
13
  // 2. 소스 정리
@@ -18,7 +18,9 @@ export async function askVault(searchEngine, question, options = {}) {
18
18
  snippet: r.chunk?.content?.substring(0, 200) ?? '',
19
19
  }));
20
20
  // 3. 답변 구성 (검색 결과 기반 구조화)
21
- const answer = composeAnswer(question, results);
21
+ const answer = mode === 'quotes'
22
+ ? composeQuotes(question, results)
23
+ : composeAnswer(question, results);
22
24
  // 4. vault에 저장 (선택)
23
25
  let savedTo = null;
24
26
  if (save && vaultPath) {
@@ -66,6 +68,38 @@ function composeAnswer(question, results) {
66
68
  lines.push(`- Find knowledge gaps: \`stellavault gaps\``);
67
69
  return lines.join('\n');
68
70
  }
71
+ /**
72
+ * Quotes 모드: 원문 인용을 카드 형태로 나열.
73
+ */
74
+ function composeQuotes(question, results) {
75
+ if (results.length === 0) {
76
+ return `No quotes found for "${question}".`;
77
+ }
78
+ const lines = [];
79
+ lines.push(`## Quotes: "${question}"\n`);
80
+ lines.push(`*${results.length} sources found. Each quote is a direct excerpt.*\n`);
81
+ for (let i = 0; i < Math.min(8, results.length); i++) {
82
+ const r = results[i];
83
+ const quote = r.chunk?.content
84
+ ?.replace(/^---[\s\S]*?---\n?/, '')
85
+ ?.replace(/^#+\s+.+\n/m, '')
86
+ ?.trim()
87
+ ?.substring(0, 300) ?? '';
88
+ if (!quote)
89
+ continue;
90
+ const score = Math.round(r.score * 100);
91
+ lines.push(`---`);
92
+ lines.push(`**[${i + 1}] ${r.document.title}** (${score}% match)`);
93
+ lines.push(`> ${quote.replace(/\n/g, '\n> ')}`);
94
+ if (r.document.tags.length > 0) {
95
+ lines.push(`Tags: ${r.document.tags.slice(0, 5).map(t => `#${t}`).join(' ')}`);
96
+ }
97
+ lines.push('');
98
+ }
99
+ lines.push('---');
100
+ lines.push(`*Use \`stellavault ask "${question}" --save\` to save these quotes as a note.*`);
101
+ return lines.join('\n');
102
+ }
69
103
  /**
70
104
  * 답변을 vault에 .md 파일로 저장.
71
105
  * "_stellavault/answers/YYYY-MM-DD-question.md" 형식.
@@ -95,10 +95,14 @@ export function insertWikilinks(body, vaultTitles, selfTitle) {
95
95
  // 긴 구문부터 매칭 (짧은 구문이 긴 구문의 부분 매칭되는 것 방지)
96
96
  const sortedPhrases = [...phraseToTitle.keys()].sort((a, b) => b.length - a.length);
97
97
  const linkedPhrases = new Set(); // 이미 링크된 구문 (중복 방지)
98
+ const contentLower = content.toLowerCase();
98
99
  for (const phrase of sortedPhrases) {
99
100
  const targetTitle = phraseToTitle.get(phrase);
100
101
  if (linkedPhrases.has(targetTitle))
101
- continue; // 같은 노트에 여러 구문 매칭 방지
102
+ continue;
103
+ // 사전 필터: 본문에 포함되지 않으면 regex 생성 스킵 (성능 최적화)
104
+ if (!contentLower.includes(phrase.toLowerCase()))
105
+ continue;
102
106
  const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
103
107
  const regex = new RegExp(`(?<!\\[\\[)(?<!#\\s)\\b(${escaped})\\b(?!\\]\\])(?![^\\[]*\\]\\])`, 'gi');
104
108
  let replaced = false;
@@ -1,8 +1,14 @@
1
1
  import { type FolderNames } from '../config.js';
2
+ export interface BlueprintSection {
3
+ title: string;
4
+ tags?: string[];
5
+ description?: string;
6
+ }
2
7
  export interface DraftOptions {
3
8
  topic?: string;
4
9
  format?: 'blog' | 'report' | 'outline' | 'instagram' | 'thread' | 'script';
5
10
  maxSections?: number;
11
+ blueprint?: BlueprintSection[];
6
12
  }
7
13
  export interface DraftResult {
8
14
  title: string;
@@ -10,7 +10,7 @@ import { DEFAULT_FOLDERS } from '../config.js';
10
10
  * Express 단계: 지식이 vault에서 나가는 출구.
11
11
  */
12
12
  export function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
13
- const { topic, format = 'blog', maxSections = 8 } = options;
13
+ const { topic, format = 'blog', maxSections = 8, blueprint } = options;
14
14
  // raw + wiki 문서 스캔
15
15
  const rawDir = resolve(vaultPath, folders.fleeting);
16
16
  const wikiDir = resolve(vaultPath, folders.wiki);
@@ -43,27 +43,54 @@ export function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS
43
43
  ? `Draft: ${topic}`
44
44
  : `Knowledge Draft — ${new Date().toISOString().split('T')[0]}`;
45
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 'instagram':
54
- body = generateInstagram(draftTitle, topConcepts, filteredDocs);
55
- break;
56
- case 'thread':
57
- body = generateThread(draftTitle, topConcepts, filteredDocs);
58
- break;
59
- case 'script':
60
- body = generateScript(draftTitle, topConcepts, filteredDocs);
61
- break;
62
- case 'blog':
63
- default:
64
- body = generateBlog(draftTitle, topConcepts, filteredDocs);
65
- break;
46
+ // Blueprint 모드: 사용자 정의 챕터 구성
47
+ if (blueprint && blueprint.length > 0) {
48
+ const bpLines = [`# ${draftTitle}\n`];
49
+ bpLines.push(`> Blueprint draft from ${filteredDocs.length} sources\n`);
50
+ for (const section of blueprint) {
51
+ bpLines.push(`## ${section.title}\n`);
52
+ if (section.description)
53
+ bpLines.push(`*${section.description}*\n`);
54
+ // 태그 필터로 관련 문서 찾기
55
+ const sectionDocs = section.tags
56
+ ? filteredDocs.filter(d => d.tags.some(t => section.tags.includes(t)) || d.title.toLowerCase().includes(section.title.toLowerCase()))
57
+ : filteredDocs;
58
+ const usedDocs = sectionDocs.slice(0, 5);
59
+ for (const doc of usedDocs) {
60
+ const excerpt = extractExcerpt(doc.content, 200);
61
+ if (excerpt)
62
+ bpLines.push(`> ${excerpt}\n> — *${doc.title}*\n`);
63
+ }
64
+ if (usedDocs.length > 0) {
65
+ bpLines.push(`*Sources: ${usedDocs.map(d => d.title).join(', ')}*\n`);
66
+ }
67
+ }
68
+ bpLines.push(`---\n*Generated by \`stellavault draft --blueprint\` at ${new Date().toISOString()}*`);
69
+ body = bpLines.join('\n');
66
70
  }
71
+ else {
72
+ switch (format) {
73
+ case 'outline':
74
+ body = generateOutline(draftTitle, topConcepts, filteredDocs);
75
+ break;
76
+ case 'report':
77
+ body = generateReport(draftTitle, topConcepts, filteredDocs);
78
+ break;
79
+ case 'instagram':
80
+ body = generateInstagram(draftTitle, topConcepts, filteredDocs);
81
+ break;
82
+ case 'thread':
83
+ body = generateThread(draftTitle, topConcepts, filteredDocs);
84
+ break;
85
+ case 'script':
86
+ body = generateScript(draftTitle, topConcepts, filteredDocs);
87
+ break;
88
+ case 'blog':
89
+ default:
90
+ body = generateBlog(draftTitle, topConcepts, filteredDocs);
91
+ break;
92
+ }
93
+ } // end else (non-blueprint)
67
94
  const wordCount = body.split(/\s+/).filter(Boolean).length;
68
95
  // _drafts/ 폴더에 저장
69
96
  const draftsDir = resolve(vaultPath, '_drafts');
@@ -9,10 +9,5 @@ export interface ExtractedContent {
9
9
  sourceFormat: 'pdf' | 'docx' | 'pptx' | 'xlsx' | 'xls' | 'text';
10
10
  }
11
11
  export declare function isBinaryFormat(filePath: string): boolean;
12
- /**
13
- * 파일 경로에서 텍스트 추출. 확장자 기반 파서 디스패치.
14
- * 지원: .pdf, .docx, .pptx, .xlsx, .xls
15
- * 미지원 확장자: utf-8 텍스트로 읽기
16
- */
17
12
  export declare function extractFileContent(filePath: string): Promise<ExtractedContent>;
18
13
  //# sourceMappingURL=file-extractors.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Design Ref: §file-ingest-v2 — Binary file text extraction dispatchers
2
2
  // Plan SC: SC1-SC5 (format-specific extraction + fallback)
3
- import { readFileSync } from 'node:fs';
3
+ import { readFileSync, statSync } from 'node:fs';
4
4
  import { extname, basename } from 'node:path';
5
5
  const BINARY_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.xls']);
6
6
  export function isBinaryFormat(filePath) {
@@ -11,8 +11,13 @@ export function isBinaryFormat(filePath) {
11
11
  * 지원: .pdf, .docx, .pptx, .xlsx, .xls
12
12
  * 미지원 확장자: utf-8 텍스트로 읽기
13
13
  */
14
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
14
15
  export async function extractFileContent(filePath) {
15
16
  const ext = extname(filePath).toLowerCase();
17
+ const { size } = statSync(filePath);
18
+ if (size > MAX_FILE_SIZE) {
19
+ throw new Error(`File too large (${Math.round(size / 1024 / 1024)}MB > 50MB limit)`);
20
+ }
16
21
  const buffer = readFileSync(filePath);
17
22
  switch (ext) {
18
23
  case '.pdf': return extractPdf(buffer, filePath);
@@ -86,12 +91,35 @@ async function extractXlsx(buffer, filePath) {
86
91
  try {
87
92
  const XLSX = await import('xlsx');
88
93
  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');
94
+ const parts = [];
95
+ for (const name of workbook.SheetNames) {
96
+ const sheet = workbook.Sheets[name];
97
+ // 마크다운 테이블 형식 (헤더-값 구조 보존)
98
+ const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
99
+ if (rows.length === 0)
100
+ continue;
101
+ parts.push(`## ${name}\n`);
102
+ const headers = rows[0].map((h) => String(h ?? ''));
103
+ parts.push(`| ${headers.join(' | ')} |`);
104
+ parts.push(`| ${headers.map(() => '---').join(' | ')} |`);
105
+ for (const row of rows.slice(1)) {
106
+ const cells = headers.map((_, i) => String(row[i] ?? ''));
107
+ parts.push(`| ${cells.join(' | ')} |`);
108
+ }
109
+ parts.push('');
110
+ // JSON 구조도 포함 (AI 검색/ask에서 수치 활용 가능)
111
+ if (rows.length <= 100) {
112
+ const jsonRows = XLSX.utils.sheet_to_json(sheet);
113
+ if (jsonRows.length > 0) {
114
+ parts.push(`<details><summary>Structured Data (${jsonRows.length} rows)</summary>\n`);
115
+ parts.push('```json');
116
+ parts.push(JSON.stringify(jsonRows.slice(0, 50), null, 2));
117
+ parts.push('```');
118
+ parts.push('</details>\n');
119
+ }
120
+ }
121
+ }
122
+ const text = parts.join('\n');
95
123
  return {
96
124
  text,
97
125
  metadata: {
@@ -65,7 +65,9 @@ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
65
65
  }]);
66
66
  indexCode = assignments.get(filePath);
67
67
  }
68
- catch { /* index code is optional */ }
68
+ catch (err) {
69
+ console.warn('[ingest] Index code skipped:', err instanceof Error ? err.message : err);
70
+ }
69
71
  // Stellavault 표준 포맷으로 저장
70
72
  let md = buildStandardNote({
71
73
  title,
@@ -81,7 +83,9 @@ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
81
83
  try {
82
84
  md = autoLink(md, vaultPath, title, folders);
83
85
  }
84
- catch { /* autoLink 실패해도 저장은 진행 */ }
86
+ catch (err) {
87
+ console.warn('[ingest] Auto-link skipped:', err instanceof Error ? err.message : err);
88
+ }
85
89
  writeFileSync(fullPath, md, 'utf-8');
86
90
  // 자동 compile: fleeting → wiki (rule-based, <100ms)
87
91
  try {
@@ -91,7 +95,9 @@ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
91
95
  compileWiki(rawDir, wikiDir);
92
96
  }
93
97
  }
94
- catch { /* compile 실패해도 ingest 성공 */ }
98
+ catch (err) {
99
+ console.warn('[ingest] Auto-compile skipped:', err instanceof Error ? err.message : err);
100
+ }
95
101
  return {
96
102
  savedTo: filePath,
97
103
  stage: autoStage,
@@ -221,10 +227,10 @@ function buildStandardNote(params) {
221
227
  '---',
222
228
  `title: "${sanitizeYaml(params.title)}"`,
223
229
  `type: ${params.stage}`,
224
- `source: ${params.source}`,
230
+ `source: "${sanitizeYaml(params.source)}"`,
225
231
  `input_type: ${params.inputType}`,
226
232
  params.indexCode ? `zettel_id: "${params.indexCode}"` : null,
227
- `tags: [${params.tags.map(t => `"${t}"`).join(', ')}]`,
233
+ `tags: [${params.tags.map(t => `"${sanitizeYaml(t)}"`).join(', ')}]`,
228
234
  `created: ${params.created}`,
229
235
  `summary: "${sanitizeYaml(params.body.slice(0, 100))}"`,
230
236
  '---',