stellavault 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (265) hide show
  1. package/README.md +52 -16
  2. package/SECURITY.md +50 -0
  3. package/dist/stellavault.js +8968 -0
  4. package/package.json +70 -33
  5. package/.env.example +0 -12
  6. package/CHANGELOG.md +0 -106
  7. package/CLAUDE.md +0 -42
  8. package/CONTRIBUTING.md +0 -65
  9. package/index.html +0 -509
  10. package/packages/cli/bin/ekh.js +0 -2
  11. package/packages/cli/bin/stellavault.js +0 -2
  12. package/packages/cli/dist/commands/adr-cmd.d.ts +0 -7
  13. package/packages/cli/dist/commands/adr-cmd.js +0 -45
  14. package/packages/cli/dist/commands/ask-cmd.d.ts +0 -5
  15. package/packages/cli/dist/commands/ask-cmd.js +0 -36
  16. package/packages/cli/dist/commands/autopilot-cmd.d.ts +0 -4
  17. package/packages/cli/dist/commands/autopilot-cmd.js +0 -76
  18. package/packages/cli/dist/commands/brief-cmd.d.ts +0 -2
  19. package/packages/cli/dist/commands/brief-cmd.js +0 -82
  20. package/packages/cli/dist/commands/capture-cmd.d.ts +0 -7
  21. package/packages/cli/dist/commands/capture-cmd.js +0 -31
  22. package/packages/cli/dist/commands/card-cmd.d.ts +0 -4
  23. package/packages/cli/dist/commands/card-cmd.js +0 -26
  24. package/packages/cli/dist/commands/clip-cmd.d.ts +0 -4
  25. package/packages/cli/dist/commands/clip-cmd.js +0 -151
  26. package/packages/cli/dist/commands/cloud-cmd.d.ts +0 -4
  27. package/packages/cli/dist/commands/cloud-cmd.js +0 -64
  28. package/packages/cli/dist/commands/compile-cmd.d.ts +0 -6
  29. package/packages/cli/dist/commands/compile-cmd.js +0 -30
  30. package/packages/cli/dist/commands/contradictions-cmd.d.ts +0 -2
  31. package/packages/cli/dist/commands/contradictions-cmd.js +0 -34
  32. package/packages/cli/dist/commands/decay-cmd.d.ts +0 -2
  33. package/packages/cli/dist/commands/decay-cmd.js +0 -48
  34. package/packages/cli/dist/commands/digest-cmd.d.ts +0 -5
  35. package/packages/cli/dist/commands/digest-cmd.js +0 -136
  36. package/packages/cli/dist/commands/draft-cmd.d.ts +0 -6
  37. package/packages/cli/dist/commands/draft-cmd.js +0 -110
  38. package/packages/cli/dist/commands/duplicates-cmd.d.ts +0 -4
  39. package/packages/cli/dist/commands/duplicates-cmd.js +0 -30
  40. package/packages/cli/dist/commands/federate-cmd.d.ts +0 -5
  41. package/packages/cli/dist/commands/federate-cmd.js +0 -247
  42. package/packages/cli/dist/commands/fleeting-cmd.d.ts +0 -4
  43. package/packages/cli/dist/commands/fleeting-cmd.js +0 -45
  44. package/packages/cli/dist/commands/flush-cmd.d.ts +0 -2
  45. package/packages/cli/dist/commands/flush-cmd.js +0 -58
  46. package/packages/cli/dist/commands/gaps-cmd.d.ts +0 -2
  47. package/packages/cli/dist/commands/gaps-cmd.js +0 -33
  48. package/packages/cli/dist/commands/graph-cmd.d.ts +0 -2
  49. package/packages/cli/dist/commands/graph-cmd.js +0 -89
  50. package/packages/cli/dist/commands/index-cmd.d.ts +0 -2
  51. package/packages/cli/dist/commands/index-cmd.js +0 -57
  52. package/packages/cli/dist/commands/ingest-cmd.d.ts +0 -9
  53. package/packages/cli/dist/commands/ingest-cmd.js +0 -195
  54. package/packages/cli/dist/commands/init-cmd.d.ts +0 -2
  55. package/packages/cli/dist/commands/init-cmd.js +0 -161
  56. package/packages/cli/dist/commands/learn-cmd.d.ts +0 -2
  57. package/packages/cli/dist/commands/learn-cmd.js +0 -48
  58. package/packages/cli/dist/commands/lint-cmd.d.ts +0 -2
  59. package/packages/cli/dist/commands/lint-cmd.js +0 -61
  60. package/packages/cli/dist/commands/pack-cmd.d.ts +0 -15
  61. package/packages/cli/dist/commands/pack-cmd.js +0 -93
  62. package/packages/cli/dist/commands/review-cmd.d.ts +0 -4
  63. package/packages/cli/dist/commands/review-cmd.js +0 -107
  64. package/packages/cli/dist/commands/search-cmd.d.ts +0 -4
  65. package/packages/cli/dist/commands/search-cmd.js +0 -38
  66. package/packages/cli/dist/commands/serve-cmd.d.ts +0 -2
  67. package/packages/cli/dist/commands/serve-cmd.js +0 -14
  68. package/packages/cli/dist/commands/session-cmd.d.ts +0 -7
  69. package/packages/cli/dist/commands/session-cmd.js +0 -95
  70. package/packages/cli/dist/commands/status-cmd.d.ts +0 -2
  71. package/packages/cli/dist/commands/status-cmd.js +0 -33
  72. package/packages/cli/dist/commands/sync-cmd.d.ts +0 -5
  73. package/packages/cli/dist/commands/sync-cmd.js +0 -62
  74. package/packages/cli/dist/commands/vault-cmd.d.ts +0 -10
  75. package/packages/cli/dist/commands/vault-cmd.js +0 -54
  76. package/packages/cli/dist/index.d.ts +0 -2
  77. package/packages/cli/dist/index.js +0 -233
  78. package/packages/cli/package.json +0 -24
  79. package/packages/core/dist/api/dashboard.d.ts +0 -3
  80. package/packages/core/dist/api/dashboard.js +0 -135
  81. package/packages/core/dist/api/graph-data.d.ts +0 -11
  82. package/packages/core/dist/api/graph-data.js +0 -255
  83. package/packages/core/dist/api/pwa.d.ts +0 -3
  84. package/packages/core/dist/api/pwa.js +0 -77
  85. package/packages/core/dist/api/server.d.ts +0 -16
  86. package/packages/core/dist/api/server.js +0 -1122
  87. package/packages/core/dist/capture/voice.d.ts +0 -24
  88. package/packages/core/dist/capture/voice.js +0 -135
  89. package/packages/core/dist/cloud/index.d.ts +0 -3
  90. package/packages/core/dist/cloud/index.js +0 -2
  91. package/packages/core/dist/cloud/sync.d.ts +0 -29
  92. package/packages/core/dist/cloud/sync.js +0 -137
  93. package/packages/core/dist/config.d.ts +0 -35
  94. package/packages/core/dist/config.js +0 -63
  95. package/packages/core/dist/federation/credits.d.ts +0 -26
  96. package/packages/core/dist/federation/credits.js +0 -56
  97. package/packages/core/dist/federation/identity.d.ts +0 -14
  98. package/packages/core/dist/federation/identity.js +0 -74
  99. package/packages/core/dist/federation/index.d.ts +0 -7
  100. package/packages/core/dist/federation/index.js +0 -5
  101. package/packages/core/dist/federation/node.d.ts +0 -31
  102. package/packages/core/dist/federation/node.js +0 -216
  103. package/packages/core/dist/federation/privacy.d.ts +0 -8
  104. package/packages/core/dist/federation/privacy.js +0 -40
  105. package/packages/core/dist/federation/reputation.d.ts +0 -37
  106. package/packages/core/dist/federation/reputation.js +0 -139
  107. package/packages/core/dist/federation/search.d.ts +0 -19
  108. package/packages/core/dist/federation/search.js +0 -101
  109. package/packages/core/dist/federation/sharing.d.ts +0 -72
  110. package/packages/core/dist/federation/sharing.js +0 -246
  111. package/packages/core/dist/federation/trust.d.ts +0 -15
  112. package/packages/core/dist/federation/trust.js +0 -60
  113. package/packages/core/dist/federation/types.d.ts +0 -40
  114. package/packages/core/dist/federation/types.js +0 -3
  115. package/packages/core/dist/i18n/index.d.ts +0 -6
  116. package/packages/core/dist/i18n/index.js +0 -81
  117. package/packages/core/dist/i18n/note-strings.d.ts +0 -5
  118. package/packages/core/dist/i18n/note-strings.js +0 -94
  119. package/packages/core/dist/index.d.ts +0 -123
  120. package/packages/core/dist/index.js +0 -74
  121. package/packages/core/dist/indexer/chunker.d.ts +0 -14
  122. package/packages/core/dist/indexer/chunker.js +0 -148
  123. package/packages/core/dist/indexer/embedder.d.ts +0 -8
  124. package/packages/core/dist/indexer/embedder.js +0 -3
  125. package/packages/core/dist/indexer/index.d.ts +0 -28
  126. package/packages/core/dist/indexer/index.js +0 -74
  127. package/packages/core/dist/indexer/local-embedder.d.ts +0 -3
  128. package/packages/core/dist/indexer/local-embedder.js +0 -29
  129. package/packages/core/dist/indexer/scanner.d.ts +0 -11
  130. package/packages/core/dist/indexer/scanner.js +0 -148
  131. package/packages/core/dist/indexer/watcher.d.ts +0 -19
  132. package/packages/core/dist/indexer/watcher.js +0 -49
  133. package/packages/core/dist/intelligence/ask-engine.d.ts +0 -24
  134. package/packages/core/dist/intelligence/ask-engine.js +0 -142
  135. package/packages/core/dist/intelligence/auto-linker.d.ts +0 -19
  136. package/packages/core/dist/intelligence/auto-linker.js +0 -126
  137. package/packages/core/dist/intelligence/code-linker.d.ts +0 -20
  138. package/packages/core/dist/intelligence/code-linker.js +0 -88
  139. package/packages/core/dist/intelligence/contradiction-detector.d.ts +0 -20
  140. package/packages/core/dist/intelligence/contradiction-detector.js +0 -115
  141. package/packages/core/dist/intelligence/decay-engine.d.ts +0 -27
  142. package/packages/core/dist/intelligence/decay-engine.js +0 -190
  143. package/packages/core/dist/intelligence/draft-generator.d.ts +0 -25
  144. package/packages/core/dist/intelligence/draft-generator.js +0 -277
  145. package/packages/core/dist/intelligence/duplicate-detector.d.ts +0 -20
  146. package/packages/core/dist/intelligence/duplicate-detector.js +0 -55
  147. package/packages/core/dist/intelligence/file-extractors.d.ts +0 -13
  148. package/packages/core/dist/intelligence/file-extractors.js +0 -155
  149. package/packages/core/dist/intelligence/fsrs.d.ts +0 -43
  150. package/packages/core/dist/intelligence/fsrs.js +0 -70
  151. package/packages/core/dist/intelligence/gap-detector.d.ts +0 -25
  152. package/packages/core/dist/intelligence/gap-detector.js +0 -78
  153. package/packages/core/dist/intelligence/ingest-pipeline.d.ts +0 -32
  154. package/packages/core/dist/intelligence/ingest-pipeline.js +0 -245
  155. package/packages/core/dist/intelligence/knowledge-lint.d.ts +0 -27
  156. package/packages/core/dist/intelligence/knowledge-lint.js +0 -132
  157. package/packages/core/dist/intelligence/learning-path.d.ts +0 -31
  158. package/packages/core/dist/intelligence/learning-path.js +0 -53
  159. package/packages/core/dist/intelligence/notifications.d.ts +0 -31
  160. package/packages/core/dist/intelligence/notifications.js +0 -65
  161. package/packages/core/dist/intelligence/predictive-gaps.d.ts +0 -14
  162. package/packages/core/dist/intelligence/predictive-gaps.js +0 -74
  163. package/packages/core/dist/intelligence/semantic-versioning.d.ts +0 -37
  164. package/packages/core/dist/intelligence/semantic-versioning.js +0 -68
  165. package/packages/core/dist/intelligence/types.d.ts +0 -28
  166. package/packages/core/dist/intelligence/types.js +0 -3
  167. package/packages/core/dist/intelligence/wiki-compiler.d.ts +0 -30
  168. package/packages/core/dist/intelligence/wiki-compiler.js +0 -222
  169. package/packages/core/dist/intelligence/youtube-extractor.d.ts +0 -29
  170. package/packages/core/dist/intelligence/youtube-extractor.js +0 -321
  171. package/packages/core/dist/intelligence/zettelkasten.d.ts +0 -59
  172. package/packages/core/dist/intelligence/zettelkasten.js +0 -234
  173. package/packages/core/dist/mcp/custom-tools.d.ts +0 -29
  174. package/packages/core/dist/mcp/custom-tools.js +0 -70
  175. package/packages/core/dist/mcp/index.d.ts +0 -2
  176. package/packages/core/dist/mcp/index.js +0 -2
  177. package/packages/core/dist/mcp/server.d.ts +0 -51
  178. package/packages/core/dist/mcp/server.js +0 -174
  179. package/packages/core/dist/mcp/tools/agentic-graph.d.ts +0 -93
  180. package/packages/core/dist/mcp/tools/agentic-graph.js +0 -116
  181. package/packages/core/dist/mcp/tools/ask.d.ts +0 -29
  182. package/packages/core/dist/mcp/tools/ask.js +0 -43
  183. package/packages/core/dist/mcp/tools/brief.d.ts +0 -31
  184. package/packages/core/dist/mcp/tools/brief.js +0 -39
  185. package/packages/core/dist/mcp/tools/decay.d.ts +0 -33
  186. package/packages/core/dist/mcp/tools/decay.js +0 -32
  187. package/packages/core/dist/mcp/tools/decision-journal.d.ts +0 -78
  188. package/packages/core/dist/mcp/tools/decision-journal.js +0 -79
  189. package/packages/core/dist/mcp/tools/detect-gaps.d.ts +0 -24
  190. package/packages/core/dist/mcp/tools/detect-gaps.js +0 -47
  191. package/packages/core/dist/mcp/tools/export.d.ts +0 -29
  192. package/packages/core/dist/mcp/tools/export.js +0 -60
  193. package/packages/core/dist/mcp/tools/federated-search.d.ts +0 -29
  194. package/packages/core/dist/mcp/tools/federated-search.js +0 -36
  195. package/packages/core/dist/mcp/tools/generate-claude-md.d.ts +0 -35
  196. package/packages/core/dist/mcp/tools/generate-claude-md.js +0 -107
  197. package/packages/core/dist/mcp/tools/generate-draft.d.ts +0 -34
  198. package/packages/core/dist/mcp/tools/generate-draft.js +0 -120
  199. package/packages/core/dist/mcp/tools/get-document.d.ts +0 -35
  200. package/packages/core/dist/mcp/tools/get-document.js +0 -25
  201. package/packages/core/dist/mcp/tools/get-evolution.d.ts +0 -28
  202. package/packages/core/dist/mcp/tools/get-evolution.js +0 -70
  203. package/packages/core/dist/mcp/tools/get-related.d.ts +0 -32
  204. package/packages/core/dist/mcp/tools/get-related.js +0 -33
  205. package/packages/core/dist/mcp/tools/learning-path.d.ts +0 -23
  206. package/packages/core/dist/mcp/tools/learning-path.js +0 -45
  207. package/packages/core/dist/mcp/tools/link-code.d.ts +0 -34
  208. package/packages/core/dist/mcp/tools/link-code.js +0 -44
  209. package/packages/core/dist/mcp/tools/list-topics.d.ts +0 -15
  210. package/packages/core/dist/mcp/tools/list-topics.js +0 -18
  211. package/packages/core/dist/mcp/tools/search.d.ts +0 -39
  212. package/packages/core/dist/mcp/tools/search.js +0 -29
  213. package/packages/core/dist/mcp/tools/snapshot.d.ts +0 -47
  214. package/packages/core/dist/mcp/tools/snapshot.js +0 -84
  215. package/packages/core/dist/multi-vault/index.d.ts +0 -26
  216. package/packages/core/dist/multi-vault/index.js +0 -80
  217. package/packages/core/dist/pack/creator.d.ts +0 -21
  218. package/packages/core/dist/pack/creator.js +0 -105
  219. package/packages/core/dist/pack/exporter.d.ts +0 -4
  220. package/packages/core/dist/pack/exporter.js +0 -18
  221. package/packages/core/dist/pack/importer.d.ts +0 -10
  222. package/packages/core/dist/pack/importer.js +0 -55
  223. package/packages/core/dist/pack/index.d.ts +0 -6
  224. package/packages/core/dist/pack/index.js +0 -5
  225. package/packages/core/dist/pack/marketplace.d.ts +0 -14
  226. package/packages/core/dist/pack/marketplace.js +0 -90
  227. package/packages/core/dist/pack/pii-masker.d.ts +0 -7
  228. package/packages/core/dist/pack/pii-masker.js +0 -29
  229. package/packages/core/dist/pack/types.d.ts +0 -36
  230. package/packages/core/dist/pack/types.js +0 -3
  231. package/packages/core/dist/plugins/index.d.ts +0 -35
  232. package/packages/core/dist/plugins/index.js +0 -57
  233. package/packages/core/dist/plugins/webhooks.d.ts +0 -30
  234. package/packages/core/dist/plugins/webhooks.js +0 -79
  235. package/packages/core/dist/search/adaptive.d.ts +0 -16
  236. package/packages/core/dist/search/adaptive.js +0 -67
  237. package/packages/core/dist/search/bm25.d.ts +0 -4
  238. package/packages/core/dist/search/bm25.js +0 -10
  239. package/packages/core/dist/search/index.d.ts +0 -15
  240. package/packages/core/dist/search/index.js +0 -64
  241. package/packages/core/dist/search/rrf.d.ts +0 -7
  242. package/packages/core/dist/search/rrf.js +0 -21
  243. package/packages/core/dist/search/semantic.d.ts +0 -5
  244. package/packages/core/dist/search/semantic.js +0 -6
  245. package/packages/core/dist/store/index.d.ts +0 -3
  246. package/packages/core/dist/store/index.js +0 -2
  247. package/packages/core/dist/store/sqlite-vec.d.ts +0 -6
  248. package/packages/core/dist/store/sqlite-vec.js +0 -251
  249. package/packages/core/dist/store/types.d.ts +0 -20
  250. package/packages/core/dist/store/types.js +0 -3
  251. package/packages/core/dist/team/index.d.ts +0 -25
  252. package/packages/core/dist/team/index.js +0 -97
  253. package/packages/core/dist/types/chunk.d.ts +0 -23
  254. package/packages/core/dist/types/chunk.js +0 -3
  255. package/packages/core/dist/types/document.d.ts +0 -23
  256. package/packages/core/dist/types/document.js +0 -3
  257. package/packages/core/dist/types/graph.d.ts +0 -39
  258. package/packages/core/dist/types/graph.js +0 -3
  259. package/packages/core/dist/types/index.d.ts +0 -5
  260. package/packages/core/dist/types/index.js +0 -2
  261. package/packages/core/dist/types/search.d.ts +0 -39
  262. package/packages/core/dist/types/search.js +0 -3
  263. package/packages/core/dist/utils/retry.d.ts +0 -25
  264. package/packages/core/dist/utils/retry.js +0 -59
  265. package/packages/core/package.json +0 -67
