stellavault 0.1.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 (294) hide show
  1. package/.env.example +12 -0
  2. package/CLAUDE.md +39 -0
  3. package/CONTRIBUTING.md +65 -0
  4. package/LICENSE +21 -0
  5. package/README.md +182 -0
  6. package/memory/MEMORY.md +25 -0
  7. package/package.json +33 -0
  8. package/packages/cli/bin/ekh.js +2 -0
  9. package/packages/cli/bin/stellavault.js +2 -0
  10. package/packages/cli/dist/commands/brief-cmd.d.ts +2 -0
  11. package/packages/cli/dist/commands/brief-cmd.d.ts.map +1 -0
  12. package/packages/cli/dist/commands/brief-cmd.js +82 -0
  13. package/packages/cli/dist/commands/brief-cmd.js.map +1 -0
  14. package/packages/cli/dist/commands/capture-cmd.d.ts +7 -0
  15. package/packages/cli/dist/commands/capture-cmd.d.ts.map +1 -0
  16. package/packages/cli/dist/commands/capture-cmd.js +31 -0
  17. package/packages/cli/dist/commands/capture-cmd.js.map +1 -0
  18. package/packages/cli/dist/commands/card-cmd.d.ts +4 -0
  19. package/packages/cli/dist/commands/card-cmd.d.ts.map +1 -0
  20. package/packages/cli/dist/commands/card-cmd.js +26 -0
  21. package/packages/cli/dist/commands/card-cmd.js.map +1 -0
  22. package/packages/cli/dist/commands/clip-cmd.d.ts +4 -0
  23. package/packages/cli/dist/commands/clip-cmd.d.ts.map +1 -0
  24. package/packages/cli/dist/commands/clip-cmd.js +151 -0
  25. package/packages/cli/dist/commands/clip-cmd.js.map +1 -0
  26. package/packages/cli/dist/commands/cloud-cmd.d.ts +4 -0
  27. package/packages/cli/dist/commands/cloud-cmd.d.ts.map +1 -0
  28. package/packages/cli/dist/commands/cloud-cmd.js +64 -0
  29. package/packages/cli/dist/commands/cloud-cmd.js.map +1 -0
  30. package/packages/cli/dist/commands/contradictions-cmd.d.ts +2 -0
  31. package/packages/cli/dist/commands/contradictions-cmd.d.ts.map +1 -0
  32. package/packages/cli/dist/commands/contradictions-cmd.js +34 -0
  33. package/packages/cli/dist/commands/contradictions-cmd.js.map +1 -0
  34. package/packages/cli/dist/commands/decay-cmd.d.ts +2 -0
  35. package/packages/cli/dist/commands/decay-cmd.d.ts.map +1 -0
  36. package/packages/cli/dist/commands/decay-cmd.js +48 -0
  37. package/packages/cli/dist/commands/decay-cmd.js.map +1 -0
  38. package/packages/cli/dist/commands/digest-cmd.d.ts +4 -0
  39. package/packages/cli/dist/commands/digest-cmd.d.ts.map +1 -0
  40. package/packages/cli/dist/commands/digest-cmd.js +79 -0
  41. package/packages/cli/dist/commands/digest-cmd.js.map +1 -0
  42. package/packages/cli/dist/commands/duplicates-cmd.d.ts +4 -0
  43. package/packages/cli/dist/commands/duplicates-cmd.d.ts.map +1 -0
  44. package/packages/cli/dist/commands/duplicates-cmd.js +30 -0
  45. package/packages/cli/dist/commands/duplicates-cmd.js.map +1 -0
  46. package/packages/cli/dist/commands/federate-cmd.d.ts +5 -0
  47. package/packages/cli/dist/commands/federate-cmd.d.ts.map +1 -0
  48. package/packages/cli/dist/commands/federate-cmd.js +217 -0
  49. package/packages/cli/dist/commands/federate-cmd.js.map +1 -0
  50. package/packages/cli/dist/commands/gaps-cmd.d.ts +2 -0
  51. package/packages/cli/dist/commands/gaps-cmd.d.ts.map +1 -0
  52. package/packages/cli/dist/commands/gaps-cmd.js +33 -0
  53. package/packages/cli/dist/commands/gaps-cmd.js.map +1 -0
  54. package/packages/cli/dist/commands/graph-cmd.d.ts +2 -0
  55. package/packages/cli/dist/commands/graph-cmd.d.ts.map +1 -0
  56. package/packages/cli/dist/commands/graph-cmd.js +77 -0
  57. package/packages/cli/dist/commands/graph-cmd.js.map +1 -0
  58. package/packages/cli/dist/commands/index-cmd.d.ts +2 -0
  59. package/packages/cli/dist/commands/index-cmd.d.ts.map +1 -0
  60. package/packages/cli/dist/commands/index-cmd.js +57 -0
  61. package/packages/cli/dist/commands/index-cmd.js.map +1 -0
  62. package/packages/cli/dist/commands/init-cmd.d.ts +2 -0
  63. package/packages/cli/dist/commands/init-cmd.d.ts.map +1 -0
  64. package/packages/cli/dist/commands/init-cmd.js +123 -0
  65. package/packages/cli/dist/commands/init-cmd.js.map +1 -0
  66. package/packages/cli/dist/commands/learn-cmd.d.ts +2 -0
  67. package/packages/cli/dist/commands/learn-cmd.d.ts.map +1 -0
  68. package/packages/cli/dist/commands/learn-cmd.js +48 -0
  69. package/packages/cli/dist/commands/learn-cmd.js.map +1 -0
  70. package/packages/cli/dist/commands/pack-cmd.d.ts +15 -0
  71. package/packages/cli/dist/commands/pack-cmd.d.ts.map +1 -0
  72. package/packages/cli/dist/commands/pack-cmd.js +93 -0
  73. package/packages/cli/dist/commands/pack-cmd.js.map +1 -0
  74. package/packages/cli/dist/commands/review-cmd.d.ts +4 -0
  75. package/packages/cli/dist/commands/review-cmd.d.ts.map +1 -0
  76. package/packages/cli/dist/commands/review-cmd.js +107 -0
  77. package/packages/cli/dist/commands/review-cmd.js.map +1 -0
  78. package/packages/cli/dist/commands/search-cmd.d.ts +4 -0
  79. package/packages/cli/dist/commands/search-cmd.d.ts.map +1 -0
  80. package/packages/cli/dist/commands/search-cmd.js +38 -0
  81. package/packages/cli/dist/commands/search-cmd.js.map +1 -0
  82. package/packages/cli/dist/commands/serve-cmd.d.ts +2 -0
  83. package/packages/cli/dist/commands/serve-cmd.d.ts.map +1 -0
  84. package/packages/cli/dist/commands/serve-cmd.js +14 -0
  85. package/packages/cli/dist/commands/serve-cmd.js.map +1 -0
  86. package/packages/cli/dist/commands/status-cmd.d.ts +2 -0
  87. package/packages/cli/dist/commands/status-cmd.d.ts.map +1 -0
  88. package/packages/cli/dist/commands/status-cmd.js +33 -0
  89. package/packages/cli/dist/commands/status-cmd.js.map +1 -0
  90. package/packages/cli/dist/commands/sync-cmd.d.ts +5 -0
  91. package/packages/cli/dist/commands/sync-cmd.d.ts.map +1 -0
  92. package/packages/cli/dist/commands/sync-cmd.js +62 -0
  93. package/packages/cli/dist/commands/sync-cmd.js.map +1 -0
  94. package/packages/cli/dist/commands/vault-cmd.d.ts +10 -0
  95. package/packages/cli/dist/commands/vault-cmd.d.ts.map +1 -0
  96. package/packages/cli/dist/commands/vault-cmd.js +54 -0
  97. package/packages/cli/dist/commands/vault-cmd.js.map +1 -0
  98. package/packages/cli/dist/index.d.ts +2 -0
  99. package/packages/cli/dist/index.d.ts.map +1 -0
  100. package/packages/cli/dist/index.js +156 -0
  101. package/packages/cli/dist/index.js.map +1 -0
  102. package/packages/cli/package.json +24 -0
  103. package/packages/cli/src/commands/brief-cmd.ts +87 -0
  104. package/packages/cli/src/commands/capture-cmd.ts +34 -0
  105. package/packages/cli/src/commands/card-cmd.ts +29 -0
  106. package/packages/cli/src/commands/clip-cmd.ts +172 -0
  107. package/packages/cli/src/commands/cloud-cmd.ts +75 -0
  108. package/packages/cli/src/commands/contradictions-cmd.ts +41 -0
  109. package/packages/cli/src/commands/decay-cmd.ts +57 -0
  110. package/packages/cli/src/commands/digest-cmd.ts +89 -0
  111. package/packages/cli/src/commands/duplicates-cmd.ts +38 -0
  112. package/packages/cli/src/commands/federate-cmd.ts +236 -0
  113. package/packages/cli/src/commands/gaps-cmd.ts +40 -0
  114. package/packages/cli/src/commands/graph-cmd.ts +88 -0
  115. package/packages/cli/src/commands/index-cmd.ts +65 -0
  116. package/packages/cli/src/commands/init-cmd.ts +145 -0
  117. package/packages/cli/src/commands/learn-cmd.ts +56 -0
  118. package/packages/cli/src/commands/pack-cmd.ts +121 -0
  119. package/packages/cli/src/commands/review-cmd.ts +125 -0
  120. package/packages/cli/src/commands/search-cmd.ts +45 -0
  121. package/packages/cli/src/commands/serve-cmd.ts +17 -0
  122. package/packages/cli/src/commands/status-cmd.ts +37 -0
  123. package/packages/cli/src/commands/sync-cmd.ts +68 -0
  124. package/packages/cli/src/commands/vault-cmd.ts +64 -0
  125. package/packages/cli/src/index.ts +187 -0
  126. package/packages/core/package.json +40 -0
  127. package/packages/core/src/api/dashboard.ts +138 -0
  128. package/packages/core/src/api/graph-data.ts +286 -0
  129. package/packages/core/src/api/pwa.ts +82 -0
  130. package/packages/core/src/api/server.ts +660 -0
  131. package/packages/core/src/capture/voice.ts +168 -0
  132. package/packages/core/src/cloud/index.ts +2 -0
  133. package/packages/core/src/cloud/sync.ts +167 -0
  134. package/packages/core/src/config.ts +82 -0
  135. package/packages/core/src/federation/credits.ts +80 -0
  136. package/packages/core/src/federation/hyperswarm.d.ts +19 -0
  137. package/packages/core/src/federation/identity.ts +90 -0
  138. package/packages/core/src/federation/index.ts +8 -0
  139. package/packages/core/src/federation/node.ts +235 -0
  140. package/packages/core/src/federation/privacy.ts +52 -0
  141. package/packages/core/src/federation/reputation.ts +202 -0
  142. package/packages/core/src/federation/search.ts +129 -0
  143. package/packages/core/src/federation/sharing.ts +165 -0
  144. package/packages/core/src/federation/trust.ts +76 -0
  145. package/packages/core/src/federation/types.ts +25 -0
  146. package/packages/core/src/i18n/index.ts +85 -0
  147. package/packages/core/src/index.ts +133 -0
  148. package/packages/core/src/indexer/chunker.ts +180 -0
  149. package/packages/core/src/indexer/embedder.ts +9 -0
  150. package/packages/core/src/indexer/index.ts +113 -0
  151. package/packages/core/src/indexer/local-embedder.ts +35 -0
  152. package/packages/core/src/indexer/scanner.ts +142 -0
  153. package/packages/core/src/indexer/watcher.ts +62 -0
  154. package/packages/core/src/intelligence/contradiction-detector.ts +134 -0
  155. package/packages/core/src/intelligence/decay-engine.ts +229 -0
  156. package/packages/core/src/intelligence/duplicate-detector.ts +71 -0
  157. package/packages/core/src/intelligence/fsrs.ts +79 -0
  158. package/packages/core/src/intelligence/gap-detector.ts +109 -0
  159. package/packages/core/src/intelligence/learning-path.ts +86 -0
  160. package/packages/core/src/intelligence/notifications.ts +106 -0
  161. package/packages/core/src/intelligence/predictive-gaps.ts +94 -0
  162. package/packages/core/src/intelligence/semantic-versioning.ts +97 -0
  163. package/packages/core/src/intelligence/types.ts +28 -0
  164. package/packages/core/src/mcp/custom-tools.ts +97 -0
  165. package/packages/core/src/mcp/index.ts +1 -0
  166. package/packages/core/src/mcp/server.ts +142 -0
  167. package/packages/core/src/mcp/tools/agentic-graph.ts +96 -0
  168. package/packages/core/src/mcp/tools/brief.ts +49 -0
  169. package/packages/core/src/mcp/tools/decay.ts +40 -0
  170. package/packages/core/src/mcp/tools/decision-journal.ts +95 -0
  171. package/packages/core/src/mcp/tools/export.ts +72 -0
  172. package/packages/core/src/mcp/tools/federated-search.ts +43 -0
  173. package/packages/core/src/mcp/tools/generate-claude-md.ts +130 -0
  174. package/packages/core/src/mcp/tools/get-document.ts +26 -0
  175. package/packages/core/src/mcp/tools/get-related.ts +41 -0
  176. package/packages/core/src/mcp/tools/learning-path.ts +52 -0
  177. package/packages/core/src/mcp/tools/list-topics.ts +20 -0
  178. package/packages/core/src/mcp/tools/search.ts +35 -0
  179. package/packages/core/src/mcp/tools/snapshot.ts +98 -0
  180. package/packages/core/src/multi-vault/index.ts +118 -0
  181. package/packages/core/src/pack/creator.ts +127 -0
  182. package/packages/core/src/pack/exporter.ts +21 -0
  183. package/packages/core/src/pack/importer.ts +82 -0
  184. package/packages/core/src/pack/index.ts +5 -0
  185. package/packages/core/src/pack/marketplace.ts +103 -0
  186. package/packages/core/src/pack/pii-masker.ts +38 -0
  187. package/packages/core/src/pack/types.ts +39 -0
  188. package/packages/core/src/plugins/index.ts +100 -0
  189. package/packages/core/src/plugins/webhooks.ts +110 -0
  190. package/packages/core/src/search/bm25.ts +16 -0
  191. package/packages/core/src/search/index.ts +83 -0
  192. package/packages/core/src/search/rrf.ts +31 -0
  193. package/packages/core/src/search/semantic.ts +15 -0
  194. package/packages/core/src/store/index.ts +2 -0
  195. package/packages/core/src/store/sqlite-vec.ts +290 -0
  196. package/packages/core/src/store/types.ts +22 -0
  197. package/packages/core/src/team/index.ts +126 -0
  198. package/packages/core/src/types/chunk.ts +25 -0
  199. package/packages/core/src/types/document.ts +24 -0
  200. package/packages/core/src/types/graph.ts +44 -0
  201. package/packages/core/src/types/index.ts +15 -0
  202. package/packages/core/src/types/search.ts +38 -0
  203. package/packages/core/src/utils/retry.ts +85 -0
  204. package/packages/core/tests/api-card.test.ts +60 -0
  205. package/packages/core/tests/api-routes.test.ts +98 -0
  206. package/packages/core/tests/bm25.test.ts +87 -0
  207. package/packages/core/tests/chunker.test.ts +48 -0
  208. package/packages/core/tests/cluster.test.ts +75 -0
  209. package/packages/core/tests/constellation.test.ts +77 -0
  210. package/packages/core/tests/export-utils.test.ts +97 -0
  211. package/packages/core/tests/fsrs.test.ts +96 -0
  212. package/packages/core/tests/gesture-detector.test.ts +45 -0
  213. package/packages/core/tests/graph-data.test.ts +87 -0
  214. package/packages/core/tests/layout.test.ts +83 -0
  215. package/packages/core/tests/mcp.test.ts +148 -0
  216. package/packages/core/tests/pack.test.ts +127 -0
  217. package/packages/core/tests/pii-masker.test.ts +42 -0
  218. package/packages/core/tests/profile-card.test.ts +62 -0
  219. package/packages/core/tests/rrf.test.ts +29 -0
  220. package/packages/core/tests/search-integration.test.ts +139 -0
  221. package/packages/core/tests/store.test.ts +80 -0
  222. package/packages/graph/click-result.png +0 -0
  223. package/packages/graph/index.html +17 -0
  224. package/packages/graph/package.json +32 -0
  225. package/packages/graph/src/App.tsx +7 -0
  226. package/packages/graph/src/api/client.ts +39 -0
  227. package/packages/graph/src/components/ClusterFilter.tsx +73 -0
  228. package/packages/graph/src/components/ConstellationView.tsx +232 -0
  229. package/packages/graph/src/components/ExportPanel.tsx +177 -0
  230. package/packages/graph/src/components/Graph3D.tsx +230 -0
  231. package/packages/graph/src/components/GraphEdges.tsx +100 -0
  232. package/packages/graph/src/components/GraphNodes.tsx +386 -0
  233. package/packages/graph/src/components/HealthDashboard.tsx +173 -0
  234. package/packages/graph/src/components/Layout.tsx +214 -0
  235. package/packages/graph/src/components/MotionOverlay.tsx +81 -0
  236. package/packages/graph/src/components/MotionToggle.tsx +33 -0
  237. package/packages/graph/src/components/MultiverseView.tsx +286 -0
  238. package/packages/graph/src/components/NodeDetail.tsx +232 -0
  239. package/packages/graph/src/components/PulseParticle.tsx +232 -0
  240. package/packages/graph/src/components/SearchBar.tsx +107 -0
  241. package/packages/graph/src/components/StarField.tsx +197 -0
  242. package/packages/graph/src/components/StatusBar.tsx +53 -0
  243. package/packages/graph/src/components/Timeline.tsx +148 -0
  244. package/packages/graph/src/components/ToolsPanel.tsx +512 -0
  245. package/packages/graph/src/components/Tooltip.tsx +100 -0
  246. package/packages/graph/src/components/TypeFilter.tsx +131 -0
  247. package/packages/graph/src/embed/EmbedGraph.tsx +144 -0
  248. package/packages/graph/src/hooks/useConstellationLOD.ts +76 -0
  249. package/packages/graph/src/hooks/useDecay.ts +37 -0
  250. package/packages/graph/src/hooks/useExport.ts +165 -0
  251. package/packages/graph/src/hooks/useGraph.ts +69 -0
  252. package/packages/graph/src/hooks/useKeyboardNav.ts +122 -0
  253. package/packages/graph/src/hooks/useLayout.ts +45 -0
  254. package/packages/graph/src/hooks/useMotion.ts +120 -0
  255. package/packages/graph/src/hooks/usePulse.ts +58 -0
  256. package/packages/graph/src/hooks/useSearch.ts +71 -0
  257. package/packages/graph/src/lib/constellation.ts +107 -0
  258. package/packages/graph/src/lib/export-utils.ts +48 -0
  259. package/packages/graph/src/lib/gesture-detector.ts +123 -0
  260. package/packages/graph/src/lib/layout.worker.ts +153 -0
  261. package/packages/graph/src/lib/motion-controller.ts +83 -0
  262. package/packages/graph/src/lib/profile-card.ts +122 -0
  263. package/packages/graph/src/main.tsx +4 -0
  264. package/packages/graph/src/stores/graph-store.ts +155 -0
  265. package/packages/graph/success.png +0 -0
  266. package/packages/graph/test-click.mjs +49 -0
  267. package/packages/graph/test-explore.mjs +102 -0
  268. package/packages/graph/test-final.mjs +61 -0
  269. package/packages/graph/test-graph.mjs +139 -0
  270. package/packages/graph/test-hover.mjs +48 -0
  271. package/packages/graph/test-pulse.mjs +68 -0
  272. package/packages/graph/test-screenshot.mjs +56 -0
  273. package/packages/graph/test-v2.mjs +97 -0
  274. package/packages/graph/vite.config.ts +15 -0
  275. package/packages/sync/.env.example +11 -0
  276. package/packages/sync/.sync-state.json +317 -0
  277. package/packages/sync/.upload-state.json +1009 -0
  278. package/packages/sync/create-stella-network-notion.mjs +151 -0
  279. package/packages/sync/create-stellavault-project-notion.mjs +322 -0
  280. package/packages/sync/logs/sync-2026-03-28.log +6 -0
  281. package/packages/sync/logs/sync-2026-03-29.log +12 -0
  282. package/packages/sync/logs/sync-2026-03-30.log +6 -0
  283. package/packages/sync/logs/sync-2026-03-31.log +6 -0
  284. package/packages/sync/logs/sync-2026-04-01.log +6 -0
  285. package/packages/sync/logs/sync-2026-04-02.log +6 -0
  286. package/packages/sync/package-lock.json +373 -0
  287. package/packages/sync/package.json +16 -0
  288. package/packages/sync/run-sync.bat +18 -0
  289. package/packages/sync/run-sync.mjs +46 -0
  290. package/packages/sync/setup-scheduler.mjs +119 -0
  291. package/packages/sync/structured-sync.mjs +187 -0
  292. package/packages/sync/sync-to-obsidian.mjs +264 -0
  293. package/packages/sync/upload-pdca-to-notion.mjs +495 -0
  294. package/tsconfig.base.json +18 -0
