stellavault 0.4.2 → 0.4.4

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/CLAUDE.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # Stellavault — Project Rules
2
2
 
3
3
  ## Project Info
4
- - **Name**: Stellavault (분산 지식 인텔리전스 플랫폼)
4
+ - **Name**: Stellavault Self-compiling knowledge MCP server
5
+ - **Version**: 0.4.2
5
6
  - **GitHub**: https://github.com/Evanciel/stellavault
7
+ - **npm**: stellavault (171KB)
6
8
  - **Stack**: Node.js 20+, TypeScript, ESM, Monorepo (npm workspaces)
7
- - **Packages**: core, cli, graph, sync
9
+ - **Packages**: core (21 MCP tools), cli (39+ commands), graph (3D UI), sync
10
+ - **Architecture**: Karpathy self-compiling KB (ingest→compile→wiki→session→flush loop)
8
11
 
9
12
  ## Autopilot 추가 규칙
10
13
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Self-compiling knowledge MCP server** — ingest anything, auto-organize into Zettelkasten wiki, and let Claude access your entire knowledge base.
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 20 MCP tools.
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.
6
6
 
7
7
  <p align="center">
8
8
  <img src="images/screenshots/graph-dark-full.png" alt="3D Knowledge Graph" width="800" />
@@ -21,7 +21,7 @@ PDF, DOCX, YouTube, URL, text — everything goes through the same pipeline. You
21
21
  ```bash
22
22
  claude mcp add stellavault -- stellavault serve
23
23
  ```
24
- 20 MCP tools give Claude direct access to search, ask, draft, and navigate your entire knowledge base.
24
+ 21 MCP tools give Claude direct access to search, ask, draft, and navigate your entire knowledge base.
25
25
 
26
26
  ## 5-Minute Setup
27
27
 
@@ -66,6 +66,27 @@ stellavault draft --format outline # All-knowledge outline
66
66
 
67
67
  Or in Claude Code: *"Write a blog post about machine learning from my notes"* — Claude uses MCP `generate-draft` tool (free, no API key).
68
68
 
69
+ ## Self-Evolving Memory (Karpathy's Compounding Loop)
70
+
71
+ ```
72
+ Session → session-save → daily-log → flush → wiki
73
+ ↑ ↓
74
+ └──── Claude reads wiki via MCP (20 tools) ←─┘
75
+ ```
76
+
77
+ Every conversation makes your knowledge base smarter:
78
+
79
+ ```bash
80
+ # Auto-capture session summary to daily log
81
+ echo "Decided to use JWT. Lesson: never store tokens in localStorage" | stellavault session-save
82
+
83
+ # Flush daily logs → extract concepts → rebuild wiki
84
+ stellavault flush
85
+
86
+ # Or set up Claude Code hooks for full automation
87
+ # See: docs/hooks-setup.md
88
+ ```
89
+
69
90
  ## Daily Commands
70
91
 
71
92
  ```bash
@@ -74,10 +95,11 @@ stellavault brief # Morning knowledge briefing
74
95
  stellavault decay # What's fading from memory?
75
96
  stellavault lint # Health score (0-100)
76
97
  stellavault learn # AI learning path
98
+ stellavault flush # Daily logs → wiki compilation
77
99
  stellavault digest --visual # Weekly Mermaid chart report
78
100
  ```
79
101
 
80
- ## MCP Tools (20)
102
+ ## MCP Tools (21)
81
103
 
82
104
  | Tool | What it does |
83
105
  |------|-------------|
@@ -99,6 +121,16 @@ stellavault digest --visual # Weekly Mermaid chart report
99
121
  | `create-snapshot` / `load-snapshot` | Context snapshots |
100
122
  | `generate-claude-md` | Auto-generate CLAUDE.md |
101
123
  | `export` | JSON/CSV export |
124
+ | `federated-search` | P2P federated search |
125
+
126
+ ## Self-Evolving Commands
127
+
128
+ ```bash
129
+ stellavault session-save # Capture session summary to daily log
130
+ stellavault flush # Daily logs → wiki (Karpathy compile)
131
+ stellavault promote note.md --to lit # Upgrade note stage
132
+ stellavault autopilot # Full cycle: inbox → compile → lint → archive
133
+ ```
102
134
 