@@ -1,1122 +0,0 @@
1
- // Design Ref: §4.1 — REST API (core/api/)
2
- // Design Ref: §7 — Security: localhost only, CORS restricted
3
- import express from 'express';
4
- import cors from 'cors';
5
- import { buildGraphData } from './graph-data.js';
6
- import { detectDuplicates } from '../intelligence/duplicate-detector.js';
7
- import { detectKnowledgeGaps } from '../intelligence/gap-detector.js';
8
- export function createApiServer(options) {
9
- const { store, searchEngine, port = 3333, vaultName = '', vaultPath = '', decayEngine } = options;
10
- const app = express();
11
- app.use(cors({ origin: ['http://localhost:5173', 'http://127.0.0.1:5173'] }));
12
- app.use(express.json());
13
- // GET /api/graph?mode=semantic|folder — 전체 그래프 데이터
14
- const graphCaches = new Map();
15
- app.get('/api/graph', async (req, res) => {
16
- try {
17
- const mode = req.query.mode === 'folder' ? 'folder' : 'semantic';
18
- if (!graphCaches.has(mode)) {
19
- const data = await buildGraphData(store, { mode });
20
- graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
21
- }
22
- const cached = graphCaches.get(mode);
23
- res.json({ data: cached.data, generatedAt: cached.generatedAt, mode });
24
- }
25
- catch (err) {
26
- console.error(err);
27
- res.status(500).json({ error: 'Internal server error' });
28
- }
29
- });
30
- // GET /api/graph/refresh?mode= — 캐시 무효화 + 재생성
31
- app.get('/api/graph/refresh', async (req, res) => {
32
- try {
33
- const mode = req.query.mode === 'folder' ? 'folder' : 'semantic';
34
- const data = await buildGraphData(store, { mode });
35
- graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
36
- const cached = graphCaches.get(mode);
37
- res.json({ data: cached.data, generatedAt: cached.generatedAt, mode });
38
- }
39
- catch (err) {
40
- console.error(err);
41
- res.status(500).json({ error: 'Internal server error' });
42
- }
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
- });
49
- // POST /api/reindex — 웹에서 인덱싱 트리거
50
- let isReindexing = false;
51
- app.post('/api/reindex', async (_req, res) => {
52
- if (isReindexing) {
53
- res.json({ success: false, error: 'Reindexing already in progress', progress: reindexProgress });
54
- return;
55
- }
56
- isReindexing = true;
57
- reindexProgress = { active: true, current: 0, total: 0, phase: 'initializing' };
58
- try {
59
- const indexer = await import('../indexer/index.js');
60
- reindexProgress.phase = 'loading embedder';
61
- const embedder = indexer.createLocalEmbedder('all-MiniLM-L6-v2');
62
- await embedder.initialize();
63
- reindexProgress.phase = 'indexing';
64
- const result = await indexer.indexVault(vaultPath, {
65
- store,
66
- embedder,
67
- onProgress: (current, total) => {
68
- reindexProgress.current = current;
69
- reindexProgress.total = total;
70
- if (current % 50 === 0)
71
- console.error(`[reindex] ${current}/${total}`);
72
- },
73
- });
74
- // 그래프 캐시 리셋
75
- graphCaches.clear();
76
- res.json({
77
- success: true,
78
- indexed: result.indexed,
79
- skipped: result.skipped,
80
- chunks: result.totalChunks,
81
- });
82
- }
83
- catch (err) {
84
- console.error('[reindex]', err);
85
- res.status(500).json({ error: `Reindex failed: ${err?.message ?? String(err)}` });
86
- }
87
- finally {
88
- isReindexing = false;
89
- reindexProgress = { active: false, current: 0, total: 0, phase: 'done' };
90
- }
91
- });
92
- // GET /api/search?q=&limit=
93
- app.get('/api/search', async (req, res) => {
94
- try {
95
- const query = String(req.query.q || '');
96
- const limit = parseInt(String(req.query.limit || '10'), 10);
97
- if (!query) {
98
- res.json({ results: [], query: '' });
99
- return;
100
- }
101
- const results = await searchEngine.search({ query, limit });
102
- // 검색 결과 문서에 대해 접근 이벤트 기록 (감쇠 리셋)
103
- if (decayEngine) {
104
- const now = new Date().toISOString();
105
- for (const r of results) {
106
- decayEngine.recordAccess({ documentId: r.document.id, type: 'search', timestamp: now }).catch(() => { });
107
- }
108
- }
109
- res.json({
110
- results: results.map(r => ({
111
- documentId: r.document.id,
112
- title: r.document.title,
113
- filePath: r.document.filePath,
114
- score: Math.round(r.score * 1000) / 1000,
115
- snippet: r.chunk?.content?.substring(0, 200) ?? '',
116
- tags: r.document.tags ?? [],
117
- highlights: r.highlights,
118
- })),
119
- query,
120
- });
121
- }
122
- catch (err) {
123
- console.error(err);
124
- res.status(500).json({ error: 'Internal server error' });
125
- }
126
- });
127
- // GET /api/document/:id
128
- app.get('/api/document/:id', async (req, res) => {
129
- try {
130
- const doc = await store.getDocument(req.params.id);
131
- if (!doc) {
132
- res.status(404).json({ error: 'Not found' });
133
- return;
134
- }
135
- // 접근 이벤트 기록 (감쇠 리셋)
136
- if (decayEngine) {
137
- decayEngine.recordAccess({ documentId: doc.id, type: 'view', timestamp: new Date().toISOString() }).catch(() => { });
138
- }
139
- // 관련 문서 (제목 기반 검색)
140
- const related = await searchEngine.search({
141
- query: doc.title,
142
- limit: 6,
143
- });
144
- res.json({
145
- id: doc.id,
146
- title: doc.title,
147
- filePath: doc.filePath,
148
- content: doc.content,
149
- tags: doc.tags,
150
- lastModified: doc.lastModified,
151
- related: related
152
- .filter(r => r.document.id !== doc.id)
153
- .slice(0, 5)
154
- .map(r => ({ id: r.document.id, title: r.document.title, score: r.score })),
155
- });
156
- }
157
- catch (err) {
158
- console.error(err);
159
- res.status(500).json({ error: 'Internal server error' });
160
- }
161
- });
162
- // GET /api/profile-card → SVG
163
- app.get('/api/profile-card', async (_req, res) => {
164
- try {
165
- const mode = _req.query.mode === 'folder' ? 'folder' : 'semantic';
166
- if (!graphCaches.has(mode)) {
167
- const data = await buildGraphData(store, { mode });
168
- graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
169
- }
170
- const graphData = graphCaches.get(mode).data;
171
- const topics = await store.getTopics();
172
- // 동적 import (graph 패키지의 profile-card)
173
- // 여기서는 간단히 SVG를 직접 생성
174
- const stats = await store.getStats();
175
- const top6 = graphData.clusters
176
- .sort((a, b) => b.nodeCount - a.nodeCount)
177
- .slice(0, 6);
178
- const maxCount = Math.max(1, ...top6.map((c) => c.nodeCount));
179
- const W = 800, H = 420;
180
- const radarCx = 200, radarCy = 220, radarR = 100;
181
- const radarPoints = top6.map((c, i) => {
182
- const angle = (Math.PI * 2 * i) / top6.length - Math.PI / 2;
183
- const r = radarR * (c.nodeCount / maxCount);
184
- return {
185
- x: radarCx + r * Math.cos(angle),
186
- y: radarCy + r * Math.sin(angle),
187
- lx: radarCx + (radarR + 20) * Math.cos(angle),
188
- ly: radarCy + (radarR + 20) * Math.sin(angle),
189
- label: c.label.split(',')[0].trim().slice(0, 12),
190
- color: c.color,
191
- };
192
- });
193
- const radarPath = radarPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + 'Z';
194
- const gridPaths = [0.33, 0.66, 1].map((s) => top6.map((_, i) => {
195
- const a = (Math.PI * 2 * i) / top6.length - Math.PI / 2;
196
- return `${i === 0 ? 'M' : 'L'}${radarCx + radarR * s * Math.cos(a)},${radarCy + radarR * s * Math.sin(a)}`;
197
- }).join(' ') + 'Z');
198
- const tags20 = topics.slice(0, 20);
199
- const maxTag = Math.max(1, ...tags20.map((t) => t.count));
200
- // HIGH-07: SVG injection 방어 — 모든 특수문자 이스케이프
201
- const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
202
- const tagEls = tags20.map((t, i) => {
203
- const sz = 10 + 14 * (t.count / maxTag);
204
- const x = 480 + (Math.floor(i / 2) % 5) * 60;
205
- const y = 140 + (i % 2) * 30 + Math.floor(i / 10) * 70;
206
- const op = 0.5 + 0.5 * (t.count / maxTag);
207
- return `<text x="${x}" y="${y}" font-size="${sz}" fill="#88aaff" opacity="${op}" font-family="monospace">#${esc(t.topic)}</text>`;
208
- }).join('\n ');
209
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
210
- <defs>
211
- <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
212
- <stop offset="0%" stop-color="#0d1028"/><stop offset="100%" stop-color="#050510"/>
213
- </linearGradient>
214
- <linearGradient id="rf" x1="0%" y1="0%" x2="100%" y2="100%">
215
- <stop offset="0%" stop-color="#6366f1" stop-opacity="0.3"/><stop offset="100%" stop-color="#06b6d4" stop-opacity="0.15"/>
216
- </linearGradient>
217
- </defs>
218
- <rect width="${W}" height="${H}" rx="16" fill="url(#bg)"/>
219
- <rect width="${W}" height="${H}" rx="16" fill="none" stroke="#6366f140"/>
220
- <text x="30" y="40" font-size="20" font-weight="700" fill="#c0c0f0" font-family="system-ui">🧠 Knowledge Universe</text>
221
- <text x="30" y="65" font-size="13" fill="#556" font-family="monospace">${stats.documentCount} docs · ${graphData.clusters.length} clusters · ${graphData.edges.length} connections</text>
222
- <line x1="30" y1="80" x2="${W - 30}" y2="80" stroke="#6366f120"/>
223
- <text x="${radarCx}" y="105" font-size="11" fill="#667" text-anchor="middle" font-family="system-ui">KNOWLEDGE DISTRIBUTION</text>
224
- ${gridPaths.map((p) => `<path d="${p}" fill="none" stroke="#6366f115" stroke-width="0.5"/>`).join('\n ')}
225
- ${radarPoints.map((p) => `<line x1="${radarCx}" y1="${radarCy}" x2="${p.lx}" y2="${p.ly}" stroke="#6366f110" stroke-width="0.5"/>`).join('\n ')}
226
- <path d="${radarPath}" fill="url(#rf)" stroke="#818cf8" stroke-width="1.5"/>
227
- ${radarPoints.map((p) => `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${p.color}"/><text x="${p.lx}" y="${p.ly + 4}" font-size="9" fill="#889" text-anchor="middle" font-family="monospace">${esc(p.label)}</text>`).join('\n ')}
228
- <text x="580" y="105" font-size="11" fill="#667" text-anchor="middle" font-family="system-ui">TOP TOPICS</text>
229
- <rect x="440" y="115" width="320" height="240" rx="8" fill="#6366f108"/>
230
- ${tagEls}
231
- <text x="${W / 2}" y="${H - 15}" font-size="10" fill="#334" text-anchor="middle" font-family="monospace">Generated by Stellavault</text>
232
- </svg>`;
233
- res.setHeader('Content-Type', 'image/svg+xml');
234
- res.send(svg);
235
- }
236
- catch (err) {
237
- console.error(err);
238
- res.status(500).json({ error: 'Internal server error' });
239
- }
240
- });
241
- // GET /api/stats
242
- app.get('/api/stats', async (_req, res) => {
243
- try {
244
- const stats = await store.getStats();
245
- res.json({ ...stats, vaultName });
246
- }
247
- catch (err) {
248
- console.error(err);
249
- res.status(500).json({ error: 'Internal server error' });
250
- }
251
- });
252
- // GET /api/decay — 감쇠 상태 리포트
253
- app.get('/api/decay', async (_req, res) => {
254
- if (!decayEngine) {
255
- res.json({ error: 'Decay engine not initialized' });
256
- return;
257
- }
258
- try {
259
- const report = await decayEngine.computeAll();
260
- res.json(report);
261
- }
262
- catch (err) {
263
- console.error(err);
264
- res.status(500).json({ error: 'Internal server error' });
265
- }
266
- });
267
- // PUT /api/document/:id — 노트 편집 (vault 파일 직접 수정)
268
- app.put('/api/document/:id', async (req, res) => {
269
- try {
270
- const { id } = req.params;
271
- const { title, content, tags } = req.body;
272
- const doc = await store.getDocument(id);
273
- if (!doc) {
274
- res.status(404).json({ error: 'Document not found' });
275
- return;
276
- }
277
- const { resolve, join } = await import('node:path');
278
- const { writeFileSync, readFileSync } = await import('node:fs');
279
- const fullPath = resolve(vaultPath, doc.filePath);
280
- // path traversal 방지
281
- if (!fullPath.startsWith(resolve(vaultPath))) {
282
- res.status(403).json({ error: 'Access denied' });
283
- return;
284
- }
285
- // 기존 파일 읽기
286
- const existing = readFileSync(fullPath, 'utf-8');
287
- // frontmatter 업데이트
288
- let updated = existing;
289
- if (title && title !== doc.title) {
290
- updated = updated.replace(/^title:\s*.+$/m, `title: "${title.replace(/"/g, "''")}"`);
291
- }
292
- if (tags) {
293
- const tagStr = `tags: [${tags.map((t) => `"${t}"`).join(', ')}]`;
294
- if (updated.match(/^tags:\s*.+$/m)) {
295
- updated = updated.replace(/^tags:\s*.+$/m, tagStr);
296
- }
297
- }
298
- if (content !== undefined) {
299
- // frontmatter 이후 본문 교체
300
- const fmEnd = updated.indexOf('---', 4);
301
- if (fmEnd > 0) {
302
- const fm = updated.substring(0, fmEnd + 3);
303
- updated = fm + '\n\n' + content;
304
- }
305
- else {
306
- updated = content;
307
- }
308
- }
309
- writeFileSync(fullPath, updated, 'utf-8');
310
- // DB 업데이트
311
- await store.upsertDocument({
312
- ...doc,
313
- title: title ?? doc.title,
314
- content: content ?? doc.content,
315
- tags: tags ?? doc.tags,
316
- lastModified: new Date().toISOString(),
317
- });
318
- res.json({ success: true, id, title: title ?? doc.title });
319
- }
320
- catch (err) {
321
- console.error('[edit]', err);
322
- res.status(500).json({ error: err?.message ?? 'Edit failed' });
323
- }
324
- });
325
- // DELETE /api/document/:id — 노트 삭제 (vault 파일 + DB)
326
- app.delete('/api/document/:id', async (req, res) => {
327
- try {
328
- const { id } = req.params;
329
- const doc = await store.getDocument(id);
330
- if (!doc) {
331
- res.status(404).json({ error: 'Document not found' });
332
- return;
333
- }
334
- const { resolve } = await import('node:path');
335
- const { unlinkSync, existsSync } = await import('node:fs');
336
- const fullPath = resolve(vaultPath, doc.filePath);
337
- // path traversal 방지
338
- if (!fullPath.startsWith(resolve(vaultPath))) {
339
- res.status(403).json({ error: 'Access denied' });
340
- return;
341
- }
342
- // 파일 삭제
343
- if (existsSync(fullPath)) {
344
- unlinkSync(fullPath);
345
- }
346
- // DB에서 삭제
347
- await store.deleteByDocumentId(id);
348
- res.json({ success: true, id, deleted: doc.filePath });
349
- }
350
- catch (err) {
351
- console.error('[delete]', err);
352
- res.status(500).json({ error: err?.message ?? 'Delete failed' });
353
- }
354
- });
355
- // GET /api/ask — 웹 UI Q&A
356
- app.get('/api/ask', async (req, res) => {
357
- try {
358
- const question = String(req.query.q || '');
359
- if (!question) {
360
- res.json({ question: '', answer: '', sources: [] });
361
- return;
362
- }
363
- const { askVault } = await import('../intelligence/ask-engine.js');
364
- const result = await askVault(searchEngine, question, {
365
- limit: 10,
366
- save: req.query.save === 'true',
367
- vaultPath,
368
- });
369
- res.json(result);
370
- }
371
- catch (err) {
372
- console.error(err);
373
- res.status(500).json({ error: 'Ask failed' });
374
- }
375
- });
376
- // POST /api/ingest — 웹 UI에서 URL/텍스트 인제스트
377
- app.post('/api/ingest', async (req, res) => {
378
- try {
379
- const { input, type, tags, title, stage, locale } = req.body;
380
- // 노트 언어 설정
381
- if (locale) {
382
- const { setNoteLocale } = await import('../i18n/note-strings.js');
383
- setNoteLocale(locale);
384
- }
385
- if (!input || typeof input !== 'string') {
386
- res.status(400).json({ error: 'input is required' });
387
- return;
388
- }
389
- // URL인 경우 제목+내용 가져오기
390
- let content = input;
391
- let autoTitle = title;
392
- let autoTags = tags ?? [];
393
- let autoStage = stage ?? 'fleeting';
394
- const isYouTube = /youtube\.com\/watch|youtu\.be\//.test(input);
395
- if (isYouTube) {
396
- // YouTube 전용: 자막 + 메타데이터 추출
397
- try {
398
- const { extractYouTubeContent, formatYouTubeNote } = await import('../intelligence/youtube-extractor.js');
399
- const ytContent = await extractYouTubeContent(input);
400
- autoTitle = ytContent.title;
401
- autoTags = [...new Set(['youtube', ...ytContent.tags, ...(tags ?? [])])];
402
- autoStage = 'literature';
403
- // formatYouTubeNote는 frontmatter 없이 본문만 생성 → pipeline이 frontmatter 담당
404
- content = formatYouTubeNote(ytContent);
405
- }
406
- catch (ytErr) {
407
- console.error('[ingest] YouTube extraction failed, falling back to basic:', ytErr instanceof Error ? ytErr.message : ytErr);
408
- // 폴백: 기본 HTML 추출
409
- try {
410
- const resp = await fetch(input, { signal: AbortSignal.timeout(8000) });
411
- const html = await resp.text();
412
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
413
- if (titleMatch && !autoTitle)
414
- autoTitle = titleMatch[1].trim();
415
- content = input + '\n\n' + html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 5000);
416
- }
417
- catch { /* URL만 저장 */ }
418
- }
419
- }
420
- else if (input.startsWith('http')) {
421
- // 일반 웹페이지
422
- try {
423
- const resp = await fetch(input, { signal: AbortSignal.timeout(8000) });
424
- const html = await resp.text();
425
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
426
- if (titleMatch && !autoTitle)
427
- autoTitle = titleMatch[1].trim();
428
- const text = html
429
- .replace(/<script[\s\S]*?<\/script>/gi, '')
430
- .replace(/<style[\s\S]*?<\/style>/gi, '')
431
- .replace(/<[^>]+>/g, ' ')
432
- .replace(/&nbsp;/g, ' ')
433
- .replace(/&amp;/g, '&')
434
- .replace(/&lt;/g, '<')
435
- .replace(/&gt;/g, '>')
436
- .replace(/\s+/g, ' ')
437
- .trim()
438
- .slice(0, 5000);
439
- content = input + '\n\n' + text;
440
- }
441
- catch { /* URL만 저장 */ }
442
- }
443
- const { ingest } = await import('../intelligence/ingest-pipeline.js');
444
- const result = ingest(vaultPath, {
445
- type: type ?? (isYouTube ? 'youtube' : input.startsWith('http') ? 'url' : 'text'),
446
- content,
447
- tags: autoTags,
448
- title: autoTitle,
449
- stage: autoStage,
450
- source: input.startsWith('http') ? input : undefined,
451
- });
452
- // 저장 후 자동 인덱싱 (그래프에 바로 반영)
453
- try {
454
- const fullPath = require('node:path').resolve(vaultPath, result.savedTo);
455
- const { chunkDocument } = await import('../indexer/index.js');
456
- const doc = {
457
- id: require('node:crypto').createHash('sha256').update(result.savedTo).digest('hex').slice(0, 16),
458
- filePath: result.savedTo,
459
- title: result.title,
460
- content: content,
461
- frontmatter: {},
462
- tags: result.tags,
463
- lastModified: new Date().toISOString(),
464
- contentHash: '',
465
- source: 'ingest',
466
- type: result.stage,
467
- };
468
- await store.upsertDocument(doc);
469
- }
470
- catch (indexErr) {
471
- console.error('[ingest] Auto-index failed:', indexErr instanceof Error ? indexErr.message : indexErr);
472
- }
473
- res.json({
474
- success: true,
475
- savedTo: result.savedTo,
476
- stage: result.stage,
477
- title: result.title,
478
- indexCode: result.indexCode,
479
- tags: result.tags,
480
- wordCount: result.wordCount,
481
- });
482
- }
483
- catch (err) {
484
- console.error(err);
485
- res.status(500).json({ error: 'Ingest failed' });
486
- }
487
- });
488
- // POST /api/ingest/file — 웹 UI에서 파일 드래그앤드롭 인제스트
489
- app.post('/api/ingest/file', async (req, res) => {
490
- try {
491
- const multer = (await import('multer')).default;
492
- const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 50 * 1024 * 1024 } });
493
- upload.single('file')(req, res, async (uploadErr) => {
494
- if (uploadErr) {
495
- res.status(400).json({ error: uploadErr.message || 'Upload failed' });
496
- return;
497
- }
498
- const file = req.file;
499
- if (!file) {
500
- res.status(400).json({ error: 'No file provided' });
501
- return;
502
- }
503
- // MIME 화이트리스트
504
- const allowedMimes = new Set([
505
- 'application/pdf',
506
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
507
- 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
508
- 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
509
- 'application/vnd.ms-excel',
510
- 'text/plain', 'text/markdown', 'text/csv',
511
- ]);
512
- if (!allowedMimes.has(file.mimetype)) {
513
- res.status(400).json({ error: `Unsupported file type: ${file.mimetype}` });
514
- return;
515
- }
516
- try {
517
- const { writeFileSync, unlinkSync } = await import('node:fs');
518
- const { join } = await import('node:path');
519
- const { tmpdir } = await import('node:os');
520
- const tmpPath = join(tmpdir(), `sv-upload-${Date.now()}-${file.originalname}`);
521
- // 임시 파일 저장 → 파서가 파일 경로 필요
522
- writeFileSync(tmpPath, file.buffer);
523
- const { extractFileContent, isBinaryFormat } = await import('../intelligence/file-extractors.js');
524
- const ext = file.originalname.split('.').pop()?.toLowerCase() ?? '';
525
- const binaryExts = new Set(['pdf', 'docx', 'pptx', 'xlsx', 'xls']);
526
- let content;
527
- let extractedTitle;
528
- let formatTag = ext;
529
- if (binaryExts.has(ext)) {
530
- const extracted = await extractFileContent(tmpPath);
531
- content = extracted.text;
532
- extractedTitle = extracted.metadata.title;
533
- formatTag = extracted.sourceFormat;
534
- }
535
- else {
536
- content = file.buffer.toString('utf-8');
537
- }
538
- // 임시 파일 삭제
539
- try {
540
- unlinkSync(tmpPath);
541
- }
542
- catch { /* ok */ }
543
- // locale 설정
544
- const locale = req.body?.locale;
545
- if (locale) {
546
- const { setNoteLocale } = await import('../i18n/note-strings.js');
547
- setNoteLocale(locale);
548
- }
549
- const tags = req.body?.tags ? (Array.isArray(req.body.tags) ? req.body.tags : req.body.tags.split(',').map((t) => t.trim())) : [];
550
- const { ingest } = await import('../intelligence/ingest-pipeline.js');
551
- const result = ingest(vaultPath, {
552
- type: formatTag,
553
- content,
554
- tags: [...tags, formatTag],
555
- title: req.body?.title ?? extractedTitle,
556
- stage: 'fleeting',
557
- source: file.originalname,
558
- });
559
- // 자동 인덱싱
560
- try {
561
- const doc = {
562
- id: require('node:crypto').createHash('sha256').update(result.savedTo).digest('hex').slice(0, 16),
563
- filePath: result.savedTo,
564
- title: result.title,
565
- content,
566
- frontmatter: {},
567
- tags: result.tags,
568
- lastModified: new Date().toISOString(),
569
- contentHash: '',
570
- source: 'upload',
571
- type: result.stage,
572
- };
573
- await store.upsertDocument(doc);
574
- }
575
- catch (indexErr) {
576
- console.error('[ingest/file] Auto-index failed:', indexErr instanceof Error ? indexErr.message : indexErr);
577
- }
578
- res.json({
579
- success: true,
580
- savedTo: result.savedTo,
581
- stage: result.stage,
582
- title: result.title,
583
- indexCode: result.indexCode,
584
- tags: result.tags,
585
- wordCount: result.wordCount,
586
- });
587
- }
588
- catch (err) {
589
- res.status(500).json({ error: err instanceof Error ? err.message : 'Processing failed' });
590
- }
591
- });
592
- }
593
- catch (err) {
594
- res.status(500).json({ error: 'File upload initialization failed' });
595
- }
596
- });
597
- // GET /api/recent — 최근 저장된 노트 목록
598
- app.get('/api/recent', async (_req, res) => {
599
- try {
600
- const docs = await store.getAllDocuments();
601
- const recent = docs
602
- .filter(d => d.lastModified)
603
- .sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime())
604
- .slice(0, 20)
605
- .map(d => ({
606
- id: d.id,
607
- title: d.title,
608
- filePath: d.filePath,
609
- lastModified: d.lastModified,
610
- tags: d.tags.slice(0, 5),
611
- type: d.type ?? 'note',
612
- }));
613
- res.json({ recent });
614
- }
615
- catch (err) {
616
- console.error(err);
617
- res.status(500).json({ error: 'Failed' });
618
- }
619
- });
620
- // GET /api/heatmap — Design Ref: §2.2 — 지식 히트맵 활동 점수
621
- app.get('/api/heatmap', async (_req, res) => {
622
- try {
623
- const docs = await store.getAllDocuments();
624
- const now = Date.now();
625
- const scores = {};
626
- let hotCount = 0;
627
- let coldCount = 0;
628
- // Pre-fetch decay data if available
629
- let decayMap = {};
630
- if (decayEngine) {
631
- try {
632
- const report = await decayEngine.computeAll();
633
- // topDecaying has R values for worst-performing docs
634
- for (const item of report.topDecaying) {
635
- decayMap[item.documentId] = item.retrievability;
636
- }
637
- }
638
- catch { /* ignore */ }
639
- }
640
- for (const doc of docs) {
641
- // 최근 수정 기반 점수 (0~0.4)
642
- const modified = doc.lastModified ? new Date(doc.lastModified).getTime() : now - 86400000 * 60;
643
- const daysSinceModified = (now - modified) / 86400000;
644
- const recencyScore = Math.max(0, 1 - daysSinceModified / 90) * 0.4;
645
- // 감쇠 R값 기반 (0~0.3)
646
- const decayScore = (decayMap[doc.id] ?? 0.5) * 0.3;
647
- // 태그 수 기반 연결도 (0~0.3)
648
- const tagScore = Math.min((doc.tags?.length ?? 0) / 10, 1) * 0.3;
649
- const score = Math.min(1, recencyScore + decayScore + tagScore);
650
- scores[doc.id] = score;
651
- if (score > 0.6)
652
- hotCount++;
653
- if (score < 0.2)
654
- coldCount++;
655
- }
656
- res.json({ scores, stats: { total: docs.length, hotCount, coldCount } });
657
- }
658
- catch (err) {
659
- console.error(err);
660
- res.status(500).json({ error: 'Internal server error' });
661
- }
662
- });
663
- // GET /api/evolution — Design Ref: F02 — 시맨틱 진화 데이터
664
- app.get('/api/evolution', async (req, res) => {
665
- try {
666
- const topic = req.query.topic;
667
- const limit = parseInt(String(req.query.limit ?? '20'), 10);
668
- const docs = await store.getAllDocuments();
669
- let filtered = docs;
670
- if (topic) {
671
- const t = topic.toLowerCase();
672
- filtered = docs.filter((d) => d.tags.some((tag) => tag.toLowerCase().includes(t)) || d.title.toLowerCase().includes(t));
673
- }
674
- const evolved = filtered
675
- .filter((d) => d.lastModified)
676
- .sort((a, b) => new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime())
677
- .slice(0, limit)
678
- .map((d) => ({
679
- id: d.id,
680
- title: d.title,
681
- lastModified: d.lastModified,
682
- tags: d.tags.slice(0, 5),
683
- }));
684
- res.json({ topic: topic ?? 'all', total: filtered.length, recentlyEvolved: evolved });
685
- }
686
- catch (err) {
687
- console.error(err);
688
- res.status(500).json({ error: 'Internal server error' });
689
- }
690
- });
691
- // GET /api/duplicates — 중복 노트 탐지
692
- app.get('/api/duplicates', async (req, res) => {
693
- try {
694
- const threshold = parseFloat(String(req.query.threshold ?? '0.88'));
695
- const pairs = await detectDuplicates(store, threshold, 20);
696
- res.json({ pairs, count: pairs.length, threshold });
697
- }
698
- catch (err) {
699
- console.error(err);
700
- res.status(500).json({ error: 'Internal server error' });
701
- }
702
- });
703
- // GET /api/gaps — 지식 갭 탐지
704
- app.get('/api/gaps', async (_req, res) => {
705
- try {
706
- const report = await detectKnowledgeGaps(store);
707
- res.json(report);
708
- }
709
- catch (err) {
710
- console.error(err);
711
- res.status(500).json({ error: 'Internal server error' });
712
- }
713
- });
714
- // POST /api/duplicates/merge — 중복 노트 자동 병합
715
- app.post('/api/duplicates/merge', async (req, res) => {
716
- try {
717
- const { docAId, docBId } = req.body;
718
- if (!docAId || !docBId) {
719
- res.status(400).json({ error: 'docAId, docBId required' });
720
- return;
721
- }
722
- const docA = await store.getDocument(docAId);
723
- const docB = await store.getDocument(docBId);
724
- if (!docA || !docB) {
725
- res.status(404).json({ error: 'Document not found' });
726
- return;
727
- }
728
- const { readFileSync, writeFileSync, unlinkSync } = await import('node:fs');
729
- const { join, resolve, relative } = await import('node:path');
730
- // 긴 노트를 기준으로 유지, 짧은 노트의 고유 내용을 추가
731
- const [keeper, removed] = docA.content.length >= docB.content.length
732
- ? [docA, docB] : [docB, docA];
733
- // HIGH-02: Path Traversal 방어 — vault 외부 접근 차단
734
- const keeperPath = resolve(join(vaultPath, keeper.filePath));
735
- const removedPath = resolve(join(vaultPath, removed.filePath));
736
- const vaultRoot = resolve(vaultPath);
737
- if (!keeperPath.startsWith(vaultRoot) || !removedPath.startsWith(vaultRoot)) {
738
- res.status(400).json({ error: 'Invalid file path' });
739
- return;
740
- }
741
- // 병합: keeper 끝에 removed 고유 내용 추가
742
- const keeperContent = readFileSync(keeperPath, 'utf-8');
743
- const appendix = `\n\n---\n\n> Merged from: ${removed.title} (${removed.filePath})\n\n${removed.content}`;
744
- writeFileSync(keeperPath, keeperContent + appendix, 'utf-8');
745
- // 삭제
746
- try {
747
- unlinkSync(removedPath);
748
- }
749
- catch { /* 이미 없을 수 있음 */ }
750
- // DB에서도 삭제
751
- await store.deleteByDocumentId(removed.id);
752
- res.json({
753
- success: true,
754
- kept: { id: keeper.id, title: keeper.title },
755
- removed: { id: removed.id, title: removed.title },
756
- });
757
- }
758
- catch (err) {
759
- console.error(err);
760
- res.status(500).json({ error: 'Merge failed' });
761
- }
762
- });
763
- // POST /api/gaps/create-bridge — 갭 브릿지 노트 자동 생성
764
- app.post('/api/gaps/create-bridge', async (req, res) => {
765
- try {
766
- const { clusterA, clusterB } = req.body;
767
- if (!clusterA || !clusterB) {
768
- res.status(400).json({ error: 'clusterA, clusterB required' });
769
- return;
770
- }
771
- const { writeFileSync, mkdirSync } = await import('node:fs');
772
- const { join } = await import('node:path');
773
- const nameA = clusterA.replace(/\s*\(\d+\)$/, '');
774
- const nameB = clusterB.replace(/\s*\(\d+\)$/, '');
775
- // 양쪽 클러스터의 대표 노트 검색
776
- const resultsA = await searchEngine.search({ query: nameA, limit: 3 });
777
- const resultsB = await searchEngine.search({ query: nameB, limit: 3 });
778
- const refsA = resultsA.map(r => `- [[${r.document.title}]]: ${r.document.content.slice(0, 100).replace(/\n/g, ' ')}...`).join('\n');
779
- const refsB = resultsB.map(r => `- [[${r.document.title}]]: ${r.document.content.slice(0, 100).replace(/\n/g, ' ')}...`).join('\n');
780
- const title = `${nameA} × ${nameB}`;
781
- const date = new Date().toISOString().slice(0, 10);
782
- const content = [
783
- '---',
784
- `title: "${title}"`,
785
- `created: ${date}`,
786
- 'tags: [bridge, auto-generated]',
787
- '---',
788
- '',
789
- `# ${title}`,
790
- '',
791
- `> 이 노트는 지식 갭 탐지기에 의해 자동 생성되었습니다.`,
792
- `> ${nameA}와 ${nameB} 사이의 연결 지식을 정리하세요.`,
793
- '',
794
- `## ${nameA} 핵심 노트`,
795
- '',
796
- refsA || '- (관련 노트 없음)',
797
- '',
798
- `## ${nameB} 핵심 노트`,
799
- '',
800
- refsB || '- (관련 노트 없음)',
801
- '',
802
- '## 연결 포인트',
803
- '',
804
- `${nameA}와 ${nameB}의 관계:`,
805
- '',
806
- '- ',
807
- '',
808
- '## 메모',
809
- '',
810
- '',
811
- ].join('\n');
812
- const safeTitle = title.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ');
813
- const { resolve } = await import('node:path');
814
- const dir = resolve(join(vaultPath, '01_Knowledge'));
815
- if (!dir.startsWith(resolve(vaultPath))) {
816
- res.status(400).json({ error: 'Invalid path' });
817
- return;
818
- }
819
- mkdirSync(dir, { recursive: true });
820
- const filePath = join(dir, `${safeTitle}.md`);
821
- writeFileSync(filePath, content, 'utf-8');
822
- res.json({ success: true, title: safeTitle, path: filePath });
823
- }
824
- catch (err) {
825
- console.error(err);
826
- res.status(500).json({ error: 'Bridge creation failed' });
827
- }
828
- });
829
- // GET /api/health — 종합 건강도 대시보드
830
- app.get('/api/health', async (_req, res) => {
831
- try {
832
- const stats = await store.getStats();
833
- const docs = await store.getAllDocuments();
834
- // Decay 요약
835
- let decaySummary = { totalDocuments: 0, criticalCount: 0, decayingCount: 0, averageR: 1.0, topDecaying: [] };
836
- if (decayEngine) {
837
- const report = await decayEngine.computeAll();
838
- decaySummary = {
839
- totalDocuments: report.totalDocuments ?? docs.length,
840
- criticalCount: report.criticalCount ?? 0,
841
- decayingCount: report.decayingCount ?? 0,
842
- averageR: report.averageR ?? 1.0,
843
- topDecaying: (report.topDecaying ?? []).slice(0, 5),
844
- };
845
- }
846
- // Gaps 요약
847
- let gapSummary = { gapCount: 0, isolatedCount: 0 };
848
- try {
849
- const gapReport = await detectKnowledgeGaps(store);
850
- gapSummary = {
851
- gapCount: gapReport.gaps?.length ?? 0,
852
- isolatedCount: gapReport.isolatedNodes?.length ?? 0,
853
- };
854
- }
855
- catch { /* gaps may fail if no embeddings */ }
856
- // Duplicates 요약
857
- let dupCount = 0;
858
- try {
859
- const pairs = await detectDuplicates(store, 0.88, 50);
860
- dupCount = pairs.length;
861
- }
862
- catch { /* duplicates may fail */ }
863
- // Source/Type 분포
864
- const sourceDist = new Map();
865
- const typeDist = new Map();
866
- for (const doc of docs) {
867
- const s = doc.source ?? 'local';
868
- const t = doc.type ?? 'note';
869
- sourceDist.set(s, (sourceDist.get(s) ?? 0) + 1);
870
- typeDist.set(t, (typeDist.get(t) ?? 0) + 1);
871
- }
872
- // 시간별 문서 증가 (월별)
873
- const monthlyGrowth = new Map();
874
- for (const doc of docs) {
875
- const month = doc.lastModified?.slice(0, 7) ?? 'unknown';
876
- monthlyGrowth.set(month, (monthlyGrowth.get(month) ?? 0) + 1);
877
- }
878
- res.json({
879
- stats: { ...stats, vaultName },
880
- decay: decaySummary,
881
- gaps: gapSummary,
882
- duplicates: { count: dupCount },
883
- distribution: {
884
- source: Object.fromEntries(sourceDist),
885
- type: Object.fromEntries(typeDist),
886
- },
887
- growth: Object.fromEntries([...monthlyGrowth.entries()].sort((a, b) => a[0].localeCompare(b[0]))),
888
- });
889
- }
890
- catch (err) {
891
- console.error(err);
892
- res.status(500).json({ error: 'Internal server error' });
893
- }
894
- });
895
- // GET /api/profile — Knowledge Profile summary (F-A09)
896
- app.get('/api/profile', async (_req, res) => {
897
- try {
898
- const stats = await store.getStats();
899
- const topics = await store.getTopics();
900
- const docs = await store.getAllDocuments();
901
- let decaySummary = { averageR: 1.0, criticalCount: 0, healthScore: 100 };
902
- if (decayEngine) {
903
- const report = await decayEngine.computeAll();
904
- const avgR = report.averageR ?? 1.0;
905
- decaySummary = {
906
- averageR: avgR,
907
- criticalCount: report.criticalCount ?? 0,
908
- healthScore: Math.round(avgR * 100),
909
- };
910
- }
911
- // Source/Type distribution
912
- const sourceDist = {};
913
- const typeDist = {};
914
- for (const doc of docs) {
915
- const s = doc.source ?? 'local';
916
- const t = doc.type ?? 'note';
917
- sourceDist[s] = (sourceDist[s] ?? 0) + 1;
918
- typeDist[t] = (typeDist[t] ?? 0) + 1;
919
- }
920
- // Activity: docs per month (last 12)
921
- const monthlyActivity = {};
922
- for (const doc of docs) {
923
- const month = doc.lastModified?.slice(0, 7);
924
- if (month)
925
- monthlyActivity[month] = (monthlyActivity[month] ?? 0) + 1;
926
- }
927
- res.setHeader('Access-Control-Allow-Origin', '*');
928
- res.json({
929
- name: vaultName || 'Knowledge Vault',
930
- stats: {
931
- documents: stats.documentCount,
932
- chunks: stats.chunkCount,
933
- topics: topics.length,
934
- },
935
- healthScore: decaySummary.healthScore,
936
- topTopics: topics.slice(0, 15).map(t => ({ name: t.topic, count: t.count })),
937
- distribution: { source: sourceDist, type: typeDist },
938
- activity: Object.fromEntries(Object.entries(monthlyActivity).sort((a, b) => a[0].localeCompare(b[0])).slice(-12)),
939
- generatedAt: new Date().toISOString(),
940
- });
941
- }
942
- catch (err) {
943
- console.error(err);
944
- res.status(500).json({ error: 'Internal server error' });
945
- }
946
- });
947
- // GET /api/embed — 임베드용 경량 그래프 데이터 (F-A08)
948
- app.get('/api/embed', async (req, res) => {
949
- try {
950
- const mode = req.query.mode === 'folder' ? 'folder' : 'semantic';
951
- const maxNodes = Math.min(parseInt(String(req.query.max ?? '200'), 10), 500);
952
- if (!graphCaches.has(mode)) {
953
- const data = await buildGraphData(store, { mode });
954
- graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
955
- }
956
- const cached = graphCaches.get(mode);
957
- const { nodes, edges, clusters } = cached.data;
958
- const connCount = new Map();
959
- for (const e of edges) {
960
- connCount.set(e.source, (connCount.get(e.source) ?? 0) + 1);
961
- connCount.set(e.target, (connCount.get(e.target) ?? 0) + 1);
962
- }
963
- const sortedNodes = [...nodes].sort((a, b) => (connCount.get(b.id) ?? 0) - (connCount.get(a.id) ?? 0));
964
- const selectedNodes = sortedNodes.slice(0, maxNodes);
965
- const selectedIds = new Set(selectedNodes.map(n => n.id));
966
- const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target));
967
- const embedNodes = selectedNodes.map((n, i) => {
968
- const angle = (i / selectedNodes.length) * Math.PI * 2;
969
- const r = 100 + n.clusterId * 15;
970
- return {
971
- id: n.id, label: n.label, clusterId: n.clusterId, size: n.size,
972
- position: [
973
- r * Math.cos(angle) + (Math.random() - 0.5) * 60,
974
- (Math.random() - 0.5) * 200,
975
- r * Math.sin(angle) + (Math.random() - 0.5) * 60,
976
- ],
977
- };
978
- });
979
- res.setHeader('Access-Control-Allow-Origin', '*');
980
- res.json({
981
- nodes: embedNodes, edges: selectedEdges,
982
- stats: { nodeCount: embedNodes.length, edgeCount: selectedEdges.length, clusterCount: clusters.length, totalNodes: nodes.length },
983
- title: vaultName || 'Knowledge Graph',
984
- });
985
- }
986
- catch (err) {
987
- console.error(err);
988
- res.status(500).json({ error: 'Internal server error' });
989
- }
990
- });
991
- // Sync 상태 추적
992
- let syncState = {
993
- running: false, startedAt: '', completedAt: '', result: '', output: '',
994
- };
995
- // POST /api/sync — Notion → Obsidian 동기화 트리거
996
- app.post('/api/sync', async (_req, res) => {
997
- if (syncState.running) {
998
- res.json({ success: false, error: 'Sync already running', state: syncState });
999
- return;
1000
- }
1001
- try {
1002
- const { spawn } = await import('node:child_process');
1003
- const { resolve } = await import('node:path');
1004
- const syncScript = resolve(process.cwd(), 'packages/sync/sync-to-obsidian.mjs');
1005
- const syncDir = resolve(process.cwd(), 'packages/sync');
1006
- const { existsSync } = await import('node:fs');
1007
- if (!existsSync(syncScript)) {
1008
- res.json({ success: false, error: 'sync script not found' });
1009
- return;
1010
- }
1011
- if (!existsSync(resolve(syncDir, '.env'))) {
1012
- res.json({ success: false, error: '.env not found' });
1013
- return;
1014
- }
1015
- syncState = { running: true, startedAt: new Date().toISOString(), completedAt: '', result: '', output: '' };
1016
- const child = spawn('node', [syncScript], { cwd: syncDir, stdio: ['ignore', 'pipe', 'pipe'], shell: true });
1017
- let output = '';
1018
- child.stdout.on('data', (d) => { output += d.toString(); });
1019
- child.stderr.on('data', (d) => { output += d.toString(); });
1020
- child.on('close', (code) => {
1021
- syncState.running = false;
1022
- syncState.completedAt = new Date().toISOString();
1023
- syncState.result = code === 0 ? 'success' : 'failed';
1024
- syncState.output = output.slice(-500); // 마지막 500자만
1025
- });
1026
- res.json({ success: true, message: 'Sync started' });
1027
- }
1028
- catch (err) {
1029
- syncState.running = false;
1030
- console.error(err);
1031
- res.status(500).json({ error: 'Sync failed' });
1032
- }
1033
- });
1034
- // GET /api/sync/status — 동기화 상태 조회
1035
- app.get('/api/sync/status', (_req, res) => {
1036
- res.json(syncState);
1037
- });
1038
- // POST /api/clip — 웹 페이지 클리핑
1039
- app.post('/api/clip', async (req, res) => {
1040
- try {
1041
- const { url } = req.body;
1042
- if (!url) {
1043
- res.status(400).json({ error: 'url required' });
1044
- return;
1045
- }
1046
- // HIGH-03: SSRF 방어 — 내부 네트워크 차단
1047
- try {
1048
- const parsed = new URL(url);
1049
- if (!['http:', 'https:'].includes(parsed.protocol)) {
1050
- res.status(400).json({ error: 'Only http/https URLs allowed' });
1051
- return;
1052
- }
1053
- const host = parsed.hostname.toLowerCase();
1054
- if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local') || host.startsWith('192.168.') || host.startsWith('10.') || host.startsWith('172.16.')) {
1055
- res.status(400).json({ error: 'Internal URLs not allowed' });
1056
- return;
1057
- }
1058
- }
1059
- catch {
1060
- res.status(400).json({ error: 'Invalid URL' });
1061
- return;
1062
- }
1063
- const isYT = /youtube\.com\/watch|youtu\.be\//.test(url);
1064
- const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 stellavault-clipper/1.0' }, signal: AbortSignal.timeout(15000) });
1065
- const html = await response.text();
1066
- // 제목 추출
1067
- const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
1068
- let title = (titleMatch ? titleMatch[1] : new URL(url).hostname).replace(/ - YouTube$/, '').trim();
1069
- const safeTitle = title.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80);
1070
- let content;
1071
- if (isYT) {
1072
- const videoId = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]+)/)?.[1] ?? '';
1073
- const descMatch = html.match(/"shortDescription":"([\s\S]*?)"/);
1074
- const desc = descMatch ? descMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').slice(0, 3000) : '';
1075
- content = `![thumbnail](https://img.youtube.com/vi/${videoId}/maxresdefault.jpg)\n\n## 설명\n\n${desc}\n\n[YouTube](${url})`;
1076
- }
1077
- else {
1078
- content = html
1079
- .replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
1080
- .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n').replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n')
1081
- .replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n')
1082
- .replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n').replace(/<br\s*\/?>/gi, '\n')
1083
- .replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
1084
- .replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n')
1085
- .replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**')
1086
- .replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*')
1087
- .replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
1088
- .replace(/<[^>]+>/g, '')
1089
- .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
1090
- .replace(/\n{3,}/g, '\n\n').trim();
1091
- if (content.length > 10000)
1092
- content = content.slice(0, 10000) + '\n\n...(truncated)';
1093
- }
1094
- // vault에 저장
1095
- const { writeFileSync, mkdirSync } = await import('node:fs');
1096
- const { join } = await import('node:path');
1097
- const date = new Date().toISOString().slice(0, 10);
1098
- const clipDir = join(vaultPath || '.', '06_Research', 'clips');
1099
- mkdirSync(clipDir, { recursive: true });
1100
- const fileName = `${date} ${safeTitle}.md`;
1101
- const md = `---\ntitle: "${safeTitle}"\nsource: "${url}"\nclipped: ${date}\ntags: [clip${isYT ? ', youtube' : ''}]\n---\n\n# ${safeTitle}\n\n> Source: ${url}\n\n${content}`;
1102
- writeFileSync(join(clipDir, fileName), md, 'utf-8');
1103
- res.json({ success: true, fileName, path: join(clipDir, fileName) });
1104
- }
1105
- catch (err) {
1106
- console.error(err);
1107
- res.status(500).json({ error: 'Clip failed' });
1108
- }
1109
- });
1110
- return {
1111
- async start() {
1112
- return new Promise((resolve) => {
1113
- app.listen(port, '127.0.0.1', () => {
1114
- console.error(`🌐 API server running at http://127.0.0.1:${port}`);
1115
- resolve();
1116
- });
1117
- });
1118
- },
1119
- app,
1120
- };
1121
- }
1122
- //# sourceMappingURL=server.js.map