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.
Files changed (113) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +97 -8
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/core/connectors.d.ts +1 -1
  5. package/dist/core/connectors.d.ts.map +1 -1
  6. package/dist/core/git.d.ts +1 -1
  7. package/dist/core/git.d.ts.map +1 -1
  8. package/dist/core/git.js.map +1 -1
  9. package/dist/core/ingest.d.ts.map +1 -1
  10. package/dist/core/ingest.js +74 -3
  11. package/dist/core/ingest.js.map +1 -1
  12. package/dist/core/lint.d.ts.map +1 -1
  13. package/dist/core/lint.js +23 -4
  14. package/dist/core/lint.js.map +1 -1
  15. package/dist/core/oauth-defaults.d.ts +31 -0
  16. package/dist/core/oauth-defaults.d.ts.map +1 -0
  17. package/dist/core/oauth-defaults.js +79 -0
  18. package/dist/core/oauth-defaults.js.map +1 -0
  19. package/dist/core/observer.d.ts +24 -1
  20. package/dist/core/observer.d.ts.map +1 -1
  21. package/dist/core/observer.js +146 -4
  22. package/dist/core/observer.js.map +1 -1
  23. package/dist/core/sync/gdrive.d.ts +14 -0
  24. package/dist/core/sync/gdrive.d.ts.map +1 -0
  25. package/dist/core/sync/gdrive.js +205 -0
  26. package/dist/core/sync/gdrive.js.map +1 -0
  27. package/dist/core/sync/github.d.ts +20 -0
  28. package/dist/core/sync/github.d.ts.map +1 -0
  29. package/dist/core/sync/github.js +206 -0
  30. package/dist/core/sync/github.js.map +1 -0
  31. package/dist/core/sync/gmail.d.ts +15 -0
  32. package/dist/core/sync/gmail.d.ts.map +1 -0
  33. package/dist/core/sync/gmail.js +159 -0
  34. package/dist/core/sync/gmail.js.map +1 -0
  35. package/dist/core/sync/index.d.ts +47 -0
  36. package/dist/core/sync/index.d.ts.map +1 -0
  37. package/dist/core/sync/index.js +100 -0
  38. package/dist/core/sync/index.js.map +1 -0
  39. package/dist/core/sync/jira.d.ts +15 -0
  40. package/dist/core/sync/jira.d.ts.map +1 -0
  41. package/dist/core/sync/jira.js +176 -0
  42. package/dist/core/sync/jira.js.map +1 -0
  43. package/dist/core/sync/linear.d.ts +15 -0
  44. package/dist/core/sync/linear.d.ts.map +1 -0
  45. package/dist/core/sync/linear.js +111 -0
  46. package/dist/core/sync/linear.js.map +1 -0
  47. package/dist/core/sync/notion.d.ts +14 -0
  48. package/dist/core/sync/notion.d.ts.map +1 -0
  49. package/dist/core/sync/notion.js +168 -0
  50. package/dist/core/sync/notion.js.map +1 -0
  51. package/dist/core/sync/rss.d.ts +20 -0
  52. package/dist/core/sync/rss.d.ts.map +1 -0
  53. package/dist/core/sync/rss.js +165 -0
  54. package/dist/core/sync/rss.js.map +1 -0
  55. package/dist/core/sync/scheduler.d.ts +31 -0
  56. package/dist/core/sync/scheduler.d.ts.map +1 -0
  57. package/dist/core/sync/scheduler.js +129 -0
  58. package/dist/core/sync/scheduler.js.map +1 -0
  59. package/dist/core/sync/slack.d.ts +16 -0
  60. package/dist/core/sync/slack.d.ts.map +1 -0
  61. package/dist/core/sync/slack.js +173 -0
  62. package/dist/core/sync/slack.js.map +1 -0
  63. package/dist/core/vault.d.ts +22 -0
  64. package/dist/core/vault.d.ts.map +1 -1
  65. package/dist/core/vault.js +65 -0
  66. package/dist/core/vault.js.map +1 -1
  67. package/dist/core/webhooks.d.ts +13 -0
  68. package/dist/core/webhooks.d.ts.map +1 -0
  69. package/dist/core/webhooks.js +206 -0
  70. package/dist/core/webhooks.js.map +1 -0
  71. package/dist/mcp-server.d.ts +11 -6
  72. package/dist/mcp-server.d.ts.map +1 -1
  73. package/dist/mcp-server.js +99 -6
  74. package/dist/mcp-server.js.map +1 -1
  75. package/dist/mcp-tools-extended.d.ts +15 -0
  76. package/dist/mcp-tools-extended.d.ts.map +1 -0
  77. package/dist/mcp-tools-extended.js +277 -0
  78. package/dist/mcp-tools-extended.js.map +1 -0
  79. package/dist/processors/csv.d.ts +18 -0
  80. package/dist/processors/csv.d.ts.map +1 -0
  81. package/dist/processors/csv.js +230 -0
  82. package/dist/processors/csv.js.map +1 -0
  83. package/dist/processors/image.d.ts.map +1 -1
  84. package/dist/processors/image.js +55 -27
  85. package/dist/processors/image.js.map +1 -1
  86. package/dist/processors/pdf.d.ts.map +1 -1
  87. package/dist/processors/pdf.js +5 -1
  88. package/dist/processors/pdf.js.map +1 -1
  89. package/dist/processors/pptx.d.ts +3 -1
  90. package/dist/processors/pptx.d.ts.map +1 -1
  91. package/dist/processors/pptx.js +236 -95
  92. package/dist/processors/pptx.js.map +1 -1
  93. package/dist/processors/xlsx.d.ts +2 -0
  94. package/dist/processors/xlsx.d.ts.map +1 -1
  95. package/dist/processors/xlsx.js +182 -46
  96. package/dist/processors/xlsx.js.map +1 -1
  97. package/dist/templates/source-types.d.ts +33 -0
  98. package/dist/templates/source-types.d.ts.map +1 -0
  99. package/dist/templates/source-types.js +178 -0
  100. package/dist/templates/source-types.js.map +1 -0
  101. package/dist/web/public/index.html +1785 -103
  102. package/dist/web/server.d.ts.map +1 -1
  103. package/dist/web/server.js +746 -38
  104. package/dist/web/server.js.map +1 -1
  105. package/package.json +4 -1
  106. package/src/web/public/index.html +1785 -103
  107. package/templates/source-types/article.md +21 -0
  108. package/templates/source-types/book.md +21 -0
  109. package/templates/source-types/paper.md +23 -0
  110. package/templates/source-types/podcast.md +21 -0
  111. package/templates/source-types/raw-notes.md +17 -0
  112. package/templates/source-types/tweet-thread.md +19 -0
  113. package/templates/source-types/video.md +21 -0