103
135
  ## Zettelkasten (Luhmann + Karpathy)
104
136
 
@@ -161,6 +193,20 @@ stellavault autopilot # Full cycle: inbox → compile
161
193
  | 3D | React Three Fiber + Three.js |
162
194
  | AI | MCP (Model Context Protocol) + Anthropic SDK |
163
195
 
196
+ ## Full Feature List
197
+
198
+ | Category | Features |
199
+ |----------|----------|
200
+ | **Capture** | ingest (URL/YouTube/PDF/DOCX/PPTX/XLSX/text), fleeting, web drag & drop, mobile PWA |
201
+ | **Organize** | Zettelkasten 3-stage, auto index codes, wikilink auto-connect, configurable folders |
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 |
209
+
164
210
  ## License
165
211
 
166
212
  MIT
package/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Stellavault — Self-Compiling Knowledge MCP Server</title>
7
- <meta name="description" content="Drop anything. It organizes itself. Claude knows everything you know. Self-compiling Zettelkasten with 20 MCP tools.">
7
+ <meta name="description" content="Drop anything. It organizes itself. Claude knows everything you know. Self-compiling Zettelkasten with 21 MCP tools.">
8
8
  <meta name="theme-color" content="#6366f1">
9
9
  <meta property="og:title" content="Stellavault — Self-Compiling Knowledge MCP Server">
10
10
  <meta property="og:description" content="Drop anything. It organizes itself. Claude knows everything you know.">
@@ -233,7 +233,7 @@ footer{padding:60px 0;text-align:center}
233
233
  <div class="hero-inner container">
234
234
  <div class="hero-badge">open source <span class="sep">&#9679;</span> local-first <span class="sep">&#9679;</span> MIT license</div>
235
235
  <h1>Drop anything.<br>It <em>compiles itself</em><br>into knowledge.</h1>
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 20 MCP tools.</p>
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">
238
238
  <button class="cta-install" id="ctaInstall">
239
239
  <span class="prompt">$</span> npm install -g stellavault
@@ -243,7 +243,7 @@ footer{padding:60px 0;text-align:center}
243
243
  <a href="https://github.com/Evanciel/stellavault" class="cta-mobile">View on GitHub &rarr;</a>
244
244
  </div>
245
245
  <div class="hero-proof">
246
- <span><span class="proof-num">157KB</span> on npm</span>
246
+ <span><span class="proof-num">171KB</span> on npm</span>
247
247
  <span class="proof-sep">&#9679;</span>
248
248
  <span><span class="proof-num">20</span> MCP tools</span>
249
249
  <span class="proof-sep">&#9679;</span>
@@ -285,7 +285,7 @@ footer{padding:60px 0;text-align:center}
285
285
  <div class="problem-title">AI doesn't know what you know</div>
286
286
  <div class="problem-desc">ChatGPT has the internet. It doesn't have your notes, your decisions, your context.</div>
287
287
  <span class="problem-arrow">&darr;</span>
288
- <div class="problem-solution">20 MCP tools &mdash; Claude searches, asks, drafts from your vault</div>
288
+ <div class="problem-solution">21 MCP tools &mdash; Claude searches, asks, drafts from your vault</div>
289
289
  </div>
290
290
  </div>
291
291
  </div>
@@ -308,6 +308,27 @@ footer{padding:60px 0;text-align:center}
308
308
  </div>
309
309
  </section>
310
310
 
