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.
- package/README.md +52 -16
- package/SECURITY.md +50 -0
- package/dist/stellavault.js +8968 -0
- package/package.json +70 -33
- package/.env.example +0 -12
- package/CHANGELOG.md +0 -106
- package/CLAUDE.md +0 -42
- package/CONTRIBUTING.md +0 -65
- package/index.html +0 -509
- package/packages/cli/bin/ekh.js +0 -2
- package/packages/cli/bin/stellavault.js +0 -2
- package/packages/cli/dist/commands/adr-cmd.d.ts +0 -7
- package/packages/cli/dist/commands/adr-cmd.js +0 -45
- package/packages/cli/dist/commands/ask-cmd.d.ts +0 -5
- package/packages/cli/dist/commands/ask-cmd.js +0 -36
- package/packages/cli/dist/commands/autopilot-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/autopilot-cmd.js +0 -76
- package/packages/cli/dist/commands/brief-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/brief-cmd.js +0 -82
- package/packages/cli/dist/commands/capture-cmd.d.ts +0 -7
- package/packages/cli/dist/commands/capture-cmd.js +0 -31
- package/packages/cli/dist/commands/card-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/card-cmd.js +0 -26
- package/packages/cli/dist/commands/clip-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/clip-cmd.js +0 -151
- package/packages/cli/dist/commands/cloud-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/cloud-cmd.js +0 -64
- package/packages/cli/dist/commands/compile-cmd.d.ts +0 -6
- package/packages/cli/dist/commands/compile-cmd.js +0 -30
- package/packages/cli/dist/commands/contradictions-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/contradictions-cmd.js +0 -34
- package/packages/cli/dist/commands/decay-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/decay-cmd.js +0 -48
- package/packages/cli/dist/commands/digest-cmd.d.ts +0 -5
- package/packages/cli/dist/commands/digest-cmd.js +0 -136
- package/packages/cli/dist/commands/draft-cmd.d.ts +0 -6
- package/packages/cli/dist/commands/draft-cmd.js +0 -110
- package/packages/cli/dist/commands/duplicates-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/duplicates-cmd.js +0 -30
- package/packages/cli/dist/commands/federate-cmd.d.ts +0 -5
- package/packages/cli/dist/commands/federate-cmd.js +0 -247
- package/packages/cli/dist/commands/fleeting-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/fleeting-cmd.js +0 -45
- package/packages/cli/dist/commands/flush-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/flush-cmd.js +0 -58
- package/packages/cli/dist/commands/gaps-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/gaps-cmd.js +0 -33
- package/packages/cli/dist/commands/graph-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/graph-cmd.js +0 -89
- package/packages/cli/dist/commands/index-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/index-cmd.js +0 -57
- package/packages/cli/dist/commands/ingest-cmd.d.ts +0 -9
- package/packages/cli/dist/commands/ingest-cmd.js +0 -195
- package/packages/cli/dist/commands/init-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/init-cmd.js +0 -161
- package/packages/cli/dist/commands/learn-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/learn-cmd.js +0 -48
- package/packages/cli/dist/commands/lint-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/lint-cmd.js +0 -61
- package/packages/cli/dist/commands/pack-cmd.d.ts +0 -15
- package/packages/cli/dist/commands/pack-cmd.js +0 -93
- package/packages/cli/dist/commands/review-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/review-cmd.js +0 -107
- package/packages/cli/dist/commands/search-cmd.d.ts +0 -4
- package/packages/cli/dist/commands/search-cmd.js +0 -38
- package/packages/cli/dist/commands/serve-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/serve-cmd.js +0 -14
- package/packages/cli/dist/commands/session-cmd.d.ts +0 -7
- package/packages/cli/dist/commands/session-cmd.js +0 -95
- package/packages/cli/dist/commands/status-cmd.d.ts +0 -2
- package/packages/cli/dist/commands/status-cmd.js +0 -33
- package/packages/cli/dist/commands/sync-cmd.d.ts +0 -5
- package/packages/cli/dist/commands/sync-cmd.js +0 -62
- package/packages/cli/dist/commands/vault-cmd.d.ts +0 -10
- package/packages/cli/dist/commands/vault-cmd.js +0 -54
- package/packages/cli/dist/index.d.ts +0 -2
- package/packages/cli/dist/index.js +0 -233
- package/packages/cli/package.json +0 -24
- package/packages/core/dist/api/dashboard.d.ts +0 -3
- package/packages/core/dist/api/dashboard.js +0 -135
- package/packages/core/dist/api/graph-data.d.ts +0 -11
- package/packages/core/dist/api/graph-data.js +0 -255
- package/packages/core/dist/api/pwa.d.ts +0 -3
- package/packages/core/dist/api/pwa.js +0 -77
- package/packages/core/dist/api/server.d.ts +0 -16
- package/packages/core/dist/api/server.js +0 -1122
- package/packages/core/dist/capture/voice.d.ts +0 -24
- package/packages/core/dist/capture/voice.js +0 -135
- package/packages/core/dist/cloud/index.d.ts +0 -3
- package/packages/core/dist/cloud/index.js +0 -2
- package/packages/core/dist/cloud/sync.d.ts +0 -29
- package/packages/core/dist/cloud/sync.js +0 -137
- package/packages/core/dist/config.d.ts +0 -35
- package/packages/core/dist/config.js +0 -63
- package/packages/core/dist/federation/credits.d.ts +0 -26
- package/packages/core/dist/federation/credits.js +0 -56
- package/packages/core/dist/federation/identity.d.ts +0 -14
- package/packages/core/dist/federation/identity.js +0 -74
- package/packages/core/dist/federation/index.d.ts +0 -7
- package/packages/core/dist/federation/index.js +0 -5
- package/packages/core/dist/federation/node.d.ts +0 -31
- package/packages/core/dist/federation/node.js +0 -216
- package/packages/core/dist/federation/privacy.d.ts +0 -8
- package/packages/core/dist/federation/privacy.js +0 -40
- package/packages/core/dist/federation/reputation.d.ts +0 -37
- package/packages/core/dist/federation/reputation.js +0 -139
- package/packages/core/dist/federation/search.d.ts +0 -19
- package/packages/core/dist/federation/search.js +0 -101
- package/packages/core/dist/federation/sharing.d.ts +0 -72
- package/packages/core/dist/federation/sharing.js +0 -246
- package/packages/core/dist/federation/trust.d.ts +0 -15
- package/packages/core/dist/federation/trust.js +0 -60
- package/packages/core/dist/federation/types.d.ts +0 -40
- package/packages/core/dist/federation/types.js +0 -3
- package/packages/core/dist/i18n/index.d.ts +0 -6
- package/packages/core/dist/i18n/index.js +0 -81
- package/packages/core/dist/i18n/note-strings.d.ts +0 -5
- package/packages/core/dist/i18n/note-strings.js +0 -94
- package/packages/core/dist/index.d.ts +0 -123
- package/packages/core/dist/index.js +0 -74
- package/packages/core/dist/indexer/chunker.d.ts +0 -14
- package/packages/core/dist/indexer/chunker.js +0 -148
- package/packages/core/dist/indexer/embedder.d.ts +0 -8
- package/packages/core/dist/indexer/embedder.js +0 -3
- package/packages/core/dist/indexer/index.d.ts +0 -28
- package/packages/core/dist/indexer/index.js +0 -74
- package/packages/core/dist/indexer/local-embedder.d.ts +0 -3
- package/packages/core/dist/indexer/local-embedder.js +0 -29
- package/packages/core/dist/indexer/scanner.d.ts +0 -11
- package/packages/core/dist/indexer/scanner.js +0 -148
- package/packages/core/dist/indexer/watcher.d.ts +0 -19
- package/packages/core/dist/indexer/watcher.js +0 -49
- package/packages/core/dist/intelligence/ask-engine.d.ts +0 -24
- package/packages/core/dist/intelligence/ask-engine.js +0 -142
- package/packages/core/dist/intelligence/auto-linker.d.ts +0 -19
- package/packages/core/dist/intelligence/auto-linker.js +0 -126
- package/packages/core/dist/intelligence/code-linker.d.ts +0 -20
- package/packages/core/dist/intelligence/code-linker.js +0 -88
- package/packages/core/dist/intelligence/contradiction-detector.d.ts +0 -20
- package/packages/core/dist/intelligence/contradiction-detector.js +0 -115
- package/packages/core/dist/intelligence/decay-engine.d.ts +0 -27
- package/packages/core/dist/intelligence/decay-engine.js +0 -190
- package/packages/core/dist/intelligence/draft-generator.d.ts +0 -25
- package/packages/core/dist/intelligence/draft-generator.js +0 -277
- package/packages/core/dist/intelligence/duplicate-detector.d.ts +0 -20
- package/packages/core/dist/intelligence/duplicate-detector.js +0 -55
- package/packages/core/dist/intelligence/file-extractors.d.ts +0 -13
- package/packages/core/dist/intelligence/file-extractors.js +0 -155
- package/packages/core/dist/intelligence/fsrs.d.ts +0 -43
- package/packages/core/dist/intelligence/fsrs.js +0 -70
- package/packages/core/dist/intelligence/gap-detector.d.ts +0 -25
- package/packages/core/dist/intelligence/gap-detector.js +0 -78
- package/packages/core/dist/intelligence/ingest-pipeline.d.ts +0 -32
- package/packages/core/dist/intelligence/ingest-pipeline.js +0 -245
- package/packages/core/dist/intelligence/knowledge-lint.d.ts +0 -27
- package/packages/core/dist/intelligence/knowledge-lint.js +0 -132
- package/packages/core/dist/intelligence/learning-path.d.ts +0 -31
- package/packages/core/dist/intelligence/learning-path.js +0 -53
- package/packages/core/dist/intelligence/notifications.d.ts +0 -31
- package/packages/core/dist/intelligence/notifications.js +0 -65
- package/packages/core/dist/intelligence/predictive-gaps.d.ts +0 -14
- package/packages/core/dist/intelligence/predictive-gaps.js +0 -74
- package/packages/core/dist/intelligence/semantic-versioning.d.ts +0 -37
- package/packages/core/dist/intelligence/semantic-versioning.js +0 -68
- package/packages/core/dist/intelligence/types.d.ts +0 -28
- package/packages/core/dist/intelligence/types.js +0 -3
- package/packages/core/dist/intelligence/wiki-compiler.d.ts +0 -30
- package/packages/core/dist/intelligence/wiki-compiler.js +0 -222
- package/packages/core/dist/intelligence/youtube-extractor.d.ts +0 -29
- package/packages/core/dist/intelligence/youtube-extractor.js +0 -321
- package/packages/core/dist/intelligence/zettelkasten.d.ts +0 -59
- package/packages/core/dist/intelligence/zettelkasten.js +0 -234
- package/packages/core/dist/mcp/custom-tools.d.ts +0 -29
- package/packages/core/dist/mcp/custom-tools.js +0 -70
- package/packages/core/dist/mcp/index.d.ts +0 -2
- package/packages/core/dist/mcp/index.js +0 -2
- package/packages/core/dist/mcp/server.d.ts +0 -51
- package/packages/core/dist/mcp/server.js +0 -174
- package/packages/core/dist/mcp/tools/agentic-graph.d.ts +0 -93
- package/packages/core/dist/mcp/tools/agentic-graph.js +0 -116
- package/packages/core/dist/mcp/tools/ask.d.ts +0 -29
- package/packages/core/dist/mcp/tools/ask.js +0 -43
- package/packages/core/dist/mcp/tools/brief.d.ts +0 -31
- package/packages/core/dist/mcp/tools/brief.js +0 -39
- package/packages/core/dist/mcp/tools/decay.d.ts +0 -33
- package/packages/core/dist/mcp/tools/decay.js +0 -32
- package/packages/core/dist/mcp/tools/decision-journal.d.ts +0 -78
- package/packages/core/dist/mcp/tools/decision-journal.js +0 -79
- package/packages/core/dist/mcp/tools/detect-gaps.d.ts +0 -24
- package/packages/core/dist/mcp/tools/detect-gaps.js +0 -47
- package/packages/core/dist/mcp/tools/export.d.ts +0 -29
- package/packages/core/dist/mcp/tools/export.js +0 -60
- package/packages/core/dist/mcp/tools/federated-search.d.ts +0 -29
- package/packages/core/dist/mcp/tools/federated-search.js +0 -36
- package/packages/core/dist/mcp/tools/generate-claude-md.d.ts +0 -35
- package/packages/core/dist/mcp/tools/generate-claude-md.js +0 -107
- package/packages/core/dist/mcp/tools/generate-draft.d.ts +0 -34
- package/packages/core/dist/mcp/tools/generate-draft.js +0 -120
- package/packages/core/dist/mcp/tools/get-document.d.ts +0 -35
- package/packages/core/dist/mcp/tools/get-document.js +0 -25
- package/packages/core/dist/mcp/tools/get-evolution.d.ts +0 -28
- package/packages/core/dist/mcp/tools/get-evolution.js +0 -70
- package/packages/core/dist/mcp/tools/get-related.d.ts +0 -32
- package/packages/core/dist/mcp/tools/get-related.js +0 -33
- package/packages/core/dist/mcp/tools/learning-path.d.ts +0 -23
- package/packages/core/dist/mcp/tools/learning-path.js +0 -45
- package/packages/core/dist/mcp/tools/link-code.d.ts +0 -34
- package/packages/core/dist/mcp/tools/link-code.js +0 -44
- package/packages/core/dist/mcp/tools/list-topics.d.ts +0 -15
- package/packages/core/dist/mcp/tools/list-topics.js +0 -18
- package/packages/core/dist/mcp/tools/search.d.ts +0 -39
- package/packages/core/dist/mcp/tools/search.js +0 -29
- package/packages/core/dist/mcp/tools/snapshot.d.ts +0 -47
- package/packages/core/dist/mcp/tools/snapshot.js +0 -84
- package/packages/core/dist/multi-vault/index.d.ts +0 -26
- package/packages/core/dist/multi-vault/index.js +0 -80
- package/packages/core/dist/pack/creator.d.ts +0 -21
- package/packages/core/dist/pack/creator.js +0 -105
- package/packages/core/dist/pack/exporter.d.ts +0 -4
- package/packages/core/dist/pack/exporter.js +0 -18
- package/packages/core/dist/pack/importer.d.ts +0 -10
- package/packages/core/dist/pack/importer.js +0 -55
- package/packages/core/dist/pack/index.d.ts +0 -6
- package/packages/core/dist/pack/index.js +0 -5
- package/packages/core/dist/pack/marketplace.d.ts +0 -14
- package/packages/core/dist/pack/marketplace.js +0 -90
- package/packages/core/dist/pack/pii-masker.d.ts +0 -7
- package/packages/core/dist/pack/pii-masker.js +0 -29
- package/packages/core/dist/pack/types.d.ts +0 -36
- package/packages/core/dist/pack/types.js +0 -3
- package/packages/core/dist/plugins/index.d.ts +0 -35
- package/packages/core/dist/plugins/index.js +0 -57
- package/packages/core/dist/plugins/webhooks.d.ts +0 -30
- package/packages/core/dist/plugins/webhooks.js +0 -79
- package/packages/core/dist/search/adaptive.d.ts +0 -16
- package/packages/core/dist/search/adaptive.js +0 -67
- package/packages/core/dist/search/bm25.d.ts +0 -4
- package/packages/core/dist/search/bm25.js +0 -10
- package/packages/core/dist/search/index.d.ts +0 -15
- package/packages/core/dist/search/index.js +0 -64
- package/packages/core/dist/search/rrf.d.ts +0 -7
- package/packages/core/dist/search/rrf.js +0 -21
- package/packages/core/dist/search/semantic.d.ts +0 -5
- package/packages/core/dist/search/semantic.js +0 -6
- package/packages/core/dist/store/index.d.ts +0 -3
- package/packages/core/dist/store/index.js +0 -2
- package/packages/core/dist/store/sqlite-vec.d.ts +0 -6
- package/packages/core/dist/store/sqlite-vec.js +0 -251
- package/packages/core/dist/store/types.d.ts +0 -20
- package/packages/core/dist/store/types.js +0 -3
- package/packages/core/dist/team/index.d.ts +0 -25
- package/packages/core/dist/team/index.js +0 -97
- package/packages/core/dist/types/chunk.d.ts +0 -23
- package/packages/core/dist/types/chunk.js +0 -3
- package/packages/core/dist/types/document.d.ts +0 -23
- package/packages/core/dist/types/document.js +0 -3
- package/packages/core/dist/types/graph.d.ts +0 -39
- package/packages/core/dist/types/graph.js +0 -3
- package/packages/core/dist/types/index.d.ts +0 -5
- package/packages/core/dist/types/index.js +0 -2
- package/packages/core/dist/types/search.d.ts +0 -39
- package/packages/core/dist/types/search.js +0 -3
- package/packages/core/dist/utils/retry.d.ts +0 -25
- package/packages/core/dist/utils/retry.js +0 -59
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
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(/ /g, ' ')
|
|
433
|
-
.replace(/&/g, '&')
|
|
434
|
-
.replace(/</g, '<')
|
|
435
|
-
.replace(/>/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 = `\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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /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
|