@@ -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
- if (!q) {
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 = (page.frontmatter['tags'] ?? []).some((t) => t.toLowerCase().includes(q));
818
+ const tagMatch = tags.some(t => t.includes(q));
669
819
  let snippet;
670
820
  let contentMatch = false;
671
- if (!titleMatch) {
672
- const bodyLower = page.content.toLowerCase();
673
- const idx = bodyLower.indexOf(q);
674
- if (idx >= 0) {
675
- contentMatch = true;
676
- const start = Math.max(0, idx - 40);
677
- const end = Math.min(page.content.length, idx + q.length + 60);
678
- snippet = (start > 0 ? '…' : '') + page.content.substring(start, end).replace(/\n/g, ' ') + (end < page.content.length ? '…' : '');
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 category = page.frontmatter['category'] ?? 'uncategorized';
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
- res.json({ results });
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
- result[provider] = { connected: !!token, connectedAt: token?.connectedAt };
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 { loadConfig } = await import('../core/config.js');
1505
- const userConfig = loadConfig(config.configPath);
1506
- const clientId = userConfig[providerConfig.clientIdKey];
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 { loadConfig } = await import('../core/config.js');
1557
- const userConfig = loadConfig(config.configPath);
1558
- const clientId = userConfig[providerConfig.clientIdKey];
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
- </style></head><body><div class="card"><h2>Connected!</h2><p>${stateData.provider} is now linked to WikiMem.</p><p style="margin-top:12px"><small>You can close this tab.</small></p></div></body></html>`);
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 (_req, res) => {
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
- // For git repos, clone if URL provided
2116
- if (body.type === 'github' || (body.type === 'git-repo' && body.url)) {
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');