311
+ <!-- ═══ COMPOUNDING LOOP ═══ -->
312
+ <section style="padding:80px 0">
313
+ <div class="container" style="text-align:center">
314
+ <div class="section-label reveal">The Compounding Loop</div>
315
+ <h2 class="section-title reveal">Gets smarter every session.</h2>
316
+ <p class="section-desc reveal" style="margin:0 auto 40px">Every conversation with Claude becomes knowledge. Knowledge improves the next conversation. Karpathy's self-evolving architecture.</p>
317
+ <div class="reveal" style="max-width:700px;margin:0 auto;padding:32px;background:var(--surface);border:1px solid var(--border);border-radius:14px;font-family:var(--font-mono);font-size:13px;line-height:2.2;color:var(--text-dim);text-align:left">
318
+ <span style="color:var(--cyan)">Session</span> &rarr; <code style="color:var(--violet-bright)">session-save</code> &rarr; <span style="color:var(--text)">daily-log</span> &rarr; <code style="color:var(--violet-bright)">flush</code> &rarr; <span style="color:var(--emerald)">wiki</span><br>
319
+ <span style="color:var(--text-muted);margin-left:16px">&uarr;</span><span style="padding-left:280px;color:var(--text-muted)">&darr;</span><br>
320
+ <span style="color:var(--text-muted);margin-left:16px">&larr;&larr; Claude reads wiki via MCP (20 tools) &larr;&larr;&larr;</span>
321
+ </div>
322
+ <div style="display:flex;gap:16px;justify-content:center;margin-top:28px;flex-wrap:wrap" class="reveal">
323
+ <div style="padding:14px 20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px"><span style="color:var(--violet-bright);font-family:var(--font-mono)">session-save</span><br><span style="color:var(--text-dim)">Auto-capture decisions &amp; lessons</span></div>
324
+ <div style="padding:14px 20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px"><span style="color:var(--violet-bright);font-family:var(--font-mono)">flush</span><br><span style="color:var(--text-dim)">Daily logs &rarr; wiki compilation</span></div>
325
+ <div style="padding:14px 20px;background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:12px"><span style="color:var(--violet-bright);font-family:var(--font-mono)">Claude Code hooks</span><br><span style="color:var(--text-dim)">Fully automatic capture</span></div>
326
+ </div>
327
+ </div>
328
+ </section>
329
+
330
+ <div class="divider"></div>
331
+
311
332
  <!-- ═══ SCREENSHOTS ═══ -->
312
333
  <section class="s-screenshots">
313
334
  <div class="container">
@@ -345,7 +366,8 @@ footer{padding:60px 0;text-align:center}
345
366
  <section class="s-mcp">
346
367
  <div class="container">
347
368
  <div class="section-label reveal">Claude Integration</div>
348
- <h2 class="section-title reveal">20 MCP tools. Claude knows<br>everything you know.</h2>
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>
349
371
  <p class="section-desc reveal">One command connects your vault to Claude Code. No API key needed.</p>
350
372
  <div class="mcp-connect reveal"><span class="prompt">$</span> <code>claude mcp add stellavault -- stellavault serve</code></div>
351
373
  <div class="mcp-grid reveal">
@@ -365,6 +387,10 @@ footer{padding:60px 0;text-align:center}
365
387
  <div class="mcp-tool"><span class="dot"></span><code>get-morning-brief</code><span class="desc">daily briefing</span></div>
366
388
  <div class="mcp-tool"><span class="dot"></span><code>list-topics</code><span class="desc">topic cloud</span></div>
367
389
  <div class="mcp-tool"><span class="dot"></span><code>export</code><span class="desc">JSON / CSV</span></div>
390
+ <div class="mcp-tool"><span class="dot"></span><code>find-decisions</code><span class="desc">decision lookup</span></div>
391
+ <div class="mcp-tool"><span class="dot"></span><code>create-snapshot</code><span class="desc">context save/load</span></div>
392
+ <div class="mcp-tool"><span class="dot"></span><code>generate-claude-md</code><span class="desc">auto CLAUDE.md</span></div>
393
+ <div class="mcp-tool"><span class="dot"></span><code>federated-search</code><span class="desc">P2P search</span></div>
368
394
  </div>
369
395
  </div>
370
396
  </section>
@@ -415,9 +441,9 @@ footer{padding:60px 0;text-align:center}
415
441
  <div class="tech-item"><span class="label">Search</span><span class="value">BM25 + Cosine + RRF</span></div>
416
442
  <div class="tech-item"><span class="label">Memory</span><span class="value">FSRS repetition</span></div>
417
443
  <div class="tech-item"><span class="label">3D Graph</span><span class="value">React Three Fiber</span></div>