@@ -0,0 +1,660 @@
1
+ // Design Ref: §4.1 — REST API (core/api/)
2
+ // Design Ref: §7 — Security: localhost only, CORS restricted
3
+
4
+ import express from 'express';
5
+ import cors from 'cors';
6
+ import type { VectorStore } from '../store/types.js';
7
+ import type { SearchEngine } from '../search/index.js';
8
+ import { buildGraphData, type BuildGraphOptions } from './graph-data.js';
9
+ import type { DecayEngine } from '../intelligence/decay-engine.js';
10
+ import { detectDuplicates } from '../intelligence/duplicate-detector.js';
11
+ import { detectKnowledgeGaps } from '../intelligence/gap-detector.js';
12
+
13
+ export interface ApiServerOptions {
14
+ store: VectorStore;
15
+ searchEngine: SearchEngine;
16
+ port?: number;
17
+ vaultName?: string;
18
+ vaultPath?: string;
19
+ decayEngine?: DecayEngine;
20
+ }
21
+
22
+ export function createApiServer(options: ApiServerOptions) {
23
+ const { store, searchEngine, port = 3333, vaultName = '', vaultPath = '', decayEngine } = options;
24
+ const app = express();
25
+
26
+ app.use(cors({ origin: ['http://localhost:5173', 'http://127.0.0.1:5173'] }));
27
+ app.use(express.json());
28
+
29
+ // GET /api/graph?mode=semantic|folder — 전체 그래프 데이터
30
+ const graphCaches = new Map<string, { data: any; generatedAt: string }>();
31
+
32
+ app.get('/api/graph', async (req, res) => {
33
+ try {
34
+ const mode = (req.query.mode as string) === 'folder' ? 'folder' : 'semantic';
35
+ if (!graphCaches.has(mode)) {
36
+ const data = await buildGraphData(store, { mode });
37
+ graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
38
+ }
39
+ const cached = graphCaches.get(mode)!;
40
+ res.json({ data: cached.data, generatedAt: cached.generatedAt, mode });
41
+ } catch (err) {
42
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
43
+ }
44
+ });
45
+
46
+ // GET /api/graph/refresh?mode= — 캐시 무효화 + 재생성
47
+ app.get('/api/graph/refresh', async (req, res) => {
48
+ try {
49
+ const mode = (req.query.mode as string) === 'folder' ? 'folder' : 'semantic';
50
+ const data = await buildGraphData(store, { mode });
51
+ graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
52
+ const cached = graphCaches.get(mode)!;
53
+ res.json({ data: cached.data, generatedAt: cached.generatedAt, mode });
54
+ } catch (err) {
55
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
56
+ }
57
+ });
58
+
59
+ // GET /api/search?q=&limit=
60
+ app.get('/api/search', async (req, res) => {
61
+ try {
62
+ const query = String(req.query.q || '');
63
+ const limit = parseInt(String(req.query.limit || '10'), 10);
64
+ if (!query) { res.json({ results: [], query: '' }); return; }
65
+
66
+ const results = await searchEngine.search({ query, limit });
67
+
68
+ // 검색 결과 문서에 대해 접근 이벤트 기록 (감쇠 리셋)
69
+ if (decayEngine) {
70
+ const now = new Date().toISOString();
71
+ for (const r of results) {
72
+ decayEngine.recordAccess({ documentId: r.document.id, type: 'search', timestamp: now }).catch(() => {});
73
+ }
74
+ }
75
+
76
+ res.json({
77
+ results: results.map(r => ({
78
+ documentId: r.document.id,
79
+ title: r.document.title,
80
+ score: Math.round(r.score * 1000) / 1000,
81
+ highlights: r.highlights,
82
+ })),
83
+ query,
84
+ });
85
+ } catch (err) {
86
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
87
+ }
88
+ });
89
+
90
+ // GET /api/document/:id
91
+ app.get('/api/document/:id', async (req, res) => {
92
+ try {
93
+ const doc = await store.getDocument(req.params.id);
94
+ if (!doc) { res.status(404).json({ error: 'Not found' }); return; }
95
+
96
+ // 접근 이벤트 기록 (감쇠 리셋)
97
+ if (decayEngine) {
98
+ decayEngine.recordAccess({ documentId: doc.id, type: 'view', timestamp: new Date().toISOString() }).catch(() => {});
99
+ }
100
+
101
+ // 관련 문서 (제목 기반 검색)
102
+ const related = await searchEngine.search({
103
+ query: doc.title,
104
+ limit: 6,
105
+ });
106
+
107
+ res.json({
108
+ id: doc.id,
109
+ title: doc.title,
110
+ filePath: doc.filePath,
111
+ content: doc.content,
112
+ tags: doc.tags,
113
+ lastModified: doc.lastModified,
114
+ related: related
115
+ .filter(r => r.document.id !== doc.id)
116
+ .slice(0, 5)
117
+ .map(r => ({ id: r.document.id, title: r.document.title, score: r.score })),
118
+ });
119
+ } catch (err) {
120
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
121
+ }
122
+ });
123
+
124
+ // GET /api/profile-card → SVG
125
+ app.get('/api/profile-card', async (_req, res) => {
126
+ try {
127
+ const mode = ((_req as any).query.mode as string) === 'folder' ? 'folder' : 'semantic';
128
+ if (!graphCaches.has(mode)) {
129
+ const data = await buildGraphData(store, { mode });
130
+ graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
131
+ }
132
+ const graphData = graphCaches.get(mode)!.data;
133
+ const topics = await store.getTopics();
134
+
135
+ // 동적 import (graph 패키지의 profile-card)
136
+ // 여기서는 간단히 SVG를 직접 생성
137
+ const stats = await store.getStats();
138
+ const top6 = graphData.clusters
139
+ .sort((a: any, b: any) => b.nodeCount - a.nodeCount)
140
+ .slice(0, 6);
141
+ const maxCount = Math.max(1, ...top6.map((c: any) => c.nodeCount));
142
+ const W = 800, H = 420;
143
+ const radarCx = 200, radarCy = 220, radarR = 100;
144
+
145
+ const radarPoints = top6.map((c: any, i: number) => {
146
+ const angle = (Math.PI * 2 * i) / top6.length - Math.PI / 2;
147
+ const r = radarR * (c.nodeCount / maxCount);
148
+ return {
149
+ x: radarCx + r * Math.cos(angle),
150
+ y: radarCy + r * Math.sin(angle),
151
+ lx: radarCx + (radarR + 20) * Math.cos(angle),
152
+ ly: radarCy + (radarR + 20) * Math.sin(angle),
153
+ label: c.label.split(',')[0].trim().slice(0, 12),
154
+ color: c.color,
155
+ };
156
+ });
157
+
158
+ const radarPath = radarPoints.map((p: any, i: number) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + 'Z';
159
+ const gridPaths = [0.33, 0.66, 1].map((s) =>
160
+ top6.map((_: any, i: number) => {
161
+ const a = (Math.PI * 2 * i) / top6.length - Math.PI / 2;
162
+ return `${i === 0 ? 'M' : 'L'}${radarCx + radarR * s * Math.cos(a)},${radarCy + radarR * s * Math.sin(a)}`;
163
+ }).join(' ') + 'Z'
164
+ );
165
+
166
+ const tags20 = topics.slice(0, 20);
167
+ const maxTag = Math.max(1, ...tags20.map((t: any) => t.count));
168
+ // HIGH-07: SVG injection 방어 — 모든 특수문자 이스케이프
169
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
170
+
171
+ const tagEls = tags20.map((t: any, i: number) => {
172
+ const sz = 10 + 14 * (t.count / maxTag);
173
+ const x = 480 + (Math.floor(i / 2) % 5) * 60;
174
+ const y = 140 + (i % 2) * 30 + Math.floor(i / 10) * 70;
175
+ const op = 0.5 + 0.5 * (t.count / maxTag);
176
+ return `<text x="${x}" y="${y}" font-size="${sz}" fill="#88aaff" opacity="${op}" font-family="monospace">#${esc(t.topic)}</text>`;
177
+ }).join('\n ');
178
+
179
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
180
+ <defs>
181
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
182
+ <stop offset="0%" stop-color="#0d1028"/><stop offset="100%" stop-color="#050510"/>
183
+ </linearGradient>
184
+ <linearGradient id="rf" x1="0%" y1="0%" x2="100%" y2="100%">
185
+ <stop offset="0%" stop-color="#6366f1" stop-opacity="0.3"/><stop offset="100%" stop-color="#06b6d4" stop-opacity="0.15"/>
186
+ </linearGradient>
187
+ </defs>
188
+ <rect width="${W}" height="${H}" rx="16" fill="url(#bg)"/>
189
+ <rect width="${W}" height="${H}" rx="16" fill="none" stroke="#6366f140"/>
190
+ <text x="30" y="40" font-size="20" font-weight="700" fill="#c0c0f0" font-family="system-ui">🧠 Knowledge Universe</text>
191
+ <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>
192
+ <line x1="30" y1="80" x2="${W-30}" y2="80" stroke="#6366f120"/>
193
+ <text x="${radarCx}" y="105" font-size="11" fill="#667" text-anchor="middle" font-family="system-ui">KNOWLEDGE DISTRIBUTION</text>
194
+ ${gridPaths.map((p: string) => `<path d="${p}" fill="none" stroke="#6366f115" stroke-width="0.5"/>`).join('\n ')}
195
+ ${radarPoints.map((p: any) => `<line x1="${radarCx}" y1="${radarCy}" x2="${p.lx}" y2="${p.ly}" stroke="#6366f110" stroke-width="0.5"/>`).join('\n ')}
196
+ <path d="${radarPath}" fill="url(#rf)" stroke="#818cf8" stroke-width="1.5"/>
197
+ ${radarPoints.map((p: any) => `<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 ')}
198
+ <text x="580" y="105" font-size="11" fill="#667" text-anchor="middle" font-family="system-ui">TOP TOPICS</text>
199
+ <rect x="440" y="115" width="320" height="240" rx="8" fill="#6366f108"/>
200
+ ${tagEls}
201
+ <text x="${W/2}" y="${H-15}" font-size="10" fill="#334" text-anchor="middle" font-family="monospace">Generated by Stellavault</text>
202
+ </svg>`;
203
+
204
+ res.setHeader('Content-Type', 'image/svg+xml');
205
+ res.send(svg);
206
+ } catch (err) {
207
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
208
+ }
209
+ });
210
+
211
+ // GET /api/stats
212
+ app.get('/api/stats', async (_req, res) => {
213
+ try {
214
+ const stats = await store.getStats();
215
+ res.json({ ...stats, vaultName });
216
+ } catch (err) {
217
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
218
+ }
219
+ });
220
+
221
+ // GET /api/decay — 감쇠 상태 리포트
222
+ app.get('/api/decay', async (_req, res) => {
223
+ if (!decayEngine) { res.json({ error: 'Decay engine not initialized' }); return; }
224
+ try {
225
+ const report = await decayEngine.computeAll();
226
+ res.json(report);
227
+ } catch (err) {
228
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
229
+ }
230
+ });
231
+
232
+ // GET /api/duplicates — 중복 노트 탐지
233
+ app.get('/api/duplicates', async (req, res) => {
234
+ try {
235
+ const threshold = parseFloat(String(req.query.threshold ?? '0.88'));
236
+ const pairs = await detectDuplicates(store, threshold, 20);
237
+ res.json({ pairs, count: pairs.length, threshold });
238
+ } catch (err) {
239
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
240
+ }
241
+ });
242
+
243
+ // GET /api/gaps — 지식 갭 탐지
244
+ app.get('/api/gaps', async (_req, res) => {
245
+ try {
246
+ const report = await detectKnowledgeGaps(store);
247
+ res.json(report);
248
+ } catch (err) {
249
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
250
+ }
251
+ });
252
+
253
+ // POST /api/duplicates/merge — 중복 노트 자동 병합
254
+ app.post('/api/duplicates/merge', async (req, res) => {
255
+ try {
256
+ const { docAId, docBId } = req.body;
257
+ if (!docAId || !docBId) { res.status(400).json({ error: 'docAId, docBId required' }); return; }
258
+
259
+ const docA = await store.getDocument(docAId);
260
+ const docB = await store.getDocument(docBId);
261
+ if (!docA || !docB) { res.status(404).json({ error: 'Document not found' }); return; }
262
+
263
+ const { readFileSync, writeFileSync, unlinkSync } = await import('node:fs');
264
+ const { join, resolve, relative } = await import('node:path');
265
+
266
+ // 긴 노트를 기준으로 유지, 짧은 노트의 고유 내용을 추가
267
+ const [keeper, removed] = docA.content.length >= docB.content.length
268
+ ? [docA, docB] : [docB, docA];
269
+
270
+ // HIGH-02: Path Traversal 방어 — vault 외부 접근 차단
271
+ const keeperPath = resolve(join(vaultPath, keeper.filePath));
272
+ const removedPath = resolve(join(vaultPath, removed.filePath));
273
+ const vaultRoot = resolve(vaultPath);
274
+ if (!keeperPath.startsWith(vaultRoot) || !removedPath.startsWith(vaultRoot)) {
275
+ res.status(400).json({ error: 'Invalid file path' }); return;
276
+ }
277
+
278
+ // 병합: keeper 끝에 removed 고유 내용 추가
279
+ const keeperContent = readFileSync(keeperPath, 'utf-8');
280
+ const appendix = `\n\n---\n\n> Merged from: ${removed.title} (${removed.filePath})\n\n${removed.content}`;
281
+ writeFileSync(keeperPath, keeperContent + appendix, 'utf-8');
282
+
283
+ // 삭제
284
+ try { unlinkSync(removedPath); } catch { /* 이미 없을 수 있음 */ }
285
+
286
+ // DB에서도 삭제
287
+ await store.deleteByDocumentId(removed.id);
288
+
289
+ res.json({
290
+ success: true,
291
+ kept: { id: keeper.id, title: keeper.title },
292
+ removed: { id: removed.id, title: removed.title },
293
+ });
294
+ } catch (err) {
295
+ console.error(err); res.status(500).json({ error: 'Merge failed' });
296
+ }
297
+ });
298
+
299
+ // POST /api/gaps/create-bridge — 갭 브릿지 노트 자동 생성
300
+ app.post('/api/gaps/create-bridge', async (req, res) => {
301
+ try {
302
+ const { clusterA, clusterB } = req.body;
303
+ if (!clusterA || !clusterB) { res.status(400).json({ error: 'clusterA, clusterB required' }); return; }
304
+
305
+ const { writeFileSync, mkdirSync } = await import('node:fs');
306
+ const { join } = await import('node:path');
307
+
308
+ const nameA = clusterA.replace(/\s*\(\d+\)$/, '');
309
+ const nameB = clusterB.replace(/\s*\(\d+\)$/, '');
310
+
311
+ // 양쪽 클러스터의 대표 노트 검색
312
+ const resultsA = await searchEngine.search({ query: nameA, limit: 3 });
313
+ const resultsB = await searchEngine.search({ query: nameB, limit: 3 });
314
+
315
+ const refsA = resultsA.map(r => `- [[${r.document.title}]]: ${r.document.content.slice(0, 100).replace(/\n/g, ' ')}...`).join('\n');
316
+ const refsB = resultsB.map(r => `- [[${r.document.title}]]: ${r.document.content.slice(0, 100).replace(/\n/g, ' ')}...`).join('\n');
317
+
318
+ const title = `${nameA} × ${nameB}`;
319
+ const date = new Date().toISOString().slice(0, 10);
320
+ const content = [
321
+ '---',
322
+ `title: "${title}"`,
323
+ `created: ${date}`,
324
+ 'tags: [bridge, auto-generated]',
325
+ '---',
326
+ '',
327
+ `# ${title}`,
328
+ '',
329
+ `> 이 노트는 지식 갭 탐지기에 의해 자동 생성되었습니다.`,
330
+ `> ${nameA}와 ${nameB} 사이의 연결 지식을 정리하세요.`,
331
+ '',
332
+ `## ${nameA} 핵심 노트`,
333
+ '',
334
+ refsA || '- (관련 노트 없음)',
335
+ '',
336
+ `## ${nameB} 핵심 노트`,
337
+ '',
338
+ refsB || '- (관련 노트 없음)',
339
+ '',
340
+ '## 연결 포인트',
341
+ '',
342
+ `${nameA}와 ${nameB}의 관계:`,
343
+ '',
344
+ '- ',
345
+ '',
346
+ '## 메모',
347
+ '',
348
+ '',
349
+ ].join('\n');
350
+
351
+ const safeTitle = title.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ');
352
+ const { resolve } = await import('node:path');
353
+ const dir = resolve(join(vaultPath, '01_Knowledge'));
354
+ if (!dir.startsWith(resolve(vaultPath))) { res.status(400).json({ error: 'Invalid path' }); return; }
355
+ mkdirSync(dir, { recursive: true });
356
+ const filePath = join(dir, `${safeTitle}.md`);
357
+ writeFileSync(filePath, content, 'utf-8');
358
+
359
+ res.json({ success: true, title: safeTitle, path: filePath });
360
+ } catch (err) {
361
+ console.error(err); res.status(500).json({ error: 'Bridge creation failed' });
362
+ }
363
+ });
364
+
365
+ // GET /api/health — 종합 건강도 대시보드
366
+ app.get('/api/health', async (_req, res) => {
367
+ try {
368
+ const stats = await store.getStats();
369
+ const docs = await store.getAllDocuments();
370
+
371
+ // Decay 요약
372
+ let decaySummary = { totalDocuments: 0, criticalCount: 0, decayingCount: 0, averageR: 1.0, topDecaying: [] as any[] };
373
+ if (decayEngine) {
374
+ const report = await decayEngine.computeAll();
375
+ decaySummary = {
376
+ totalDocuments: report.totalDocuments ?? docs.length,
377
+ criticalCount: report.criticalCount ?? 0,
378
+ decayingCount: report.decayingCount ?? 0,
379
+ averageR: report.averageR ?? 1.0,
380
+ topDecaying: (report.topDecaying ?? []).slice(0, 5),
381
+ };
382
+ }
383
+
384
+ // Gaps 요약
385
+ let gapSummary = { gapCount: 0, isolatedCount: 0 };
386
+ try {
387
+ const gapReport = await detectKnowledgeGaps(store);
388
+ gapSummary = {
389
+ gapCount: gapReport.gaps?.length ?? 0,
390
+ isolatedCount: gapReport.isolatedNodes?.length ?? 0,
391
+ };
392
+ } catch { /* gaps may fail if no embeddings */ }
393
+
394
+ // Duplicates 요약
395
+ let dupCount = 0;
396
+ try {
397
+ const pairs = await detectDuplicates(store, 0.88, 50);
398
+ dupCount = pairs.length;
399
+ } catch { /* duplicates may fail */ }
400
+
401
+ // Source/Type 분포
402
+ const sourceDist = new Map<string, number>();
403
+ const typeDist = new Map<string, number>();
404
+ for (const doc of docs) {
405
+ const s = doc.source ?? 'local';
406
+ const t = doc.type ?? 'note';
407
+ sourceDist.set(s, (sourceDist.get(s) ?? 0) + 1);
408
+ typeDist.set(t, (typeDist.get(t) ?? 0) + 1);
409
+ }
410
+
411
+ // 시간별 문서 증가 (월별)
412
+ const monthlyGrowth = new Map<string, number>();
413
+ for (const doc of docs) {
414
+ const month = doc.lastModified?.slice(0, 7) ?? 'unknown';
415
+ monthlyGrowth.set(month, (monthlyGrowth.get(month) ?? 0) + 1);
416
+ }
417
+
418
+ res.json({
419
+ stats: { ...stats, vaultName },
420
+ decay: decaySummary,
421
+ gaps: gapSummary,
422
+ duplicates: { count: dupCount },
423
+ distribution: {
424
+ source: Object.fromEntries(sourceDist),
425
+ type: Object.fromEntries(typeDist),
426
+ },
427
+ growth: Object.fromEntries(
428
+ [...monthlyGrowth.entries()].sort((a, b) => a[0].localeCompare(b[0]))
429
+ ),
430
+ });
431
+ } catch (err) {
432
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
433
+ }
434
+ });
435
+
436
+ // GET /api/profile — Knowledge Profile summary (F-A09)
437
+ app.get('/api/profile', async (_req, res) => {
438
+ try {
439
+ const stats = await store.getStats();
440
+ const topics = await store.getTopics();
441
+ const docs = await store.getAllDocuments();
442
+
443
+ let decaySummary = { averageR: 1.0, criticalCount: 0, healthScore: 100 };
444
+ if (decayEngine) {
445
+ const report = await decayEngine.computeAll();
446
+ const avgR = report.averageR ?? 1.0;
447
+ decaySummary = {
448
+ averageR: avgR,
449
+ criticalCount: report.criticalCount ?? 0,
450
+ healthScore: Math.round(avgR * 100),
451
+ };
452
+ }
453
+
454
+ // Source/Type distribution
455
+ const sourceDist: Record<string, number> = {};
456
+ const typeDist: Record<string, number> = {};
457
+ for (const doc of docs) {
458
+ const s = doc.source ?? 'local';
459
+ const t = doc.type ?? 'note';
460
+ sourceDist[s] = (sourceDist[s] ?? 0) + 1;
461
+ typeDist[t] = (typeDist[t] ?? 0) + 1;
462
+ }
463
+
464
+ // Activity: docs per month (last 12)
465
+ const monthlyActivity: Record<string, number> = {};
466
+ for (const doc of docs) {
467
+ const month = doc.lastModified?.slice(0, 7);
468
+ if (month) monthlyActivity[month] = (monthlyActivity[month] ?? 0) + 1;
469
+ }
470
+
471
+ res.setHeader('Access-Control-Allow-Origin', '*');
472
+ res.json({
473
+ name: vaultName || 'Knowledge Vault',
474
+ stats: {
475
+ documents: stats.documentCount,
476
+ chunks: stats.chunkCount,
477
+ topics: topics.length,
478
+ },
479
+ healthScore: decaySummary.healthScore,
480
+ topTopics: topics.slice(0, 15).map(t => ({ name: t.topic, count: t.count })),
481
+ distribution: { source: sourceDist, type: typeDist },
482
+ activity: Object.fromEntries(
483
+ Object.entries(monthlyActivity).sort((a, b) => a[0].localeCompare(b[0])).slice(-12)
484
+ ),
485
+ generatedAt: new Date().toISOString(),
486
+ });
487
+ } catch (err) {
488
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
489
+ }
490
+ });
491
+
492
+ // GET /api/embed — 임베드용 경량 그래프 데이터 (F-A08)
493
+ app.get('/api/embed', async (req, res) => {
494
+ try {
495
+ const mode = (req.query.mode as string) === 'folder' ? 'folder' : 'semantic';
496
+ const maxNodes = Math.min(parseInt(String(req.query.max ?? '200'), 10), 500);
497
+
498
+ if (!graphCaches.has(mode)) {
499
+ const data = await buildGraphData(store, { mode });
500
+ graphCaches.set(mode, { data, generatedAt: new Date().toISOString() });
501
+ }
502
+ const cached = graphCaches.get(mode)!;
503
+ const { nodes, edges, clusters } = cached.data;
504
+
505
+ const connCount = new Map<string, number>();
506
+ for (const e of edges) {
507
+ connCount.set(e.source, (connCount.get(e.source) ?? 0) + 1);
508
+ connCount.set(e.target, (connCount.get(e.target) ?? 0) + 1);
509
+ }
510
+ const sortedNodes = [...nodes].sort((a, b) => (connCount.get(b.id) ?? 0) - (connCount.get(a.id) ?? 0));
511
+ const selectedNodes = sortedNodes.slice(0, maxNodes);
512
+ const selectedIds = new Set(selectedNodes.map(n => n.id));
513
+ const selectedEdges = edges.filter((e: any) => selectedIds.has(e.source) && selectedIds.has(e.target));
514
+
515
+ const embedNodes = selectedNodes.map((n, i) => {
516
+ const angle = (i / selectedNodes.length) * Math.PI * 2;
517
+ const r = 100 + n.clusterId * 15;
518
+ return {
519
+ id: n.id, label: n.label, clusterId: n.clusterId, size: n.size,
520
+ position: [
521
+ r * Math.cos(angle) + (Math.random() - 0.5) * 60,
522
+ (Math.random() - 0.5) * 200,
523
+ r * Math.sin(angle) + (Math.random() - 0.5) * 60,
524
+ ],
525
+ };
526
+ });
527
+
528
+ res.setHeader('Access-Control-Allow-Origin', '*');
529
+ res.json({
530
+ nodes: embedNodes, edges: selectedEdges,
531
+ stats: { nodeCount: embedNodes.length, edgeCount: selectedEdges.length, clusterCount: clusters.length, totalNodes: nodes.length },
532
+ title: vaultName || 'Knowledge Graph',
533
+ });
534
+ } catch (err) {
535
+ console.error(err); res.status(500).json({ error: 'Internal server error' });
536
+ }
537
+ });
538
+
539
+ // Sync 상태 추적
540
+ let syncState: { running: boolean; startedAt: string; completedAt: string; result: string; output: string } = {
541
+ running: false, startedAt: '', completedAt: '', result: '', output: '',
542
+ };
543
+
544
+ // POST /api/sync — Notion → Obsidian 동기화 트리거
545
+ app.post('/api/sync', async (_req, res) => {
546
+ if (syncState.running) {
547
+ res.json({ success: false, error: 'Sync already running', state: syncState }); return;
548
+ }
549
+ try {
550
+ const { spawn } = await import('node:child_process');
551
+ const { resolve } = await import('node:path');
552
+ const syncScript = resolve(process.cwd(), 'packages/sync/sync-to-obsidian.mjs');
553
+ const syncDir = resolve(process.cwd(), 'packages/sync');
554
+
555
+ const { existsSync } = await import('node:fs');
556
+ if (!existsSync(syncScript)) { res.json({ success: false, error: 'sync script not found' }); return; }
557
+ if (!existsSync(resolve(syncDir, '.env'))) { res.json({ success: false, error: '.env not found' }); return; }
558
+
559
+ syncState = { running: true, startedAt: new Date().toISOString(), completedAt: '', result: '', output: '' };
560
+ const child = spawn('node', [syncScript], { cwd: syncDir, stdio: ['ignore', 'pipe', 'pipe'], shell: true });
561
+
562
+ let output = '';
563
+ child.stdout.on('data', (d: Buffer) => { output += d.toString(); });
564
+ child.stderr.on('data', (d: Buffer) => { output += d.toString(); });
565
+ child.on('close', (code) => {
566
+ syncState.running = false;
567
+ syncState.completedAt = new Date().toISOString();
568
+ syncState.result = code === 0 ? 'success' : 'failed';
569
+ syncState.output = output.slice(-500); // 마지막 500자만
570
+ });
571
+
572
+ res.json({ success: true, message: 'Sync started' });
573
+ } catch (err) {
574
+ syncState.running = false;
575
+ console.error(err); res.status(500).json({ error: 'Sync failed' });
576
+ }
577
+ });
578
+
579
+ // GET /api/sync/status — 동기화 상태 조회
580
+ app.get('/api/sync/status', (_req, res) => {
581
+ res.json(syncState);
582
+ });
583
+
584
+ // POST /api/clip — 웹 페이지 클리핑
585
+ app.post('/api/clip', async (req, res) => {
586
+ try {
587
+ const { url } = req.body;
588
+ if (!url) { res.status(400).json({ error: 'url required' }); return; }
589
+
590
+ // HIGH-03: SSRF 방어 — 내부 네트워크 차단
591
+ try {
592
+ const parsed = new URL(url);
593
+ if (!['http:', 'https:'].includes(parsed.protocol)) { res.status(400).json({ error: 'Only http/https URLs allowed' }); return; }
594
+ const host = parsed.hostname.toLowerCase();
595
+ if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host.endsWith('.local') || host.startsWith('192.168.') || host.startsWith('10.') || host.startsWith('172.16.')) {
596
+ res.status(400).json({ error: 'Internal URLs not allowed' }); return;
597
+ }
598
+ } catch { res.status(400).json({ error: 'Invalid URL' }); return; }
599
+
600
+ const isYT = /youtube\.com\/watch|youtu\.be\//.test(url);
601
+ const response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 stellavault-clipper/1.0' }, signal: AbortSignal.timeout(15000) });
602
+ const html = await response.text();
603
+
604
+ // 제목 추출
605
+ const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
606
+ let title = (titleMatch ? titleMatch[1] : new URL(url).hostname).replace(/ - YouTube$/, '').trim();
607
+ const safeTitle = title.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80);
608
+
609
+ let content: string;
610
+ if (isYT) {
611
+ const videoId = url.match(/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]+)/)?.[1] ?? '';
612
+ const descMatch = html.match(/"shortDescription":"([\s\S]*?)"/);
613
+ const desc = descMatch ? descMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').slice(0, 3000) : '';
614
+ content = `![thumbnail](https://img.youtube.com/vi/${videoId}/maxresdefault.jpg)\n\n## 설명\n\n${desc}\n\n[YouTube](${url})`;
615
+ } else {
616
+ content = html
617
+ .replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
618
+ .replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, '\n# $1\n').replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, '\n## $1\n')
619
+ .replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, '\n### $1\n')
620
+ .replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, '\n$1\n').replace(/<br\s*\/?>/gi, '\n')
621
+ .replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
622
+ .replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, '- $1\n')
623
+ .replace(/<(strong|b)[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**')
624
+ .replace(/<(em|i)[^>]*>([\s\S]*?)<\/\1>/gi, '*$2*')
625
+ .replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, '`$1`')
626
+ .replace(/<[^>]+>/g, '')
627
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, ' ')
628
+ .replace(/\n{3,}/g, '\n\n').trim();
629
+ if (content.length > 10000) content = content.slice(0, 10000) + '\n\n...(truncated)';
630
+ }
631
+
632
+ // vault에 저장
633
+ const { writeFileSync, mkdirSync } = await import('node:fs');
634
+ const { join } = await import('node:path');
635
+ const date = new Date().toISOString().slice(0, 10);
636
+ const clipDir = join(vaultPath || '.', '06_Research', 'clips');
637
+ mkdirSync(clipDir, { recursive: true });
638
+
639
+ const fileName = `${date} ${safeTitle}.md`;
640
+ const md = `---\ntitle: "${safeTitle}"\nsource: "${url}"\nclipped: ${date}\ntags: [clip${isYT ? ', youtube' : ''}]\n---\n\n# ${safeTitle}\n\n> Source: ${url}\n\n${content}`;
641
+ writeFileSync(join(clipDir, fileName), md, 'utf-8');
642
+
643
+ res.json({ success: true, fileName, path: join(clipDir, fileName) });
644
+ } catch (err) {
645
+ console.error(err); res.status(500).json({ error: 'Clip failed' });
646
+ }
647
+ });
648
+
649
+ return {
650
+ async start() {
651
+ return new Promise<void>((resolve) => {
652
+ app.listen(port, '127.0.0.1', () => {
653
+ console.error(`🌐 API server running at http://127.0.0.1:${port}`);
654
+ resolve();
655
+ });
656
+ });
657
+ },
658
+ app,
659
+ };
660
+ }