wikimem 0.8.0 → 0.8.2
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/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +97 -8
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/core/connectors.d.ts +1 -1
- package/dist/core/connectors.d.ts.map +1 -1
- package/dist/core/git.d.ts +1 -1
- package/dist/core/git.d.ts.map +1 -1
- package/dist/core/git.js.map +1 -1
- package/dist/core/ingest.d.ts.map +1 -1
- package/dist/core/ingest.js +74 -3
- package/dist/core/ingest.js.map +1 -1
- package/dist/core/lint.d.ts.map +1 -1
- package/dist/core/lint.js +23 -4
- package/dist/core/lint.js.map +1 -1
- package/dist/core/oauth-defaults.d.ts +31 -0
- package/dist/core/oauth-defaults.d.ts.map +1 -0
- package/dist/core/oauth-defaults.js +79 -0
- package/dist/core/oauth-defaults.js.map +1 -0
- package/dist/core/observer.d.ts +24 -1
- package/dist/core/observer.d.ts.map +1 -1
- package/dist/core/observer.js +146 -4
- package/dist/core/observer.js.map +1 -1
- package/dist/core/sync/gdrive.d.ts +14 -0
- package/dist/core/sync/gdrive.d.ts.map +1 -0
- package/dist/core/sync/gdrive.js +205 -0
- package/dist/core/sync/gdrive.js.map +1 -0
- package/dist/core/sync/github.d.ts +20 -0
- package/dist/core/sync/github.d.ts.map +1 -0
- package/dist/core/sync/github.js +206 -0
- package/dist/core/sync/github.js.map +1 -0
- package/dist/core/sync/gmail.d.ts +15 -0
- package/dist/core/sync/gmail.d.ts.map +1 -0
- package/dist/core/sync/gmail.js +159 -0
- package/dist/core/sync/gmail.js.map +1 -0
- package/dist/core/sync/index.d.ts +47 -0
- package/dist/core/sync/index.d.ts.map +1 -0
- package/dist/core/sync/index.js +100 -0
- package/dist/core/sync/index.js.map +1 -0
- package/dist/core/sync/jira.d.ts +15 -0
- package/dist/core/sync/jira.d.ts.map +1 -0
- package/dist/core/sync/jira.js +176 -0
- package/dist/core/sync/jira.js.map +1 -0
- package/dist/core/sync/linear.d.ts +15 -0
- package/dist/core/sync/linear.d.ts.map +1 -0
- package/dist/core/sync/linear.js +111 -0
- package/dist/core/sync/linear.js.map +1 -0
- package/dist/core/sync/notion.d.ts +14 -0
- package/dist/core/sync/notion.d.ts.map +1 -0
- package/dist/core/sync/notion.js +168 -0
- package/dist/core/sync/notion.js.map +1 -0
- package/dist/core/sync/rss.d.ts +20 -0
- package/dist/core/sync/rss.d.ts.map +1 -0
- package/dist/core/sync/rss.js +165 -0
- package/dist/core/sync/rss.js.map +1 -0
- package/dist/core/sync/scheduler.d.ts +31 -0
- package/dist/core/sync/scheduler.d.ts.map +1 -0
- package/dist/core/sync/scheduler.js +129 -0
- package/dist/core/sync/scheduler.js.map +1 -0
- package/dist/core/sync/slack.d.ts +16 -0
- package/dist/core/sync/slack.d.ts.map +1 -0
- package/dist/core/sync/slack.js +173 -0
- package/dist/core/sync/slack.js.map +1 -0
- package/dist/core/vault.d.ts +22 -0
- package/dist/core/vault.d.ts.map +1 -1
- package/dist/core/vault.js +65 -0
- package/dist/core/vault.js.map +1 -1
- package/dist/core/webhooks.d.ts +13 -0
- package/dist/core/webhooks.d.ts.map +1 -0
- package/dist/core/webhooks.js +206 -0
- package/dist/core/webhooks.js.map +1 -0
- package/dist/mcp-server.d.ts +11 -6
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +99 -6
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools-extended.d.ts +15 -0
- package/dist/mcp-tools-extended.d.ts.map +1 -0
- package/dist/mcp-tools-extended.js +277 -0
- package/dist/mcp-tools-extended.js.map +1 -0
- package/dist/processors/csv.d.ts +18 -0
- package/dist/processors/csv.d.ts.map +1 -0
- package/dist/processors/csv.js +230 -0
- package/dist/processors/csv.js.map +1 -0
- package/dist/processors/image.d.ts.map +1 -1
- package/dist/processors/image.js +55 -27
- package/dist/processors/image.js.map +1 -1
- package/dist/processors/pdf.d.ts.map +1 -1
- package/dist/processors/pdf.js +5 -1
- package/dist/processors/pdf.js.map +1 -1
- package/dist/processors/pptx.d.ts +3 -1
- package/dist/processors/pptx.d.ts.map +1 -1
- package/dist/processors/pptx.js +236 -95
- package/dist/processors/pptx.js.map +1 -1
- package/dist/processors/xlsx.d.ts +2 -0
- package/dist/processors/xlsx.d.ts.map +1 -1
- package/dist/processors/xlsx.js +182 -46
- package/dist/processors/xlsx.js.map +1 -1
- package/dist/templates/source-types.d.ts +33 -0
- package/dist/templates/source-types.d.ts.map +1 -0
- package/dist/templates/source-types.js +178 -0
- package/dist/templates/source-types.js.map +1 -0
- package/dist/web/public/index.html +1785 -103
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +746 -38
- package/dist/web/server.js.map +1 -1
- package/package.json +4 -1
- package/src/web/public/index.html +1785 -103
- package/templates/source-types/article.md +21 -0
- package/templates/source-types/book.md +21 -0
- package/templates/source-types/paper.md +23 -0
- package/templates/source-types/podcast.md +21 -0
- package/templates/source-types/raw-notes.md +17 -0
- package/templates/source-types/tweet-thread.md +19 -0
- package/templates/source-types/video.md +21 -0
package/dist/web/server.js
CHANGED
|
@@ -3,7 +3,8 @@ import { readFileSync, existsSync, writeFileSync, mkdirSync, readdirSync, statSy
|
|
|
3
3
|
import { join, resolve, extname, basename, dirname, relative } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
|
-
import { getVaultConfig, getVaultStats, listWikiPages, readWikiPage, writeWikiPage } from '../core/vault.js';
|
|
6
|
+
import { getVaultConfig, getVaultStats, listWikiPages, readWikiPage, writeWikiPage, readPageVersions } from '../core/vault.js';
|
|
7
|
+
import { getBundledCredentials, getBundledDeviceFlowClientId } from '../core/oauth-defaults.js';
|
|
7
8
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
|
8
9
|
function buildGraph(config) {
|
|
9
10
|
const pages = listWikiPages(config.wikiDir);
|
|
@@ -107,7 +108,15 @@ export function createServer(vaultRoot, port) {
|
|
|
107
108
|
res.status(400).json({ error: 'Missing title or slug' });
|
|
108
109
|
return;
|
|
109
110
|
}
|
|
111
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
|
112
|
+
res.status(400).json({ error: 'Invalid slug — only alphanumeric, hyphens, and underscores allowed' });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
110
115
|
const dest = join(config.wikiDir, `${slug}.md`);
|
|
116
|
+
if (!resolve(dest).startsWith(resolve(config.wikiDir))) {
|
|
117
|
+
res.status(403).json({ error: 'Path traversal denied' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
111
120
|
if (existsSync(dest)) {
|
|
112
121
|
res.status(409).json({ error: 'Page already exists' });
|
|
113
122
|
return;
|
|
@@ -251,6 +260,51 @@ export function createServer(vaultRoot, port) {
|
|
|
251
260
|
res.status(500).json({ error: 'Failed to read raw page' });
|
|
252
261
|
}
|
|
253
262
|
});
|
|
263
|
+
// API: get page version history (COMP-MP-002 temporal reasoning)
|
|
264
|
+
app.get('/api/pages/:title/versions', (req, res) => {
|
|
265
|
+
try {
|
|
266
|
+
const title = req.params['title'];
|
|
267
|
+
if (!title) {
|
|
268
|
+
res.status(400).json({ error: 'Missing title' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
272
|
+
const versions = readPageVersions(config.root, slug);
|
|
273
|
+
// Also include current page as the "latest" entry
|
|
274
|
+
const pages = listWikiPages(config.wikiDir);
|
|
275
|
+
const titleLower = title.toLowerCase();
|
|
276
|
+
const match = pages.find((p) => {
|
|
277
|
+
const fileSlug = basename(p, '.md');
|
|
278
|
+
if (fileSlug === slug)
|
|
279
|
+
return true;
|
|
280
|
+
try {
|
|
281
|
+
return readWikiPage(p).title.toLowerCase() === titleLower;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
let current = null;
|
|
288
|
+
if (match) {
|
|
289
|
+
try {
|
|
290
|
+
const page = readWikiPage(match);
|
|
291
|
+
current = {
|
|
292
|
+
version: page.frontmatter['fact_version'] ?? versions.length + 1,
|
|
293
|
+
timestamp: page.frontmatter['learned_at'] ?? page.frontmatter['updated'] ?? new Date().toISOString(),
|
|
294
|
+
content: page.content,
|
|
295
|
+
frontmatter: page.frontmatter,
|
|
296
|
+
source: page.frontmatter['sources']?.[0],
|
|
297
|
+
actor: page.frontmatter['added_by'] ?? 'unknown',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
catch { /* unreadable */ }
|
|
301
|
+
}
|
|
302
|
+
res.json({ versions, current, total: versions.length + (current ? 1 : 0) });
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
res.status(500).json({ error: 'Failed to read page versions' });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
254
308
|
// API: update page content (full markdown with frontmatter)
|
|
255
309
|
app.put('/api/pages/:title', async (req, res) => {
|
|
256
310
|
try {
|
|
@@ -342,6 +396,70 @@ export function createServer(vaultRoot, port) {
|
|
|
342
396
|
res.status(500).json({ error: `Failed to delete: ${msg}` });
|
|
343
397
|
}
|
|
344
398
|
});
|
|
399
|
+
// API: update validation status / confidence on a wiki page
|
|
400
|
+
app.patch('/api/pages/:title/validate', async (req, res) => {
|
|
401
|
+
try {
|
|
402
|
+
const title = req.params['title'];
|
|
403
|
+
if (!title) {
|
|
404
|
+
res.status(400).json({ error: 'Missing title' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const { validation_status, confidence } = req.body;
|
|
408
|
+
const allowed = ['verified', 'outdated', 'wrong', 'unreviewed'];
|
|
409
|
+
if (validation_status && !allowed.includes(validation_status)) {
|
|
410
|
+
res.status(400).json({ error: `Invalid status. Allowed: ${allowed.join(', ')}` });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const pages = listWikiPages(config.wikiDir);
|
|
414
|
+
const titleLower = title.toLowerCase();
|
|
415
|
+
const slugified = titleLower.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
416
|
+
const match = pages.find((p) => {
|
|
417
|
+
const fileSlug = basename(p, '.md');
|
|
418
|
+
if (fileSlug === title || fileSlug === slugified)
|
|
419
|
+
return true;
|
|
420
|
+
try {
|
|
421
|
+
return readWikiPage(p).title.toLowerCase() === titleLower;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
if (!match) {
|
|
428
|
+
res.status(404).json({ error: 'Page not found' });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const matter = await import('gray-matter');
|
|
432
|
+
const raw = readFileSync(match, 'utf-8');
|
|
433
|
+
const parsed = matter.default(raw);
|
|
434
|
+
if (validation_status)
|
|
435
|
+
parsed.data['validation_status'] = validation_status;
|
|
436
|
+
if (confidence !== undefined)
|
|
437
|
+
parsed.data['confidence'] = Math.max(0, Math.min(100, confidence));
|
|
438
|
+
parsed.data['validated_at'] = new Date().toISOString().split('T')[0];
|
|
439
|
+
// Adjust confidence based on validation feedback
|
|
440
|
+
if (validation_status === 'verified') {
|
|
441
|
+
parsed.data['confidence'] = Math.max(parsed.data['confidence'] ?? 50, 85);
|
|
442
|
+
}
|
|
443
|
+
else if (validation_status === 'outdated') {
|
|
444
|
+
parsed.data['confidence'] = Math.min(parsed.data['confidence'] ?? 50, 40);
|
|
445
|
+
}
|
|
446
|
+
else if (validation_status === 'wrong') {
|
|
447
|
+
parsed.data['confidence'] = Math.min(parsed.data['confidence'] ?? 50, 15);
|
|
448
|
+
}
|
|
449
|
+
writeFileSync(match, matter.default.stringify(parsed.content, parsed.data), 'utf-8');
|
|
450
|
+
try {
|
|
451
|
+
const { autoCommit } = await import('../core/git.js');
|
|
452
|
+
await autoCommit(config.root, 'manual', `validate page "${title}" as ${validation_status ?? 'updated'}`);
|
|
453
|
+
}
|
|
454
|
+
catch { /* git commit is best-effort */ }
|
|
455
|
+
const page = readWikiPage(match);
|
|
456
|
+
res.json({ status: 'updated', page });
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
460
|
+
res.status(500).json({ error: `Failed to validate: ${msg}` });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
345
463
|
// API: rename a wiki page
|
|
346
464
|
app.post('/api/pages/:title/rename', async (req, res) => {
|
|
347
465
|
try {
|
|
@@ -372,7 +490,15 @@ export function createServer(vaultRoot, port) {
|
|
|
372
490
|
const content = readFileSync(match, 'utf-8');
|
|
373
491
|
const newContent = content.replace(/^title:\s*["']?.*?["']?\s*$/m, `title: "${newTitle}"`);
|
|
374
492
|
const newSlug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
493
|
+
if (!newSlug) {
|
|
494
|
+
res.status(400).json({ error: 'Invalid title — produces empty slug' });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
375
497
|
const newPath = join(match.substring(0, match.lastIndexOf('/')), newSlug + '.md');
|
|
498
|
+
if (!resolve(newPath).startsWith(resolve(config.wikiDir))) {
|
|
499
|
+
res.status(403).json({ error: 'Path traversal denied' });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
376
502
|
writeFileSync(newPath, newContent, 'utf-8');
|
|
377
503
|
if (newPath !== match) {
|
|
378
504
|
const { unlinkSync } = await import('node:fs');
|
|
@@ -651,12 +777,16 @@ export function createServer(vaultRoot, port) {
|
|
|
651
777
|
res.json({ available: false, path: null });
|
|
652
778
|
}
|
|
653
779
|
});
|
|
654
|
-
// API: search pages
|
|
780
|
+
// API: search pages (with filters: category, tag, dateFrom, dateTo)
|
|
655
781
|
app.get('/api/search', (req, res) => {
|
|
656
782
|
try {
|
|
657
783
|
const q = (req.query['q'] ?? '').toLowerCase().trim();
|
|
658
784
|
const limit = parseInt(req.query['limit']) || 20;
|
|
659
|
-
|
|
785
|
+
const filterCategory = (req.query['category'] ?? '').toLowerCase().trim();
|
|
786
|
+
const filterTag = (req.query['tag'] ?? '').toLowerCase().trim();
|
|
787
|
+
const filterDateFrom = (req.query['dateFrom'] ?? '').trim();
|
|
788
|
+
const filterDateTo = (req.query['dateTo'] ?? '').trim();
|
|
789
|
+
if (!q && !filterCategory && !filterTag) {
|
|
660
790
|
res.json({ results: [] });
|
|
661
791
|
return;
|
|
662
792
|
}
|
|
@@ -664,33 +794,140 @@ export function createServer(vaultRoot, port) {
|
|
|
664
794
|
const results = [];
|
|
665
795
|
for (const pagePath of pages) {
|
|
666
796
|
const page = readWikiPage(pagePath);
|
|
797
|
+
const category = (page.frontmatter['category'] ?? page.frontmatter['type'] ?? '').toLowerCase();
|
|
798
|
+
const tags = (page.frontmatter['tags'] ?? []).map((t) => t.toLowerCase());
|
|
799
|
+
const created = (page.frontmatter['created'] ?? page.frontmatter['date'] ?? '');
|
|
800
|
+
// Apply filters
|
|
801
|
+
if (filterCategory && category !== filterCategory)
|
|
802
|
+
continue;
|
|
803
|
+
if (filterTag && !tags.includes(filterTag))
|
|
804
|
+
continue;
|
|
805
|
+
if (filterDateFrom && created && created < filterDateFrom)
|
|
806
|
+
continue;
|
|
807
|
+
if (filterDateTo && created && created > filterDateTo)
|
|
808
|
+
continue;
|
|
809
|
+
// If no text query, include all filtered pages
|
|
810
|
+
if (!q) {
|
|
811
|
+
results.push({ title: page.title, category: category || 'uncategorized', tags, created, wordCount: page.wordCount, matchType: 'filter' });
|
|
812
|
+
if (results.length >= limit)
|
|
813
|
+
break;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
// Text matching
|
|
667
817
|
const titleMatch = page.title.toLowerCase().includes(q);
|
|
668
|
-
const tagMatch =
|
|
818
|
+
const tagMatch = tags.some(t => t.includes(q));
|
|
669
819
|
let snippet;
|
|
670
820
|
let contentMatch = false;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
821
|
+
const bodyLower = page.content.toLowerCase();
|
|
822
|
+
const idx = bodyLower.indexOf(q);
|
|
823
|
+
if (idx >= 0) {
|
|
824
|
+
contentMatch = true;
|
|
825
|
+
// Build snippet with highlighted match context
|
|
826
|
+
const start = Math.max(0, idx - 50);
|
|
827
|
+
const end = Math.min(page.content.length, idx + q.length + 80);
|
|
828
|
+
const raw = page.content.substring(start, end).replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
|
829
|
+
// Wrap the matched term in <mark> for highlighting
|
|
830
|
+
const matchStart = idx - start;
|
|
831
|
+
const before = raw.substring(0, matchStart);
|
|
832
|
+
const match = raw.substring(matchStart, matchStart + q.length);
|
|
833
|
+
const after = raw.substring(matchStart + q.length);
|
|
834
|
+
snippet = (start > 0 ? '…' : '') + before + '<mark>' + match + '</mark>' + after + (end < page.content.length ? '…' : '');
|
|
680
835
|
}
|
|
681
836
|
if (titleMatch || tagMatch || contentMatch) {
|
|
682
|
-
const
|
|
683
|
-
results.push({ title: page.title, category, wordCount: page.wordCount, snippet });
|
|
837
|
+
const matchType = titleMatch ? 'title' : tagMatch ? 'tag' : 'content';
|
|
838
|
+
results.push({ title: page.title, category: category || 'uncategorized', tags, created, wordCount: page.wordCount, snippet, matchType });
|
|
684
839
|
}
|
|
685
840
|
if (results.length >= limit)
|
|
686
841
|
break;
|
|
687
842
|
}
|
|
688
|
-
|
|
843
|
+
// Also return available filter options for the UI
|
|
844
|
+
const allCategories = [...new Set(pages.map(p => {
|
|
845
|
+
const pg = readWikiPage(p);
|
|
846
|
+
return (pg.frontmatter['category'] ?? pg.frontmatter['type'] ?? '').toLowerCase();
|
|
847
|
+
}).filter(Boolean))].sort();
|
|
848
|
+
const allTags = [...new Set(pages.flatMap(p => {
|
|
849
|
+
const pg = readWikiPage(p);
|
|
850
|
+
return (pg.frontmatter['tags'] ?? []).map((t) => t.toLowerCase());
|
|
851
|
+
}))].sort();
|
|
852
|
+
res.json({ results, filters: { categories: allCategories, tags: allTags } });
|
|
689
853
|
}
|
|
690
854
|
catch {
|
|
691
855
|
res.json({ results: [] });
|
|
692
856
|
}
|
|
693
857
|
});
|
|
858
|
+
// Utility: extract keywords from text (lowercase, deduplicated, stopwords removed)
|
|
859
|
+
function extractKeywords(text) {
|
|
860
|
+
const stopwords = new Set(['the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can', 'shall', 'and', 'or', 'but', 'if', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'as', 'into', 'about', 'that', 'this', 'it', 'its', 'not', 'no', 'so', 'up', 'out', 'then', 'than', 'more', 'also', 'very', 'just', 'only', 'each', 'any', 'all', 'both', 'few', 'many', 'some', 'such', 'too', 'own', 'same', 'other', 'most', 'much', 'what', 'when', 'where', 'which', 'who', 'how']);
|
|
861
|
+
return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
|
|
862
|
+
.filter(w => w.length > 2 && !stopwords.has(w)));
|
|
863
|
+
}
|
|
864
|
+
// Utility: Jaccard similarity between two sets
|
|
865
|
+
function jaccardSimilarity(a, b) {
|
|
866
|
+
if (a.size === 0 && b.size === 0)
|
|
867
|
+
return 0;
|
|
868
|
+
let intersection = 0;
|
|
869
|
+
for (const item of a) {
|
|
870
|
+
if (b.has(item))
|
|
871
|
+
intersection++;
|
|
872
|
+
}
|
|
873
|
+
const union = a.size + b.size - intersection;
|
|
874
|
+
return union === 0 ? 0 : intersection / union;
|
|
875
|
+
}
|
|
876
|
+
// API: similar pages (Jaccard similarity on tags + wikilinks + title keywords)
|
|
877
|
+
app.get('/api/pages/:title/similar', (req, res) => {
|
|
878
|
+
try {
|
|
879
|
+
const targetTitle = decodeURIComponent(req.params['title'] ?? '');
|
|
880
|
+
const limit = parseInt(req.query['limit']) || 5;
|
|
881
|
+
const pages = listWikiPages(config.wikiDir);
|
|
882
|
+
// Find target page
|
|
883
|
+
let targetPage = null;
|
|
884
|
+
const allPages = [];
|
|
885
|
+
for (const p of pages) {
|
|
886
|
+
const page = readWikiPage(p);
|
|
887
|
+
allPages.push(page);
|
|
888
|
+
if (page.title.toLowerCase() === targetTitle.toLowerCase()) {
|
|
889
|
+
targetPage = page;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (!targetPage) {
|
|
893
|
+
res.json({ similar: [] });
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
// Build feature sets for target
|
|
897
|
+
const targetTags = new Set((targetPage.frontmatter['tags'] ?? []).map((t) => t.toLowerCase()));
|
|
898
|
+
const targetLinks = new Set((targetPage.wikilinks ?? []).map((l) => l.toLowerCase()));
|
|
899
|
+
const targetWords = extractKeywords(targetPage.title + ' ' + targetPage.content);
|
|
900
|
+
const targetCategory = (targetPage.frontmatter['category'] ?? targetPage.frontmatter['type'] ?? '').toLowerCase();
|
|
901
|
+
// Score each other page
|
|
902
|
+
const scored = [];
|
|
903
|
+
for (const page of allPages) {
|
|
904
|
+
if (page.title.toLowerCase() === targetTitle.toLowerCase())
|
|
905
|
+
continue;
|
|
906
|
+
const pageTags = new Set((page.frontmatter['tags'] ?? []).map((t) => t.toLowerCase()));
|
|
907
|
+
const pageLinks = new Set((page.wikilinks ?? []).map((l) => l.toLowerCase()));
|
|
908
|
+
const pageWords = extractKeywords(page.title + ' ' + page.content);
|
|
909
|
+
const pageCategory = (page.frontmatter['category'] ?? page.frontmatter['type'] ?? '').toLowerCase();
|
|
910
|
+
// Jaccard similarity components (weighted)
|
|
911
|
+
const tagSim = jaccardSimilarity(targetTags, pageTags);
|
|
912
|
+
const linkSim = jaccardSimilarity(targetLinks, pageLinks);
|
|
913
|
+
const wordSim = jaccardSimilarity(targetWords, pageWords);
|
|
914
|
+
const catBonus = targetCategory && targetCategory === pageCategory ? 0.15 : 0;
|
|
915
|
+
// Weighted composite: tags 40%, links 30%, keywords 20%, category 10%
|
|
916
|
+
const score = (tagSim * 0.4) + (linkSim * 0.3) + (wordSim * 0.2) + catBonus;
|
|
917
|
+
if (score > 0.02) {
|
|
918
|
+
const sharedTags = [...targetTags].filter(t => pageTags.has(t));
|
|
919
|
+
const sharedLinks = [...targetLinks].filter(l => pageLinks.has(l));
|
|
920
|
+
const category = page.frontmatter['category'] ?? page.frontmatter['type'] ?? 'uncategorized';
|
|
921
|
+
scored.push({ title: page.title, category, score: Math.round(score * 100) / 100, sharedTags, sharedLinks });
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
scored.sort((a, b) => b.score - a.score);
|
|
925
|
+
res.json({ similar: scored.slice(0, limit) });
|
|
926
|
+
}
|
|
927
|
+
catch {
|
|
928
|
+
res.json({ similar: [] });
|
|
929
|
+
}
|
|
930
|
+
});
|
|
694
931
|
// API: wiki history (audit trail)
|
|
695
932
|
app.get('/api/history', async (_req, res) => {
|
|
696
933
|
try {
|
|
@@ -1452,6 +1689,38 @@ export function createServer(vaultRoot, port) {
|
|
|
1452
1689
|
},
|
|
1453
1690
|
};
|
|
1454
1691
|
const oauthStates = new Map();
|
|
1692
|
+
/**
|
|
1693
|
+
* Resolve OAuth credentials: bundled defaults → env vars → config.yaml.
|
|
1694
|
+
* Bundled defaults ship with the npm package (pre-registered WikiMem OAuth apps).
|
|
1695
|
+
* Env vars override bundled defaults (for development/self-hosted).
|
|
1696
|
+
* config.yaml is the user-level fallback.
|
|
1697
|
+
*/
|
|
1698
|
+
function resolveOAuthCredentials(providerName) {
|
|
1699
|
+
const providerConfig = OAUTH_PROVIDERS[providerName];
|
|
1700
|
+
if (!providerConfig)
|
|
1701
|
+
return null;
|
|
1702
|
+
// 1. Environment variables (highest priority — override bundled defaults)
|
|
1703
|
+
const envPrefix = `WIKIMEM_${providerName.toUpperCase()}`;
|
|
1704
|
+
const envId = process.env[`${envPrefix}_CLIENT_ID`];
|
|
1705
|
+
const envSecret = process.env[`${envPrefix}_CLIENT_SECRET`];
|
|
1706
|
+
if (envId && envSecret)
|
|
1707
|
+
return { clientId: envId, clientSecret: envSecret };
|
|
1708
|
+
// 2. Bundled defaults (pre-registered WikiMem OAuth apps shipped with package)
|
|
1709
|
+
const bundled = getBundledCredentials(providerName);
|
|
1710
|
+
if (bundled)
|
|
1711
|
+
return bundled;
|
|
1712
|
+
// 3. User-configured credentials in config.yaml
|
|
1713
|
+
try {
|
|
1714
|
+
const { loadConfig: lc } = require('../core/config.js');
|
|
1715
|
+
const userConfig = lc(config.configPath);
|
|
1716
|
+
const configId = userConfig[providerConfig.clientIdKey];
|
|
1717
|
+
const configSecret = userConfig[providerConfig.clientSecretKey];
|
|
1718
|
+
if (configId && configSecret)
|
|
1719
|
+
return { clientId: configId, clientSecret: configSecret };
|
|
1720
|
+
}
|
|
1721
|
+
catch { /* config load failure is non-fatal */ }
|
|
1722
|
+
return null;
|
|
1723
|
+
}
|
|
1455
1724
|
function getTokenStorePath() {
|
|
1456
1725
|
return join(vaultRoot, '.wikimem', 'tokens.json');
|
|
1457
1726
|
}
|
|
@@ -1473,14 +1742,20 @@ export function createServer(vaultRoot, port) {
|
|
|
1473
1742
|
tokens[provider] = { ...tokenData, connectedAt: new Date().toISOString() };
|
|
1474
1743
|
writeFileSync(tokenPath, JSON.stringify(tokens, null, 2), 'utf-8');
|
|
1475
1744
|
}
|
|
1476
|
-
// GET /api/auth/tokens — list which providers have tokens stored
|
|
1745
|
+
// GET /api/auth/tokens — list which providers have tokens stored + credential availability
|
|
1477
1746
|
app.get('/api/auth/tokens', (_req, res) => {
|
|
1478
1747
|
try {
|
|
1479
1748
|
const tokens = loadOAuthTokens();
|
|
1480
1749
|
const result = {};
|
|
1481
1750
|
for (const provider of Object.keys(OAUTH_PROVIDERS)) {
|
|
1482
1751
|
const token = tokens[provider];
|
|
1483
|
-
|
|
1752
|
+
const creds = resolveOAuthCredentials(provider);
|
|
1753
|
+
result[provider] = {
|
|
1754
|
+
connected: !!token,
|
|
1755
|
+
connectedAt: token?.connectedAt,
|
|
1756
|
+
hasCredentials: !!creds,
|
|
1757
|
+
...(provider === 'github' ? { hasDeviceFlow: !!resolveDeviceFlowClientId() } : {}),
|
|
1758
|
+
};
|
|
1484
1759
|
}
|
|
1485
1760
|
res.json(result);
|
|
1486
1761
|
}
|
|
@@ -1501,11 +1776,9 @@ export function createServer(vaultRoot, port) {
|
|
|
1501
1776
|
res.status(400).json({ error: `Unknown provider: ${providerName}` });
|
|
1502
1777
|
return;
|
|
1503
1778
|
}
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
if (!clientId) {
|
|
1508
|
-
res.status(400).json({ error: `Missing ${providerConfig.clientIdKey} in config.yaml` });
|
|
1779
|
+
const creds = resolveOAuthCredentials(providerName);
|
|
1780
|
+
if (!creds) {
|
|
1781
|
+
res.status(400).json({ error: 'no_credentials', message: `No OAuth credentials found for ${providerName}. Add credentials in Settings or set WIKIMEM_${providerName.toUpperCase()}_CLIENT_ID / _CLIENT_SECRET env vars.` });
|
|
1509
1782
|
return;
|
|
1510
1783
|
}
|
|
1511
1784
|
const state = randomBytes(24).toString('hex');
|
|
@@ -1517,7 +1790,7 @@ export function createServer(vaultRoot, port) {
|
|
|
1517
1790
|
}
|
|
1518
1791
|
const redirectUri = `http://localhost:${port}/api/auth/callback`;
|
|
1519
1792
|
const params = new URLSearchParams({
|
|
1520
|
-
client_id: clientId,
|
|
1793
|
+
client_id: creds.clientId,
|
|
1521
1794
|
redirect_uri: redirectUri,
|
|
1522
1795
|
scope: providerConfig.scopes,
|
|
1523
1796
|
state,
|
|
@@ -1553,12 +1826,9 @@ export function createServer(vaultRoot, port) {
|
|
|
1553
1826
|
res.status(400).send('<h2>Unknown provider</h2>');
|
|
1554
1827
|
return;
|
|
1555
1828
|
}
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
const clientSecret = userConfig[providerConfig.clientSecretKey];
|
|
1560
|
-
if (!clientId || !clientSecret) {
|
|
1561
|
-
res.status(400).send('<h2>Missing client credentials in config</h2>');
|
|
1829
|
+
const creds = resolveOAuthCredentials(stateData.provider);
|
|
1830
|
+
if (!creds) {
|
|
1831
|
+
res.status(400).send('<h2>Missing client credentials</h2><p>Configure OAuth credentials in Settings or via environment variables.</p>');
|
|
1562
1832
|
return;
|
|
1563
1833
|
}
|
|
1564
1834
|
const redirectUri = `http://localhost:${port}/api/auth/callback`;
|
|
@@ -1569,8 +1839,8 @@ export function createServer(vaultRoot, port) {
|
|
|
1569
1839
|
Accept: 'application/json',
|
|
1570
1840
|
},
|
|
1571
1841
|
body: new URLSearchParams({
|
|
1572
|
-
client_id: clientId,
|
|
1573
|
-
client_secret: clientSecret,
|
|
1842
|
+
client_id: creds.clientId,
|
|
1843
|
+
client_secret: creds.clientSecret,
|
|
1574
1844
|
code,
|
|
1575
1845
|
redirect_uri: redirectUri,
|
|
1576
1846
|
grant_type: 'authorization_code',
|
|
@@ -1587,11 +1857,20 @@ export function createServer(vaultRoot, port) {
|
|
|
1587
1857
|
refresh_token: tokenBody['refresh_token'],
|
|
1588
1858
|
scope: tokenBody['scope'],
|
|
1589
1859
|
});
|
|
1860
|
+
// Auto-trigger sync after successful OAuth connection (fire-and-forget)
|
|
1861
|
+
import('../core/sync/index.js').then(({ syncProvider: sp }) => {
|
|
1862
|
+
sp(stateData.provider, vaultRoot).catch(() => { });
|
|
1863
|
+
}).catch(() => { });
|
|
1864
|
+
const providerDisplay = stateData.provider.charAt(0).toUpperCase() + stateData.provider.slice(1);
|
|
1590
1865
|
res.send(`<!DOCTYPE html><html><head><style>
|
|
1591
1866
|
body { font-family: Inter, system-ui, sans-serif; background: #1e1e1e; color: #ccc; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
1592
1867
|
.card { background: #252526; border: 1px solid #3e3e3e; border-radius: 12px; padding: 32px 40px; text-align: center; }
|
|
1593
1868
|
h2 { color: #4ec9b0; margin: 0 0 8px; } p { color: #808080; font-size: 14px; }
|
|
1594
|
-
|
|
1869
|
+
.spin { width:20px;height:20px;border:2px solid #3e3e3e;border-top-color:#4ec9b0;border-radius:50%;animation:s 1s linear infinite;margin:12px auto 0 }
|
|
1870
|
+
@keyframes s { to { transform:rotate(360deg) } }
|
|
1871
|
+
</style></head><body><div class="card"><h2>Connected!</h2><p>${providerDisplay} is now linked to WikiMem.</p><div class="spin"></div><p style="margin-top:8px"><small>Syncing your data... This window will close automatically.</small></p></div>
|
|
1872
|
+
<script>if(window.opener){window.opener.postMessage({type:'wikimem-oauth-connected',provider:'${stateData.provider}'},'*');}setTimeout(function(){window.close()},3000)</script>
|
|
1873
|
+
</body></html>`);
|
|
1595
1874
|
}
|
|
1596
1875
|
catch (err) {
|
|
1597
1876
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -1618,6 +1897,158 @@ export function createServer(vaultRoot, port) {
|
|
|
1618
1897
|
res.status(500).json({ error: `Disconnect failed: ${msg}` });
|
|
1619
1898
|
}
|
|
1620
1899
|
});
|
|
1900
|
+
// POST /api/auth/tokens/:provider — manually store an API key (for Notion, etc.)
|
|
1901
|
+
app.post('/api/auth/tokens/:provider', (req, res) => {
|
|
1902
|
+
try {
|
|
1903
|
+
const provider = req.params['provider'];
|
|
1904
|
+
if (!provider) {
|
|
1905
|
+
res.status(400).json({ error: 'Missing provider' });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const { api_key } = req.body;
|
|
1909
|
+
if (!api_key) {
|
|
1910
|
+
res.status(400).json({ error: 'Missing api_key' });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
saveOAuthToken(provider, { access_token: api_key });
|
|
1914
|
+
res.json({ status: 'connected', provider });
|
|
1915
|
+
}
|
|
1916
|
+
catch (err) {
|
|
1917
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1918
|
+
res.status(500).json({ error: `Save token failed: ${msg}` });
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
// ─── GitHub Device Flow (no client_secret needed) ──────────────────────────
|
|
1922
|
+
/**
|
|
1923
|
+
* Resolve the GitHub client ID for Device Flow.
|
|
1924
|
+
* Priority: env vars → bundled defaults → config.yaml
|
|
1925
|
+
*/
|
|
1926
|
+
function resolveDeviceFlowClientId() {
|
|
1927
|
+
// 1. Env vars (highest priority)
|
|
1928
|
+
const deviceId = process.env['WIKIMEM_GITHUB_DEVICE_CLIENT_ID'];
|
|
1929
|
+
if (deviceId)
|
|
1930
|
+
return deviceId;
|
|
1931
|
+
const regularId = process.env['WIKIMEM_GITHUB_CLIENT_ID'];
|
|
1932
|
+
if (regularId)
|
|
1933
|
+
return regularId;
|
|
1934
|
+
// 2. Bundled defaults (shipped with package)
|
|
1935
|
+
const bundled = getBundledDeviceFlowClientId();
|
|
1936
|
+
if (bundled)
|
|
1937
|
+
return bundled;
|
|
1938
|
+
// 3. config.yaml (user-configured)
|
|
1939
|
+
try {
|
|
1940
|
+
const { loadConfig: lc } = require('../core/config.js');
|
|
1941
|
+
const userConfig = lc(config.configPath);
|
|
1942
|
+
const configId = userConfig['github_client_id'];
|
|
1943
|
+
if (configId)
|
|
1944
|
+
return configId;
|
|
1945
|
+
}
|
|
1946
|
+
catch { /* config load failure is non-fatal */ }
|
|
1947
|
+
return null;
|
|
1948
|
+
}
|
|
1949
|
+
// POST /api/auth/device-flow/start — initiate GitHub Device Flow
|
|
1950
|
+
app.post('/api/auth/device-flow/start', async (_req, res) => {
|
|
1951
|
+
try {
|
|
1952
|
+
const clientId = resolveDeviceFlowClientId();
|
|
1953
|
+
if (!clientId) {
|
|
1954
|
+
res.status(400).json({
|
|
1955
|
+
error: 'no_client_id',
|
|
1956
|
+
message: 'No GitHub client ID found. Set WIKIMEM_GITHUB_DEVICE_CLIENT_ID or WIKIMEM_GITHUB_CLIENT_ID env var.',
|
|
1957
|
+
});
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
const ghRes = await fetch('https://github.com/login/device/code', {
|
|
1961
|
+
method: 'POST',
|
|
1962
|
+
headers: {
|
|
1963
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1964
|
+
Accept: 'application/json',
|
|
1965
|
+
},
|
|
1966
|
+
body: new URLSearchParams({
|
|
1967
|
+
client_id: clientId,
|
|
1968
|
+
scope: 'repo read:user',
|
|
1969
|
+
}),
|
|
1970
|
+
});
|
|
1971
|
+
const body = await ghRes.json();
|
|
1972
|
+
if (body['error']) {
|
|
1973
|
+
res.status(400).json({ error: body['error'], message: body['error_description'] ?? 'Device flow initiation failed' });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
res.json({
|
|
1977
|
+
device_code: body['device_code'],
|
|
1978
|
+
user_code: body['user_code'],
|
|
1979
|
+
verification_uri: body['verification_uri'],
|
|
1980
|
+
expires_in: body['expires_in'],
|
|
1981
|
+
interval: body['interval'],
|
|
1982
|
+
});
|
|
1983
|
+
}
|
|
1984
|
+
catch (err) {
|
|
1985
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1986
|
+
res.status(500).json({ error: `Device flow start failed: ${msg}` });
|
|
1987
|
+
}
|
|
1988
|
+
});
|
|
1989
|
+
// POST /api/auth/device-flow/poll — poll for token after user enters code
|
|
1990
|
+
app.post('/api/auth/device-flow/poll', async (req, res) => {
|
|
1991
|
+
try {
|
|
1992
|
+
const { device_code } = req.body;
|
|
1993
|
+
if (!device_code) {
|
|
1994
|
+
res.status(400).json({ error: 'missing_device_code', message: 'device_code is required' });
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
const clientId = resolveDeviceFlowClientId();
|
|
1998
|
+
if (!clientId) {
|
|
1999
|
+
res.status(400).json({ error: 'no_client_id', message: 'No GitHub client ID configured' });
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
const ghRes = await fetch('https://github.com/login/oauth/access_token', {
|
|
2003
|
+
method: 'POST',
|
|
2004
|
+
headers: {
|
|
2005
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2006
|
+
Accept: 'application/json',
|
|
2007
|
+
},
|
|
2008
|
+
body: new URLSearchParams({
|
|
2009
|
+
client_id: clientId,
|
|
2010
|
+
device_code,
|
|
2011
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
2012
|
+
}),
|
|
2013
|
+
});
|
|
2014
|
+
const body = await ghRes.json();
|
|
2015
|
+
const error = body['error'];
|
|
2016
|
+
if (error === 'authorization_pending') {
|
|
2017
|
+
res.json({ status: 'pending' });
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (error === 'slow_down') {
|
|
2021
|
+
res.json({ status: 'slow_down' });
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
if (error === 'expired_token') {
|
|
2025
|
+
res.json({ status: 'expired' });
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
if (error) {
|
|
2029
|
+
res.status(400).json({ status: 'error', error, message: body['error_description'] ?? 'Token exchange failed' });
|
|
2030
|
+
return;
|
|
2031
|
+
}
|
|
2032
|
+
const accessToken = body['access_token'];
|
|
2033
|
+
if (!accessToken) {
|
|
2034
|
+
res.status(400).json({ status: 'error', error: 'no_token', message: 'No access_token in response' });
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
saveOAuthToken('github', {
|
|
2038
|
+
access_token: accessToken,
|
|
2039
|
+
scope: body['scope'],
|
|
2040
|
+
});
|
|
2041
|
+
// Auto-trigger GitHub sync after device flow connection (fire-and-forget)
|
|
2042
|
+
import('../core/sync/index.js').then(({ syncProvider: sp }) => {
|
|
2043
|
+
sp('github', vaultRoot).catch(() => { });
|
|
2044
|
+
}).catch(() => { });
|
|
2045
|
+
res.json({ status: 'complete', access_token: accessToken });
|
|
2046
|
+
}
|
|
2047
|
+
catch (err) {
|
|
2048
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2049
|
+
res.status(500).json({ error: `Device flow poll failed: ${msg}` });
|
|
2050
|
+
}
|
|
2051
|
+
});
|
|
1621
2052
|
// Pipeline SSE endpoint — real-time step updates
|
|
1622
2053
|
app.get('/api/pipeline/events', (_req, res) => {
|
|
1623
2054
|
res.writeHead(200, {
|
|
@@ -1956,10 +2387,11 @@ export function createServer(vaultRoot, port) {
|
|
|
1956
2387
|
});
|
|
1957
2388
|
// ─── Observer APIs ────────────────────────────────────────────────────────
|
|
1958
2389
|
// POST /api/observer/run — trigger observer manually
|
|
1959
|
-
app.post('/api/observer/run', async (
|
|
2390
|
+
app.post('/api/observer/run', async (req, res) => {
|
|
1960
2391
|
try {
|
|
2392
|
+
const { autoImprove, maxImprovements, maxBudget, model } = req.body ?? {};
|
|
1961
2393
|
const { runObserver } = await import('../core/observer.js');
|
|
1962
|
-
const report = await runObserver(config);
|
|
2394
|
+
const report = await runObserver(config, { autoImprove, maxImprovements, maxBudget, model });
|
|
1963
2395
|
res.json({
|
|
1964
2396
|
success: true,
|
|
1965
2397
|
date: report.date,
|
|
@@ -1970,6 +2402,16 @@ export function createServer(vaultRoot, port) {
|
|
|
1970
2402
|
orphanCount: report.orphans.length,
|
|
1971
2403
|
gapCount: report.gaps.length,
|
|
1972
2404
|
contradictionCount: report.contradictions.length,
|
|
2405
|
+
contradictions: report.contradictions,
|
|
2406
|
+
improvementCount: report.improvements?.length ?? 0,
|
|
2407
|
+
improvements: report.improvements ?? [],
|
|
2408
|
+
topIssues: report.topIssues ?? [],
|
|
2409
|
+
weakestPages: report.scores.filter(s => s.score < report.maxScore).slice(0, 10).map(s => ({
|
|
2410
|
+
title: s.title,
|
|
2411
|
+
score: s.score,
|
|
2412
|
+
maxScore: s.maxScore,
|
|
2413
|
+
issues: s.issues,
|
|
2414
|
+
})),
|
|
1973
2415
|
reportPath: `.wikimem/observer-reports/${report.date}.json`,
|
|
1974
2416
|
});
|
|
1975
2417
|
}
|
|
@@ -2009,6 +2451,117 @@ export function createServer(vaultRoot, port) {
|
|
|
2009
2451
|
res.status(500).json({ error: String(err) });
|
|
2010
2452
|
}
|
|
2011
2453
|
});
|
|
2454
|
+
// ─── Lint / Health Check ──────────────────────────────────────────────────
|
|
2455
|
+
// POST /api/lint — run wiki lint check (includes contradiction detection)
|
|
2456
|
+
app.post('/api/lint', async (_req, res) => {
|
|
2457
|
+
try {
|
|
2458
|
+
const { lintWiki } = await import('../core/lint.js');
|
|
2459
|
+
const { createProviderFromUserConfig } = await import('../providers/index.js');
|
|
2460
|
+
const { loadConfig } = await import('../core/config.js');
|
|
2461
|
+
const userConfig = loadConfig(config.configPath);
|
|
2462
|
+
const provider = createProviderFromUserConfig(userConfig);
|
|
2463
|
+
const result = await lintWiki(config, provider, { fix: false });
|
|
2464
|
+
res.json(result);
|
|
2465
|
+
}
|
|
2466
|
+
catch (err) {
|
|
2467
|
+
res.status(500).json({ error: String(err) });
|
|
2468
|
+
}
|
|
2469
|
+
});
|
|
2470
|
+
// GET /api/contradictions — get detected contradictions with page content for side-by-side diff
|
|
2471
|
+
app.get('/api/contradictions', async (_req, res) => {
|
|
2472
|
+
try {
|
|
2473
|
+
const { flagContradictions } = await import('../core/observer.js');
|
|
2474
|
+
const pages = listWikiPages(config.wikiDir);
|
|
2475
|
+
const contradictions = flagContradictions(pages);
|
|
2476
|
+
// Enrich with page content for side-by-side display
|
|
2477
|
+
const enriched = contradictions.map(c => {
|
|
2478
|
+
const pageA = readWikiPage(c.pageA);
|
|
2479
|
+
const pageB = readWikiPage(c.pageB);
|
|
2480
|
+
return {
|
|
2481
|
+
...c,
|
|
2482
|
+
summaryA: String(pageA.frontmatter['summary'] ?? '').slice(0, 500),
|
|
2483
|
+
summaryB: String(pageB.frontmatter['summary'] ?? '').slice(0, 500),
|
|
2484
|
+
contentSnippetA: pageA.content.slice(0, 300),
|
|
2485
|
+
contentSnippetB: pageB.content.slice(0, 300),
|
|
2486
|
+
};
|
|
2487
|
+
});
|
|
2488
|
+
res.json({ contradictions: enriched, count: enriched.length });
|
|
2489
|
+
}
|
|
2490
|
+
catch (err) {
|
|
2491
|
+
res.status(500).json({ error: String(err) });
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
// POST /api/contradictions/resolve — resolve a detected contradiction
|
|
2495
|
+
app.post('/api/contradictions/resolve', async (req, res) => {
|
|
2496
|
+
try {
|
|
2497
|
+
const { pageAPath, pageBPath, resolution, reason } = req.body;
|
|
2498
|
+
if (!pageAPath || !pageBPath || !resolution) {
|
|
2499
|
+
res.status(400).json({ error: 'Missing pageAPath, pageBPath, or resolution' });
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
const { readFileSync, writeFileSync } = await import('node:fs');
|
|
2503
|
+
const matter = (await import('gray-matter')).default;
|
|
2504
|
+
const { basename } = await import('node:path');
|
|
2505
|
+
// Read both pages
|
|
2506
|
+
const rawA = readFileSync(pageAPath, 'utf-8');
|
|
2507
|
+
const rawB = readFileSync(pageBPath, 'utf-8');
|
|
2508
|
+
const parsedA = matter(rawA);
|
|
2509
|
+
const parsedB = matter(rawB);
|
|
2510
|
+
const now = new Date().toISOString().split('T')[0] ?? '';
|
|
2511
|
+
const entry = {
|
|
2512
|
+
date: now,
|
|
2513
|
+
resolution,
|
|
2514
|
+
opposing_page: '',
|
|
2515
|
+
reason: reason ?? '',
|
|
2516
|
+
};
|
|
2517
|
+
// Add conflicts_resolved to the page that "won" or both for merge/dismiss
|
|
2518
|
+
if (resolution === 'keep-a' || resolution === 'merge' || resolution === 'dismiss') {
|
|
2519
|
+
const existing = Array.isArray(parsedA.data['conflicts_resolved'])
|
|
2520
|
+
? parsedA.data['conflicts_resolved']
|
|
2521
|
+
: [];
|
|
2522
|
+
entry.opposing_page = basename(pageBPath, '.md');
|
|
2523
|
+
existing.push({ ...entry });
|
|
2524
|
+
parsedA.data['conflicts_resolved'] = existing;
|
|
2525
|
+
parsedA.data['updated'] = now;
|
|
2526
|
+
writeFileSync(pageAPath, matter.stringify(parsedA.content, parsedA.data), 'utf-8');
|
|
2527
|
+
}
|
|
2528
|
+
if (resolution === 'keep-b' || resolution === 'merge' || resolution === 'dismiss') {
|
|
2529
|
+
const existing = Array.isArray(parsedB.data['conflicts_resolved'])
|
|
2530
|
+
? parsedB.data['conflicts_resolved']
|
|
2531
|
+
: [];
|
|
2532
|
+
entry.opposing_page = basename(pageAPath, '.md');
|
|
2533
|
+
existing.push({ ...entry });
|
|
2534
|
+
parsedB.data['conflicts_resolved'] = existing;
|
|
2535
|
+
parsedB.data['updated'] = now;
|
|
2536
|
+
writeFileSync(pageBPath, matter.stringify(parsedB.content, parsedB.data), 'utf-8');
|
|
2537
|
+
}
|
|
2538
|
+
// Auto-commit the resolution
|
|
2539
|
+
try {
|
|
2540
|
+
const { autoCommit, isGitRepo } = await import('../core/git.js');
|
|
2541
|
+
if (await isGitRepo(vaultRoot)) {
|
|
2542
|
+
const titleA = String(parsedA.data['title'] ?? basename(pageAPath, '.md'));
|
|
2543
|
+
const titleB = String(parsedB.data['title'] ?? basename(pageBPath, '.md'));
|
|
2544
|
+
await autoCommit(vaultRoot, 'resolve', `conflict between "${titleA}" and "${titleB}" → ${resolution}`, reason ?? '');
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
catch { /* non-fatal */ }
|
|
2548
|
+
// Audit trail
|
|
2549
|
+
try {
|
|
2550
|
+
const { appendAuditEntry } = await import('../core/audit-trail.js');
|
|
2551
|
+
appendAuditEntry(vaultRoot, {
|
|
2552
|
+
action: 'edit',
|
|
2553
|
+
actor: 'human',
|
|
2554
|
+
summary: `Resolved conflict: ${resolution} (${basename(pageAPath, '.md')} vs ${basename(pageBPath, '.md')})${reason ? ': ' + reason : ''}`,
|
|
2555
|
+
pagesAffected: [basename(pageAPath, '.md'), basename(pageBPath, '.md')],
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
catch { /* non-fatal */ }
|
|
2559
|
+
res.json({ success: true, resolution });
|
|
2560
|
+
}
|
|
2561
|
+
catch (err) {
|
|
2562
|
+
res.status(500).json({ error: String(err) });
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2012
2565
|
// ─── Automations: Scrape ──────────────────────────────────────────────────
|
|
2013
2566
|
// POST /api/automations/scrape — trigger scrape for a source (or all)
|
|
2014
2567
|
app.post('/api/automations/scrape', async (req, res) => {
|
|
@@ -2112,8 +2665,17 @@ export function createServer(vaultRoot, port) {
|
|
|
2112
2665
|
return;
|
|
2113
2666
|
}
|
|
2114
2667
|
let resolvedPath = body.path || '';
|
|
2115
|
-
//
|
|
2116
|
-
if (body.type === '
|
|
2668
|
+
// RSS and Notion connectors don't need a filesystem path
|
|
2669
|
+
if (body.type === 'rss' || body.type === 'notion') {
|
|
2670
|
+
if (!body.url && body.type === 'rss') {
|
|
2671
|
+
res.status(400).json({ error: 'RSS connector requires a feed URL' });
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
resolvedPath = resolvedPath || join(vaultRoot, 'raw');
|
|
2675
|
+
mkdirSync(resolvedPath, { recursive: true });
|
|
2676
|
+
}
|
|
2677
|
+
else if (body.type === 'github' || (body.type === 'git-repo' && body.url)) {
|
|
2678
|
+
// For git repos, clone if URL provided
|
|
2117
2679
|
const reposDir = join(vaultRoot, '.wikimem-repos');
|
|
2118
2680
|
mkdirSync(reposDir, { recursive: true });
|
|
2119
2681
|
const repoName = (body.url ?? '').split('/').pop()?.replace('.git', '') || 'repo';
|
|
@@ -2132,12 +2694,13 @@ export function createServer(vaultRoot, port) {
|
|
|
2132
2694
|
}
|
|
2133
2695
|
const connector = mgr.add({
|
|
2134
2696
|
type: body.type,
|
|
2135
|
-
name: body.name || basename(resolvedPath),
|
|
2697
|
+
name: body.name || (body.type === 'rss' ? (body.url ?? 'RSS Feed') : basename(resolvedPath)),
|
|
2136
2698
|
path: resolvedPath,
|
|
2137
2699
|
url: body.url,
|
|
2138
2700
|
includeGlobs: body.includeGlobs,
|
|
2139
2701
|
excludeGlobs: body.excludeGlobs,
|
|
2140
2702
|
autoSync: body.autoSync ?? false,
|
|
2703
|
+
syncSchedule: body.syncSchedule,
|
|
2141
2704
|
});
|
|
2142
2705
|
if (connector.autoSync)
|
|
2143
2706
|
mgr.startWatcher(connector);
|
|
@@ -2226,6 +2789,151 @@ export function createServer(vaultRoot, port) {
|
|
|
2226
2789
|
res.status(500).json({ error: String(err) });
|
|
2227
2790
|
}
|
|
2228
2791
|
});
|
|
2792
|
+
// ─── Platform Sync APIs ──────────────────────────────────────────────────
|
|
2793
|
+
// Shared scheduler singleton — reused across routes and startup
|
|
2794
|
+
let syncScheduler = null;
|
|
2795
|
+
async function getSyncScheduler() {
|
|
2796
|
+
if (!syncScheduler) {
|
|
2797
|
+
const { SyncScheduler } = await import('../core/sync/index.js');
|
|
2798
|
+
syncScheduler = new SyncScheduler(vaultRoot);
|
|
2799
|
+
}
|
|
2800
|
+
return syncScheduler;
|
|
2801
|
+
}
|
|
2802
|
+
// IMPORTANT: Specific routes MUST be registered before the generic :provider route
|
|
2803
|
+
// to avoid Express matching "rss" or "schedules" as a provider name.
|
|
2804
|
+
// GET /api/sync/schedules — list all active sync schedules
|
|
2805
|
+
app.get('/api/sync/schedules', async (_req, res) => {
|
|
2806
|
+
try {
|
|
2807
|
+
const scheduler = await getSyncScheduler();
|
|
2808
|
+
res.json({ schedules: scheduler.getSchedules() });
|
|
2809
|
+
}
|
|
2810
|
+
catch (err) {
|
|
2811
|
+
res.status(500).json({ error: String(err) });
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
// POST /api/sync/rss/:connectorId — trigger RSS feed sync for a specific connector
|
|
2815
|
+
app.post('/api/sync/rss/:connectorId', async (req, res) => {
|
|
2816
|
+
try {
|
|
2817
|
+
const connectorId = req.params['connectorId'];
|
|
2818
|
+
if (!connectorId) {
|
|
2819
|
+
res.status(400).json({ error: 'Missing connectorId' });
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
const { syncRssConnector } = await import('../core/sync/index.js');
|
|
2823
|
+
const result = await syncRssConnector(connectorId, vaultRoot);
|
|
2824
|
+
res.json(result);
|
|
2825
|
+
}
|
|
2826
|
+
catch (err) {
|
|
2827
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2828
|
+
res.status(500).json({ error: `RSS sync failed: ${msg}` });
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
// POST /api/sync/:provider/schedule — set sync schedule for a provider
|
|
2832
|
+
app.post('/api/sync/:provider/schedule', async (req, res) => {
|
|
2833
|
+
try {
|
|
2834
|
+
const provider = req.params['provider'];
|
|
2835
|
+
const { schedule } = req.body;
|
|
2836
|
+
if (!provider || !schedule) {
|
|
2837
|
+
res.status(400).json({ error: 'Missing provider or schedule' });
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
const scheduler = await getSyncScheduler();
|
|
2841
|
+
scheduler.schedule(provider, schedule);
|
|
2842
|
+
res.json({ status: 'scheduled', provider, schedule });
|
|
2843
|
+
}
|
|
2844
|
+
catch (err) {
|
|
2845
|
+
res.status(500).json({ error: String(err) });
|
|
2846
|
+
}
|
|
2847
|
+
});
|
|
2848
|
+
// POST /api/sync/:provider — trigger sync for a connected OAuth provider
|
|
2849
|
+
// MUST be last among /api/sync/* routes (generic :provider catches everything)
|
|
2850
|
+
app.post('/api/sync/:provider', async (req, res) => {
|
|
2851
|
+
try {
|
|
2852
|
+
const provider = req.params['provider'];
|
|
2853
|
+
if (!provider) {
|
|
2854
|
+
res.status(400).json({ error: 'Missing provider' });
|
|
2855
|
+
return;
|
|
2856
|
+
}
|
|
2857
|
+
const { syncProvider } = await import('../core/sync/index.js');
|
|
2858
|
+
const result = await syncProvider(provider, vaultRoot);
|
|
2859
|
+
res.json(result);
|
|
2860
|
+
}
|
|
2861
|
+
catch (err) {
|
|
2862
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2863
|
+
res.status(500).json({ error: `Sync failed: ${msg}` });
|
|
2864
|
+
}
|
|
2865
|
+
});
|
|
2866
|
+
// ─── Enhanced Webhooks ──────────────────────────────────────────────────
|
|
2867
|
+
// POST /api/webhook/github — receive GitHub push/issue/PR webhooks
|
|
2868
|
+
app.post('/api/webhook/github', async (req, res) => {
|
|
2869
|
+
try {
|
|
2870
|
+
const { parseGitHubWebhook, webhookToMarkdown } = await import('../core/webhooks.js');
|
|
2871
|
+
const headers = {};
|
|
2872
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
2873
|
+
if (typeof v === 'string')
|
|
2874
|
+
headers[k] = v;
|
|
2875
|
+
}
|
|
2876
|
+
const payload = parseGitHubWebhook(headers, req.body);
|
|
2877
|
+
if (!payload) {
|
|
2878
|
+
res.status(200).json({ status: 'ignored' });
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
const markdown = webhookToMarkdown(payload);
|
|
2882
|
+
const { mkdirSync: mkdir, writeFileSync: write } = await import('node:fs');
|
|
2883
|
+
const { join: joinPath } = await import('node:path');
|
|
2884
|
+
const { slugify } = await import('../core/vault.js');
|
|
2885
|
+
const now = new Date().toISOString().split('T')[0] ?? '';
|
|
2886
|
+
const dateDir = joinPath(config.rawDir, now);
|
|
2887
|
+
mkdir(dateDir, { recursive: true });
|
|
2888
|
+
const filePath = joinPath(dateDir, `github-${payload.event}-${slugify(payload.title.substring(0, 40))}.md`);
|
|
2889
|
+
write(filePath, markdown, 'utf-8');
|
|
2890
|
+
res.json({ status: 'received', event: payload.event, title: payload.title });
|
|
2891
|
+
}
|
|
2892
|
+
catch (err) {
|
|
2893
|
+
res.status(500).json({ error: String(err) });
|
|
2894
|
+
}
|
|
2895
|
+
});
|
|
2896
|
+
// POST /api/webhook/slack — receive Slack events and slash commands
|
|
2897
|
+
app.post('/api/webhook/slack', async (req, res) => {
|
|
2898
|
+
try {
|
|
2899
|
+
const { parseSlackWebhook, webhookToMarkdown } = await import('../core/webhooks.js');
|
|
2900
|
+
const headers = {};
|
|
2901
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
2902
|
+
if (typeof v === 'string')
|
|
2903
|
+
headers[k] = v;
|
|
2904
|
+
}
|
|
2905
|
+
const payload = parseSlackWebhook(headers, req.body);
|
|
2906
|
+
if (!payload) {
|
|
2907
|
+
res.status(200).json({ status: 'ignored' });
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
// Handle Slack URL verification challenge
|
|
2911
|
+
if (payload.event === 'url_verification') {
|
|
2912
|
+
res.json({ challenge: payload.content });
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
const markdown = webhookToMarkdown(payload);
|
|
2916
|
+
const { mkdirSync: mkdir, writeFileSync: write } = await import('node:fs');
|
|
2917
|
+
const { join: joinPath } = await import('node:path');
|
|
2918
|
+
const { slugify } = await import('../core/vault.js');
|
|
2919
|
+
const now = new Date().toISOString().split('T')[0] ?? '';
|
|
2920
|
+
const dateDir = joinPath(config.rawDir, now);
|
|
2921
|
+
mkdir(dateDir, { recursive: true });
|
|
2922
|
+
const filePath = joinPath(dateDir, `slack-${payload.event}-${slugify(payload.title.substring(0, 40))}.md`);
|
|
2923
|
+
write(filePath, markdown, 'utf-8');
|
|
2924
|
+
res.json({ status: 'received', event: payload.event, title: payload.title });
|
|
2925
|
+
}
|
|
2926
|
+
catch (err) {
|
|
2927
|
+
res.status(500).json({ error: String(err) });
|
|
2928
|
+
}
|
|
2929
|
+
});
|
|
2930
|
+
// Start SyncScheduler for connected providers (reuses singleton from routes)
|
|
2931
|
+
getSyncScheduler().then((scheduler) => {
|
|
2932
|
+
scheduler.startFromConfig();
|
|
2933
|
+
scheduler.on('sync-complete', (result) => {
|
|
2934
|
+
console.log(`[sync] ${result.provider}: ${result.filesWritten} files synced`);
|
|
2935
|
+
});
|
|
2936
|
+
}).catch(() => { });
|
|
2229
2937
|
// Wire file-detected events from connectors to auto-ingest
|
|
2230
2938
|
(async () => {
|
|
2231
2939
|
const { getConnectorManager } = await import('../core/connectors.js');
|