418
- <div class="tech-item"><span class="label">AI Protocol</span><span class="value">MCP (20 tools)</span></div>
444
+ <div class="tech-item"><span class="label">AI Protocol</span><span class="value">MCP (21 tools)</span></div>
419
445
  <div class="tech-item"><span class="label">Runtime</span><span class="value">Node.js 20+ ESM</span></div>
420
- <div class="tech-item"><span class="label">Package</span><span class="value">157KB on npm</span></div>
446
+ <div class="tech-item"><span class="label">Package</span><span class="value">171KB on npm</span></div>
421
447
  </div>
422
448
  </div>
423
449
  </section>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stellavault",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
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.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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...'));
@@ -1,13 +1,47 @@
1
1
  // stellavault ingest — 통합 인제스트 (URL, 텍스트, 파일 → 자동 분류 저장)
2
2
  import chalk from 'chalk';
3
3
  import { loadConfig, ingest, promoteNote } from '@stellavault/core';
4
- import { readFileSync, existsSync } from 'node:fs';
5
- import { extname, resolve } from 'node:path';
4
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
5
+ import { extname, resolve, join } from 'node:path';
6
6
  export async function ingestCommand(input, options) {
7
7
  if (!input) {
8
- console.error(chalk.yellow('Usage: stellavault ingest <url|file|text> [--tags t1,t2] [--stage fleeting|literature|permanent]'));
8
+ console.error(chalk.yellow('Usage: stellavault ingest <url|file|text|folder/> [--tags t1,t2]'));
9
9
  process.exit(1);
10
10
  }
11
+ // 배치 모드: 폴더 경로이면 내부 파일 전부 처리
12
+ if (existsSync(input) && statSync(input).isDirectory()) {
13
+ const files = readdirSync(input)
14
+ .filter(f => /\.(md|txt|pdf|docx|pptx|xlsx|xls|csv)$/i.test(f))
15
+ .map(f => join(input, f));
16
+ if (files.length === 0) {
17
+ console.error(chalk.yellow(`No supported files found in ${input}`));
18
+ process.exit(1);
19
+ }
20
+ console.log(chalk.dim(`Batch ingest: ${files.length} files from ${input}\n`));
21
+ let success = 0;
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}...`);
28
+ try {
29
+ await ingestCommand(file, { ...options, title: undefined });
30
+ success++;
31
+ }
32
+ catch (err) {
33
+ failed.push(`${name}: ${err instanceof Error ? err.message : 'error'}`);
34
+ }
35
+ }
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
+ }
43
+ return;
44
+ }
11
45
  const config = loadConfig();
12
46
  const tags = options.tags?.split(',').map(t => t.trim()) ?? [];
13
47
  const stage = (options.stage ?? 'fleeting');
@@ -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')
@@ -130,7 +130,7 @@ export async function buildGraphData(store, options = {}) {
130
130
  const size = 1 + 6 * Math.pow(ratio, 0.5);
131
131
  return {
132
132
  id: doc.id,
133
- label: doc.title.slice(0, 40),
133
+ label: doc.title.replace(/&#39;/g, "'").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s*-\s*YouTube$/, '').slice(0, 40),
134
134
  filePath: doc.filePath,
135
135
  tags: doc.tags,
136
136
  clusterId: assignmentMap.get(doc.id) ?? 0,
@@ -41,22 +41,32 @@ export function createApiServer(options) {
41
41
  res.status(500).json({ error: 'Internal server error' });
42
42
  }
43
43
  });
44
+ // GET /api/reindex/status — 인덱싱 진행률 조회
45
+ let reindexProgress = { active: false, current: 0, total: 0, phase: '' };
46
+ app.get('/api/reindex/status', (_req, res) => {
47
+ res.json(reindexProgress);
48
+ });
44
49
  // POST /api/reindex — 웹에서 인덱싱 트리거
45
50
  let isReindexing = false;
46
51
  app.post('/api/reindex', async (_req, res) => {
47
52
  if (isReindexing) {
48
- res.json({ success: false, error: '인덱싱이 이미 진행 중입니다' });
53
+ res.json({ success: false, error: 'Reindexing already in progress', progress: reindexProgress });
49
54
  return;
50
55
  }
51
56
  isReindexing = true;
57
+ reindexProgress = { active: true, current: 0, total: 0, phase: 'initializing' };
52
58
  try {
53
59
  const indexer = await import('../indexer/index.js');
60
+ reindexProgress.phase = 'loading embedder';
54
61
  const embedder = indexer.createLocalEmbedder('all-MiniLM-L6-v2');
55
62
  await embedder.initialize();
63
+ reindexProgress.phase = 'indexing';
56
64
  const result = await indexer.indexVault(vaultPath, {
57
65
  store,
58
66
  embedder,
59
67
  onProgress: (current, total) => {
68
+ reindexProgress.current = current;
69
+ reindexProgress.total = total;
60
70
  if (current % 50 === 0)
61
71
  console.error(`[reindex] ${current}/${total}`);
62
72
  },
@@ -76,6 +86,7 @@ export function createApiServer(options) {
76
86
  }
77
87
  finally {
78
88
  isReindexing = false;
89
+ reindexProgress = { active: false, current: 0, total: 0, phase: 'done' };
79
90
  }
80
91
  });
81
92
  // GET /api/search?q=&limit=
@@ -46,9 +46,20 @@ function parseDocument(vaultPath, filePath) {
46
46
  ?? extractFirstHeading(content)
47
47
  ?? relativePath.replace(/\.md$/, '');
48
48
  const tags = extractTags(frontmatter, content);
49
+ // Obsidian frontmatter 호환: aliases를 tags에 추가
50
+ if (frontmatter.aliases) {
51
+ const aliases = Array.isArray(frontmatter.aliases) ? frontmatter.aliases : [frontmatter.aliases];
52
+ for (const alias of aliases) {
53
+ if (typeof alias === 'string' && alias.length > 1)
54
+ tags.push(alias);
55
+ }
56
+ }
49
57
  // source/type 자동 추출 (원본 파일 수정 없이 DB에만 저장)
50
58
  const source = inferSource(frontmatter, relativePath);
51
59
  const type = inferType(frontmatter, relativePath);
60
+ // Obsidian frontmatter 호환: date/created 우선 사용
61
+ const fmDate = frontmatter.date ?? frontmatter.created ?? frontmatter.created_at;
62
+ const lastModified = fmDate ? new Date(fmDate).toISOString() : stat.mtime.toISOString();
52
63
  return {
53
64
  id,
54
65
  filePath: relativePath,
@@ -56,7 +67,7 @@ function parseDocument(vaultPath, filePath) {
56
67
  content,
57
68
  frontmatter,
58
69
  tags,
59
- lastModified: stat.mtime.toISOString(),
70
+ lastModified,
60
71
  contentHash,
61
72
  source,
62
73
  type,
@@ -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
- format?: 'blog' | 'report' | 'outline';
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,18 +43,53 @@ 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 'blog':
54
- default:
55
- body = generateBlog(draftTitle, topConcepts, filteredDocs);
56
- 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');
57
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
+ }
58
93
  const wordCount = body.split(/\s+/).filter(Boolean).length;
59
94
  // _drafts/ 폴더에 저장
60
95
  const draftsDir = resolve(vaultPath, '_drafts');
@@ -97,11 +132,23 @@ function generateBlog(title, concepts, docs) {
97
132
  }
98
133
  lines.push('<!-- TODO: Add your analysis and insights here -->', '');
99
134
  }
100
- // 참고 자료
135
+ // 참고 자료 (APA-style citations)
101
136
  lines.push('## References', '');
102
137
  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}]]`);
138
+ const now = new Date();
139
+ for (let i = 0; i < uniqueDocs.length; i++) {
140
+ const doc = uniqueDocs[i];
141
+ const dateMatch = doc.content.match(/(?:created|date):\s*(\d{4}-\d{2}-\d{2})/);
142
+ const year = dateMatch ? dateMatch[1].split('-')[0] : now.getFullYear().toString();
143
+ const sourceMatch = doc.content.match(/source:\s*(.+)/);
144
+ const source = sourceMatch ? sourceMatch[1].trim() : '';
145
+ // APA-style: Author/Title (Year). Source.
146
+ if (source.startsWith('http')) {
147
+ lines.push(`[${i + 1}] ${doc.title} (${year}). Retrieved from ${source}`);
148
+ }
149
+ else {
150
+ lines.push(`[${i + 1}] ${doc.title} (${year}). [[${basename(doc.filePath, extname(doc.filePath))}|Link]]`);
151
+ }
105
152
  }
106
153
  lines.push('');
107
154
  lines.push(`---`, `*Generated by \`stellavault draft\` at ${new Date().toISOString()}*`);
@@ -146,6 +193,75 @@ function generateOutline(title, concepts, docs) {
146
193
  lines.push('', `---`, `*Generated by \`stellavault draft\` at ${new Date().toISOString()}*`);
147
194
  return lines.join('\n');
148
195
  }
196
+ // ─── SNS 포맷 ───
197
+ function generateInstagram(title, concepts, docs) {
198
+ const lines = [];
199
+ const topicWords = concepts.slice(0, 3).map(([c]) => c);
200
+ lines.push(`${title}`, '');
201
+ lines.push('---', '');
202
+ // 슬라이드 1: Hook
203
+ lines.push('**Slide 1 (Hook)**', '');
204
+ lines.push(`Did you know about ${topicWords.join(', ')}?`, '');
205
+ // 슬라이드 2-4: Key Points
206
+ for (let i = 0; i < Math.min(3, concepts.length); i++) {
207
+ const [concept] = concepts[i];
208
+ const doc = docs.find(d => d.tags.includes(concept) || d.title.toLowerCase().includes(concept.toLowerCase()));
209
+ const excerpt = doc ? extractExcerpt(doc.content, 80) : '';
210
+ lines.push(`**Slide ${i + 2} (${capitalize(concept)})**`, '');
211
+ lines.push(excerpt || `Key insight about ${concept}`, '');
212
+ }
213
+ // CTA
214
+ lines.push('**Last Slide (CTA)**', '');
215
+ lines.push('Save this for later! Follow for more knowledge drops.', '');
216
+ // Caption
217
+ lines.push('---', '', '**Caption:**', '');
218
+ lines.push(`${topicWords.map(w => `#${w}`).join(' ')} #knowledge #secondbrain #stellavault`, '');
219
+ lines.push(`---`, `*Generated by \`stellavault draft --format instagram\`*`);
220
+ return lines.join('\n');
221
+ }
222
+ function generateThread(title, concepts, docs) {
223
+ const lines = [];
224
+ // Tweet 1: Hook
225
+ lines.push(`**1/** ${title}`, '');
226
+ lines.push(`Here's what I learned from ${docs.length} sources:`, '');
227
+ // Tweet 2-N: One per concept
228
+ for (let i = 0; i < Math.min(6, concepts.length); i++) {
229
+ const [concept, docPaths] = concepts[i];
230
+ const doc = docs.find(d => docPaths.includes(d.filePath));
231
+ const excerpt = doc ? extractExcerpt(doc.content, 200) : '';
232
+ lines.push(`**${i + 2}/** ${capitalize(concept)}`, '');
233
+ lines.push(excerpt || `Key point about ${concept}`, '');
234
+ }
235
+ // Final tweet
236
+ lines.push(`**${Math.min(6, concepts.length) + 2}/** That's a wrap!`, '');
237
+ lines.push('If this was useful, repost the first tweet.', '');
238
+ lines.push(`---`, `*Generated by \`stellavault draft --format thread\`*`);
239
+ return lines.join('\n');
240
+ }
241
+ function generateScript(title, concepts, docs) {
242
+ const lines = [];
243
+ lines.push(`# ${title} — Video Script`, '');
244
+ lines.push(`**Duration:** ~${Math.max(3, concepts.length * 2)} minutes`, '');
245
+ // Intro
246
+ lines.push('## Intro (30s)', '');
247
+ lines.push(`"Hey everyone, today we're diving into ${concepts.slice(0, 2).map(([c]) => c).join(' and ')}."`, '');
248
+ // Sections
249
+ for (let i = 0; i < Math.min(5, concepts.length); i++) {
250
+ const [concept, docPaths] = concepts[i];
251
+ const relatedDocs = docs.filter(d => docPaths.includes(d.filePath)).slice(0, 2);
252
+ lines.push(`## Section ${i + 1}: ${capitalize(concept)} (~${Math.max(1, Math.floor(relatedDocs.length))}min)`, '');
253
+ for (const doc of relatedDocs) {
254
+ const excerpt = extractExcerpt(doc.content, 150);
255
+ if (excerpt)
256
+ lines.push(`[Visual: ${doc.title}]`, excerpt, '');
257
+ }
258
+ }
259
+ // Outro
260
+ lines.push('## Outro (15s)', '');
261
+ lines.push('"If you found this helpful, like and subscribe!"', '');
262
+ lines.push(`---`, `*Generated by \`stellavault draft --format script\`*`);
263
+ return lines.join('\n');
264
+ }
149
265
  // ─── 유틸 ───
150
266
  function extractExcerpt(content, maxLen) {
151
267
  // frontmatter 제거
@@ -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: {
@@ -9,16 +9,24 @@ import { scanFrontmatter, assignIndexCodes, archiveFile } from './zettelkasten.j
9
9
  import { compileWiki } from './wiki-compiler.js';
10
10
  import { autoLink } from './auto-linker.js';
11
11
  import { DEFAULT_FOLDERS } from '../config.js';
12
+ /** HTML 엔티티 디코딩 */
13
+ function decodeHtmlEntities(text) {
14
+ return text
15
+ .replace(/&#39;/g, "'").replace(/&#x27;/g, "'")
16
+ .replace(/&quot;/g, '"').replace(/&amp;/g, '&')
17
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
18
+ .replace(/&nbsp;/g, ' ');
19
+ }
12
20
  /** YAML 값에서 위험한 문자를 이스케이프 */
13
21
  function sanitizeYaml(val) {
14
- return val.replace(/["\\]/g, '\\$&').replace(/\n/g, ' ').slice(0, 200);
22
+ return decodeHtmlEntities(val).replace(/["\\]/g, '\\$&').replace(/\n/g, ' ').slice(0, 200);
15
23
  }
16
24
  /**
17
25
  * 어떤 입력이든 Stellavault 표준 포맷으로 변환하여 저장.
18
26
  */
19
27
  export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
20
28
  const stage = input.stage ?? 'fleeting';
21
- const title = input.title ?? extractTitleFromContent(input.content, input.type);
29
+ const title = decodeHtmlEntities(input.title ?? extractTitleFromContent(input.content, input.type));
22
30
  const tags = input.tags ?? extractAutoTags(input.content, input.type);
23
31
  const source = input.source ?? (input.type === 'url' || input.type === 'youtube' ? input.content.split('\n')[0] : 'manual');
24
32
  // 본문 정리
@@ -57,7 +65,9 @@ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
57
65
  }]);
58
66
  indexCode = assignments.get(filePath);
59
67
  }
60
- catch { /* index code is optional */ }
68
+ catch (err) {
69
+ console.warn('[ingest] Index code skipped:', err instanceof Error ? err.message : err);
70
+ }
61
71
  // Stellavault 표준 포맷으로 저장
62
72
  let md = buildStandardNote({
63
73
  title,
@@ -73,7 +83,9 @@ export function ingest(vaultPath, input, folders = DEFAULT_FOLDERS) {
73
83
  try {
74
84
  md = autoLink(md, vaultPath, title, folders);
75
85
  }
76
- catch { /* autoLink 실패해도 저장은 진행 */ }
86
+ catch (err) {
87
+ console.warn('[ingest] Auto-link skipped:', err instanceof Error ? err.message : err);
88
+ }
77
89
  writeFileSync(fullPath, md, 'utf-8');
78
90
  // 자동 compile: fleeting → wiki (rule-based, <100ms)
79
91
  try {
@@ -166,7 +178,25 @@ function extractAutoTags(content, type) {
166
178
  // 인라인 #태그 추출
167
179
  const inline = content.match(/#([a-zA-Z가-힣][a-zA-Z0-9가-힣_-]{2,})/g) ?? [];
168
180
  inline.forEach(t => tags.add(t.slice(1)));
169
- return [...tags].slice(0, 10);
181
+ // 스마트 자동 태깅: 문서 내용 분석 → 카테고리 분류
182
+ const lc = content.toLowerCase();
183
+ if (/회의|meeting|minutes|참석자|agenda/.test(lc))
184
+ tags.add('meeting-notes');
185
+ if (/기획|prd|요구사항|spec|feature|유저\s*스토리/.test(lc))
186
+ tags.add('planning');
187
+ if (/api|endpoint|서버|backend|database|쿼리/.test(lc))
188
+ tags.add('technical');
189
+ if (/디자인|design|ui|ux|figma|wireframe|mockup/.test(lc))
190
+ tags.add('design');
191
+ if (/논문|paper|abstract|methodology|conclusion|참고문헌/.test(lc))
192
+ tags.add('research');
193
+ if (/tutorial|강의|강좌|배우|learn|course/.test(lc))
194
+ tags.add('learning');
195
+ if (/경쟁|competitor|시장|market|swot|분석/.test(lc))
196
+ tags.add('analysis');
197
+ if (/일기|diary|journal|오늘|today|daily/.test(lc))
198
+ tags.add('journal');
199
+ return [...tags].slice(0, 15);
170
200
  }
171
201
  function cleanContent(content, type) {
172
202
  if (type === 'url' || type === 'youtube') {
@@ -195,10 +225,10 @@ function buildStandardNote(params) {
195
225
  '---',
196
226
  `title: "${sanitizeYaml(params.title)}"`,
197
227
  `type: ${params.stage}`,
198
- `source: ${params.source}`,
228
+ `source: "${sanitizeYaml(params.source)}"`,
199
229
  `input_type: ${params.inputType}`,
200
230
  params.indexCode ? `zettel_id: "${params.indexCode}"` : null,
201
- `tags: [${params.tags.map(t => `"${t}"`).join(', ')}]`,
231
+ `tags: [${params.tags.map(t => `"${sanitizeYaml(t)}"`).join(', ')}]`,
202
232
  `created: ${params.created}`,
203
233
  `summary: "${sanitizeYaml(params.body.slice(0, 100))}"`,
204
234
  '---',
@@ -49,7 +49,17 @@ export function formatYouTubeNote(content) {
49
49
  lines.push(`> ${nt('views')}: ${Number(content.viewCount).toLocaleString()}`);
50
50
  lines.push(`> ${content.url}`, '');
51
51
  if (content.summary) {
52
- lines.push(`## ${nt('summary')}`, '', content.summary, '');
52
+ // 핵심 포인트를 bullet list로 포맷
53
+ const points = content.summary.split(/[.。!?]\s+/).filter(s => s.length > 15).slice(0, 5);
54
+ if (points.length > 1) {
55
+ lines.push(`## Key Points`, '');
56
+ for (const p of points)
57
+ lines.push(`- ${p.trim()}`);
58
+ lines.push('');
59
+ }
60
+ else {
61
+ lines.push(`## ${nt('summary')}`, '', content.summary, '');
62
+ }
53
63
  }
54
64
  if (content.description.length > 30) {
55
65
  lines.push(`## ${nt('description')}`, '', content.description.slice(0, 3000), '');
@@ -24,12 +24,15 @@ export function createAskTool(searchEngine, vaultPath) {
24
24
  save: args.save ?? false,
25
25
  vaultPath,
26
26
  });
27
+ const sourceList = result.sources.slice(0, 5).map((s, i) => `${i + 1}. **${s.title}** (score: ${s.score})\n > ${s.snippet.slice(0, 120)}...`).join('\n');
27
28
  const text = [
28
29
  result.answer,
29
30
  '',
30
- result.savedTo ? `Saved to: ${result.savedTo}` : '',
31
+ '---',
32
+ `### Sources (${result.sources.length} documents)`,
33
+ sourceList,
31
34
  '',
32
- `Sources: ${result.sources.length} documents found`,
35
+ result.savedTo ? `Saved to: ${result.savedTo}` : '',
33
36
  ].filter(Boolean).join('\n');
34
37
  return {
35
38
  content: [{ type: 'text', text }],