pi-para 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/ARCHITECTURE.md +4 -6
  2. package/CHANGELOG.md +14 -0
  3. package/CONFIGURATION.md +1 -16
  4. package/README.md +2 -13
  5. package/SETUP.md +1 -7
  6. package/STRUCTURE.md +7 -18
  7. package/dist/cli-legacy.d.ts +2 -1
  8. package/dist/cli-legacy.d.ts.map +1 -1
  9. package/dist/cli-legacy.js +13 -25
  10. package/dist/cli-legacy.js.map +1 -1
  11. package/dist/cli.d.ts +2 -24
  12. package/dist/cli.d.ts.map +1 -1
  13. package/dist/cli.js +29 -271
  14. package/dist/cli.js.map +1 -1
  15. package/dist/commands.d.ts.map +1 -1
  16. package/dist/commands.js +1 -28
  17. package/dist/commands.js.map +1 -1
  18. package/dist/config.d.ts +1 -5
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +0 -5
  21. package/dist/config.js.map +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -64
  24. package/dist/index.js.map +1 -1
  25. package/dist/link-utils.d.ts +1 -1
  26. package/dist/link-utils.js +1 -1
  27. package/dist/settings.d.ts +0 -3
  28. package/dist/settings.d.ts.map +1 -1
  29. package/dist/settings.js +0 -11
  30. package/dist/settings.js.map +1 -1
  31. package/dist/tools/edit-read.d.ts +18 -0
  32. package/dist/tools/edit-read.d.ts.map +1 -0
  33. package/dist/tools/edit-read.js +161 -0
  34. package/dist/tools/edit-read.js.map +1 -0
  35. package/dist/tools/ingest.d.ts +12 -0
  36. package/dist/tools/ingest.d.ts.map +1 -0
  37. package/dist/tools/ingest.js +48 -0
  38. package/dist/tools/ingest.js.map +1 -0
  39. package/dist/tools/lint.d.ts +10 -0
  40. package/dist/tools/lint.d.ts.map +1 -0
  41. package/dist/tools/lint.js +57 -0
  42. package/dist/tools/lint.js.map +1 -0
  43. package/dist/tools/migrate.d.ts +6 -0
  44. package/dist/tools/migrate.d.ts.map +1 -0
  45. package/dist/tools/migrate.js +36 -0
  46. package/dist/tools/migrate.js.map +1 -0
  47. package/dist/tools/move.d.ts +9 -0
  48. package/dist/tools/move.d.ts.map +1 -0
  49. package/dist/tools/move.js +61 -0
  50. package/dist/tools/move.js.map +1 -0
  51. package/dist/tools/query.d.ts +12 -0
  52. package/dist/tools/query.d.ts.map +1 -0
  53. package/dist/tools/query.js +47 -0
  54. package/dist/tools/query.js.map +1 -0
  55. package/dist/tools/schemas.d.ts +109 -0
  56. package/dist/tools/schemas.d.ts.map +1 -0
  57. package/dist/tools/schemas.js +80 -0
  58. package/dist/tools/schemas.js.map +1 -0
  59. package/dist/tools/shared.d.ts +14 -0
  60. package/dist/tools/shared.d.ts.map +1 -0
  61. package/dist/tools/shared.js +25 -0
  62. package/dist/tools/shared.js.map +1 -0
  63. package/dist/tools/summarize.d.ts +8 -0
  64. package/dist/tools/summarize.d.ts.map +1 -0
  65. package/dist/tools/summarize.js +82 -0
  66. package/dist/tools/summarize.js.map +1 -0
  67. package/dist/tools/write.d.ts +23 -0
  68. package/dist/tools/write.d.ts.map +1 -0
  69. package/dist/tools/write.js +215 -0
  70. package/dist/tools/write.js.map +1 -0
  71. package/dist/tools.d.ts +1 -8
  72. package/dist/tools.d.ts.map +1 -1
  73. package/dist/tools.js +13 -795
  74. package/dist/tools.js.map +1 -1
  75. package/package.json +2 -6
  76. package/src/cli-legacy.ts +14 -26
  77. package/src/cli.ts +29 -293
  78. package/src/commands.ts +0 -21
  79. package/src/config.ts +1 -6
  80. package/src/index.ts +5 -66
  81. package/src/link-utils.ts +1 -1
  82. package/src/settings.ts +0 -14
  83. package/src/tools/edit-read.ts +193 -0
  84. package/src/tools/ingest.ts +63 -0
  85. package/src/tools/lint.ts +74 -0
  86. package/src/tools/migrate.ts +44 -0
  87. package/src/tools/move.ts +79 -0
  88. package/src/tools/query.ts +63 -0
  89. package/src/tools/schemas.ts +157 -0
  90. package/src/tools/shared.ts +35 -0
  91. package/src/tools/summarize.ts +89 -0
  92. package/src/tools/write.ts +252 -0
  93. package/src/tools.ts +18 -1046
  94. package/dist/daemon.d.ts +0 -49
  95. package/dist/daemon.d.ts.map +0 -1
  96. package/dist/daemon.js +0 -226
  97. package/dist/daemon.js.map +0 -1
  98. package/dist/link-discovery.d.ts +0 -40
  99. package/dist/link-discovery.d.ts.map +0 -1
  100. package/dist/link-discovery.js +0 -122
  101. package/dist/link-discovery.js.map +0 -1
  102. package/dist/maintainer.d.ts +0 -24
  103. package/dist/maintainer.d.ts.map +0 -1
  104. package/dist/maintainer.js +0 -358
  105. package/dist/maintainer.js.map +0 -1
  106. package/dist/watcher.d.ts +0 -25
  107. package/dist/watcher.d.ts.map +0 -1
  108. package/dist/watcher.js +0 -84
  109. package/dist/watcher.js.map +0 -1
  110. package/dist/webui/client/dist/index.html +0 -1885
  111. package/dist/webui/server.d.ts +0 -31
  112. package/dist/webui/server.d.ts.map +0 -1
  113. package/dist/webui/server.js +0 -566
  114. package/dist/webui/server.js.map +0 -1
  115. package/scripts/build-assets.mjs +0 -17
  116. package/src/daemon.ts +0 -288
  117. package/src/link-discovery.ts +0 -170
  118. package/src/maintainer.ts +0 -404
  119. package/src/python/__pycache__/minimax_lm.cpython-312.pyc +0 -0
  120. package/src/python/minimax_lm.py +0 -198
  121. package/src/python/rlm_capture.py +0 -215
  122. package/src/watcher.ts +0 -101
  123. package/src/webui/client/dist/index.html +0 -1885
  124. package/src/webui/server.ts +0 -765
@@ -1,765 +0,0 @@
1
- /**
2
- * Web Wiki HTTP Server
3
- *
4
- * Serves the React SPA and provides REST API endpoints for wiki operations.
5
- * Uses Node's built-in http module — no Express dependency.
6
- *
7
- * API:
8
- * GET /api/pages — list pages grouped by PARA category
9
- * GET /api/pages/:category/:slug — get page content + frontmatter
10
- * PUT /api/pages/:category/:slug — update page body
11
- * POST /api/pages/:category/:slug/move — move page to different category
12
- * GET /api/search?q=query — BM25 search via qmd store
13
- * GET /api/graph — nodes + edges from wikilinks
14
- * GET /api/log — activity log entries
15
- * GET /api/sessions — session digest entries
16
- * GET / — serve React SPA
17
- * GET /* — serve static files, fallback to index.html
18
- */
19
-
20
- import { createServer } from "node:http";
21
- import type { Server, IncomingMessage, ServerResponse } from "node:http";
22
- import { readFile, stat } from "node:fs/promises";
23
- import { join, extname } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
-
26
- import {
27
- listPages,
28
- readPage,
29
- writePage,
30
- movePage,
31
- gitCommit,
32
- PARA_CATEGORIES,
33
- } from "../wiki.js";
34
- import type { ParaCategory, WikiPage, PageRef, PageFrontmatter } from "../wiki.js";
35
- import { extractWikilinks } from "../link-utils.js";
36
- import { redactSecrets } from "../redact.js";
37
- import { searchWiki, reindex } from "../store.js";
38
- import type { QMDStore } from "../store.js";
39
- import { readSessionDigests } from "../raw.js";
40
-
41
- // -- Types ------------------------------------------------------------------
42
-
43
- export interface WebWikiConfig {
44
- enabled: boolean;
45
- host: string;
46
- port: number;
47
- }
48
-
49
- interface ServerHandle {
50
- close: () => Promise<void>;
51
- url: string;
52
- }
53
-
54
- interface GraphNode {
55
- id: string;
56
- title: string;
57
- category: ParaCategory;
58
- slug: string;
59
- }
60
-
61
- interface GraphEdge {
62
- source: string;
63
- target: string;
64
- }
65
-
66
- interface RouteMatch {
67
- params: Record<string, string>;
68
- }
69
-
70
- // -- MIME types --------------------------------------------------------------
71
-
72
- const MIME_TYPES: Record<string, string> = {
73
- ".html": "text/html; charset=utf-8",
74
- ".js": "application/javascript; charset=utf-8",
75
- ".mjs": "application/javascript; charset=utf-8",
76
- ".css": "text/css; charset=utf-8",
77
- ".json": "application/json; charset=utf-8",
78
- ".png": "image/png",
79
- ".jpg": "image/jpeg",
80
- ".jpeg": "image/jpeg",
81
- ".gif": "image/gif",
82
- ".svg": "image/svg+xml",
83
- ".ico": "image/x-icon",
84
- ".woff": "font/woff",
85
- ".woff2": "font/woff2",
86
- ".ttf": "font/ttf",
87
- ".map": "application/json",
88
- };
89
-
90
- // -- Helpers ----------------------------------------------------------------
91
-
92
- // extractWikilinks imported from ../link-utils.js
93
-
94
- function json(res: ServerResponse, data: unknown, statusCode = 200): void {
95
- const body = JSON.stringify(data);
96
- res.writeHead(statusCode, {
97
- "Content-Type": "application/json; charset=utf-8",
98
- "Content-Length": Buffer.byteLength(body),
99
- });
100
- res.end(body);
101
- }
102
-
103
- function errorResponse(res: ServerResponse, statusCode: number, message: string): void {
104
- json(res, { error: message }, statusCode);
105
- }
106
-
107
- function setCorsHeaders(res: ServerResponse): void {
108
- res.setHeader("Access-Control-Allow-Origin", "*");
109
- res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS");
110
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
111
- }
112
-
113
- async function readBody(req: IncomingMessage): Promise<string> {
114
- return new Promise((resolve, reject) => {
115
- const chunks: Buffer[] = [];
116
- let size = 0;
117
- const MAX_BODY = 5 * 1024 * 1024; // 5MB limit
118
-
119
- req.on("data", (chunk: Buffer) => {
120
- size += chunk.length;
121
- if (size > MAX_BODY) {
122
- req.destroy();
123
- reject(new Error("Request body too large"));
124
- return;
125
- }
126
- chunks.push(chunk);
127
- });
128
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
129
- req.on("error", reject);
130
- });
131
- }
132
-
133
- function parseJsonBody(raw: string): unknown {
134
- try {
135
- return JSON.parse(raw);
136
- } catch {
137
- return null;
138
- }
139
- }
140
-
141
- function isValidCategory(cat: string): cat is ParaCategory {
142
- return (PARA_CATEGORIES as readonly string[]).includes(cat);
143
- }
144
-
145
- function matchRoute(
146
- pattern: string,
147
- pathname: string,
148
- ): RouteMatch | null {
149
- const patternParts = pattern.split("/").filter(Boolean);
150
- const pathParts = pathname.split("/").filter(Boolean);
151
-
152
- if (patternParts.length !== pathParts.length) return null;
153
-
154
- const params: Record<string, string> = {};
155
- for (let i = 0; i < patternParts.length; i++) {
156
- const pp = patternParts[i];
157
- if (pp.startsWith(":")) {
158
- params[pp.slice(1)] = decodeURIComponent(pathParts[i]);
159
- } else if (pp !== pathParts[i]) {
160
- return null;
161
- }
162
- }
163
- return { params };
164
- }
165
-
166
- // -- Log parser -------------------------------------------------------------
167
-
168
- interface ParsedLogEntry {
169
- date: string;
170
- operation: string;
171
- summary: string;
172
- pages: string[];
173
- }
174
-
175
- function parseLogEntries(content: string): ParsedLogEntry[] {
176
- const entries: ParsedLogEntry[] = [];
177
- const entryPattern = /^## \[([^\]]+)\] (\w+) \| (.+)$/gm;
178
- let match: RegExpExecArray | null;
179
-
180
- while ((match = entryPattern.exec(content)) !== null) {
181
- const date = match[1].trim();
182
- const operation = match[2].trim();
183
- const summary = match[3].trim();
184
-
185
- // Extract the block after this heading until the next heading or end
186
- const startIdx = match.index + match[0].length;
187
- const nextHeading = content.indexOf("\n## ", startIdx);
188
- const block = nextHeading === -1
189
- ? content.slice(startIdx)
190
- : content.slice(startIdx, nextHeading);
191
-
192
- // Parse "Pages: ..." line
193
- const pagesMatch = /^Pages:\s*(.+)$/m.exec(block);
194
- const pages: string[] = [];
195
- if (pagesMatch && pagesMatch[1].trim() !== "none") {
196
- for (const p of pagesMatch[1].split(",")) {
197
- const trimmed = p.trim();
198
- if (trimmed) pages.push(trimmed);
199
- }
200
- }
201
-
202
- entries.push({ date, operation, summary, pages });
203
- }
204
-
205
- return entries;
206
- }
207
-
208
- // -- Route handlers ---------------------------------------------------------
209
-
210
- async function handleListPages(
211
- wikiDir: string,
212
- url: URL,
213
- _req: IncomingMessage,
214
- res: ServerResponse,
215
- ): Promise<void> {
216
- const refs = await listPages(wikiDir);
217
-
218
- // Build flat list with metadata
219
- const allPages: Array<{
220
- slug: string;
221
- title: string;
222
- path: string;
223
- category: ParaCategory;
224
- scope: string[];
225
- tags: string[];
226
- }> = [];
227
-
228
- for (const ref of refs) {
229
- const page = await readPage(wikiDir, ref.category, ref.slug);
230
- allPages.push({
231
- slug: ref.slug,
232
- title: ref.title,
233
- path: ref.path,
234
- category: ref.category,
235
- scope: page?.frontmatter.scope ?? [],
236
- tags: page?.frontmatter.tags ?? [],
237
- });
238
- }
239
-
240
- const total = allPages.length;
241
- const rawPage = url.searchParams.get("page");
242
- const rawLimit = url.searchParams.get("limit");
243
-
244
- // Default: return grouped by category (what the SPA client expects)
245
- if (!rawPage && !rawLimit) {
246
- const grouped: Record<string, Array<{ slug: string; title: string; path: string; scope: string[]; tags: string[] }>> = {
247
- projects: [],
248
- areas: [],
249
- resources: [],
250
- archives: [],
251
- };
252
- for (const p of allPages) {
253
- grouped[p.category]?.push({ slug: p.slug, title: p.title, path: p.path, scope: p.scope, tags: p.tags });
254
- }
255
- json(res, grouped);
256
- return;
257
- }
258
-
259
- const pageParam = parseInt(rawPage ?? "1", 10);
260
- const limitParam = parseInt(rawLimit ?? "50", 10);
261
- const page = Math.max(1, isNaN(pageParam) ? 1 : pageParam);
262
- const limit = Math.max(1, Math.min(500, isNaN(limitParam) ? 50 : limitParam));
263
- const start = (page - 1) * limit;
264
- const paginatedPages = allPages.slice(start, start + limit);
265
-
266
- json(res, {
267
- pages: paginatedPages,
268
- total,
269
- page,
270
- limit,
271
- });
272
- }
273
-
274
- async function handleGetPage(
275
- wikiDir: string,
276
- params: Record<string, string>,
277
- _req: IncomingMessage,
278
- res: ServerResponse,
279
- ): Promise<void> {
280
- const { category, slug } = params;
281
-
282
- if (!isValidCategory(category)) {
283
- errorResponse(res, 400, `Invalid category: ${category}`);
284
- return;
285
- }
286
-
287
- const page = await readPage(wikiDir, category, slug);
288
- if (!page) {
289
- errorResponse(res, 404, `Page not found: ${category}/${slug}`);
290
- return;
291
- }
292
-
293
- json(res, {
294
- category: page.category,
295
- slug: page.slug,
296
- frontmatter: page.frontmatter,
297
- body: page.body,
298
- });
299
- }
300
-
301
- async function handleUpdatePage(
302
- wikiDir: string,
303
- store: QMDStore | null,
304
- params: Record<string, string>,
305
- req: IncomingMessage,
306
- res: ServerResponse,
307
- ): Promise<void> {
308
- const { category, slug } = params;
309
-
310
- if (!isValidCategory(category)) {
311
- errorResponse(res, 400, `Invalid category: ${category}`);
312
- return;
313
- }
314
-
315
- const rawBody = await readBody(req);
316
- const body = parseJsonBody(rawBody);
317
- if (!body || typeof body !== "object") {
318
- errorResponse(res, 400, "Invalid JSON body");
319
- return;
320
- }
321
-
322
- const payload = body as Record<string, unknown>;
323
-
324
- // Read existing page or create new
325
- const existing = await readPage(wikiDir, category, slug);
326
- if (!existing) {
327
- errorResponse(res, 404, `Page not found: ${category}/${slug}`);
328
- return;
329
- }
330
-
331
- // Update body if provided
332
- // Redact secrets before saving
333
- const pageBody = typeof payload.body === "string" ? payload.body : existing.body;
334
- const newBody = redactSecrets(pageBody).text;
335
-
336
- // Update frontmatter fields if provided
337
- const newFrontmatter: PageFrontmatter = { ...existing.frontmatter };
338
- if (typeof payload.title === "string") newFrontmatter.title = payload.title;
339
- if (Array.isArray(payload.tags)) newFrontmatter.tags = payload.tags.filter((t): t is string => typeof t === "string");
340
- if (Array.isArray(payload.scope)) newFrontmatter.scope = payload.scope.filter((s): s is string => typeof s === "string");
341
- newFrontmatter.updated = new Date().toISOString();
342
-
343
- // Extract wikilinks from new body
344
- newFrontmatter.links = extractWikilinks(newBody);
345
-
346
- const updatedPage: WikiPage = {
347
- category,
348
- slug,
349
- frontmatter: newFrontmatter,
350
- body: newBody,
351
- };
352
-
353
- await writePage(wikiDir, updatedPage);
354
-
355
- // Re-index for search
356
- if (store) {
357
- try {
358
- await reindex(store);
359
- } catch {
360
- // Non-fatal — search may be stale until next reindex
361
- }
362
- }
363
-
364
- // Git commit
365
- await gitCommit(wikiDir, `webui: update ${category}/${slug}`);
366
-
367
- json(res, {
368
- category: updatedPage.category,
369
- slug: updatedPage.slug,
370
- frontmatter: updatedPage.frontmatter,
371
- body: updatedPage.body,
372
- });
373
- }
374
-
375
- async function handleMovePage(
376
- wikiDir: string,
377
- store: QMDStore | null,
378
- params: Record<string, string>,
379
- req: IncomingMessage,
380
- res: ServerResponse,
381
- ): Promise<void> {
382
- const { category, slug } = params;
383
-
384
- if (!isValidCategory(category)) {
385
- errorResponse(res, 400, `Invalid source category: ${category}`);
386
- return;
387
- }
388
-
389
- const rawBody = await readBody(req);
390
- const body = parseJsonBody(rawBody);
391
- if (!body || typeof body !== "object") {
392
- errorResponse(res, 400, "Invalid JSON body");
393
- return;
394
- }
395
-
396
- const payload = body as Record<string, unknown>;
397
- const toCategory = payload.to;
398
- if (typeof toCategory !== "string" || !isValidCategory(toCategory)) {
399
- errorResponse(res, 400, `Invalid target category: ${String(toCategory)}`);
400
- return;
401
- }
402
-
403
- if (category === toCategory) {
404
- errorResponse(res, 400, "Source and target category are the same");
405
- return;
406
- }
407
-
408
- // Check page exists
409
- const page = await readPage(wikiDir, category, slug);
410
- if (!page) {
411
- errorResponse(res, 404, `Page not found: ${category}/${slug}`);
412
- return;
413
- }
414
-
415
- const pageRef: PageRef = {
416
- category,
417
- slug,
418
- title: page.frontmatter.title,
419
- path: `${category}/${slug}.md`,
420
- };
421
-
422
- await movePage(wikiDir, pageRef, toCategory);
423
-
424
- // Re-index for search
425
- if (store) {
426
- try {
427
- await reindex(store);
428
- } catch {
429
- // Non-fatal
430
- }
431
- }
432
-
433
- json(res, { moved: true, from: category, to: toCategory, slug });
434
- }
435
-
436
- async function handleSearch(
437
- wikiDir: string,
438
- store: QMDStore | null,
439
- query: string,
440
- _req: IncomingMessage,
441
- res: ServerResponse,
442
- ): Promise<void> {
443
- if (!query) {
444
- errorResponse(res, 400, "Missing query parameter: q");
445
- return;
446
- }
447
-
448
- if (!store) {
449
- errorResponse(res, 503, "Search is unavailable: store not initialized");
450
- return;
451
- }
452
-
453
- const results = await searchWiki(store, query, { limit: 20 });
454
- json(res, results.map((r) => ({
455
- category: r.page.category,
456
- slug: r.page.slug,
457
- title: r.page.title,
458
- score: r.score,
459
- snippet: r.snippet,
460
- tags: r.frontmatter.tags,
461
- scope: r.frontmatter.scope,
462
- updated: r.frontmatter.updated,
463
- })));
464
- }
465
-
466
- async function handleGraph(
467
- wikiDir: string,
468
- url: URL,
469
- _req: IncomingMessage,
470
- res: ServerResponse,
471
- ): Promise<void> {
472
- const maxNodesParam = parseInt(url.searchParams.get("maxNodes") ?? "100", 10);
473
- const maxNodes = Math.max(1, isNaN(maxNodesParam) ? 100 : maxNodesParam);
474
- const scopeParam = url.searchParams.get("scope"); // e.g. ?scope=pi-para
475
-
476
- const refs = await listPages(wikiDir);
477
- const slugToRef = new Map<string, PageRef>();
478
- for (const ref of refs) {
479
- slugToRef.set(ref.slug, ref);
480
- }
481
-
482
- // Read all pages and apply scope filter
483
- const pageData = new Map<string, { ref: PageRef; page: NonNullable<Awaited<ReturnType<typeof readPage>>> }>();
484
- for (const ref of refs) {
485
- const page = await readPage(wikiDir, ref.category, ref.slug);
486
- if (!page) continue;
487
- // Scope filter: keep page if no filter, or if page scope includes the filter value or "global"
488
- if (scopeParam && !page.frontmatter.scope.includes(scopeParam) && !page.frontmatter.scope.includes("global")) {
489
- continue;
490
- }
491
- pageData.set(ref.slug, { ref, page });
492
- }
493
-
494
- // Build all nodes and edges from filtered pages
495
- const allNodes: (GraphNode & { connectionCount: number })[] = [];
496
- const allEdges: GraphEdge[] = [];
497
- const seenEdges = new Set<string>();
498
- const connectionCounts = new Map<string, number>();
499
-
500
- for (const { ref, page } of pageData.values()) {
501
- const nodeId = `${ref.category}/${ref.slug}`;
502
-
503
- // Collect wikilinks from body and frontmatter
504
- const bodyLinks = extractWikilinks(page.body);
505
- const fmLinks = page.frontmatter.links;
506
- const allLinks = new Set([...bodyLinks, ...fmLinks]);
507
-
508
- for (const link of allLinks) {
509
- const targetRef = slugToRef.get(link);
510
- if (!targetRef) continue; // skip broken links
511
- // Only include edges to pages that passed scope filter
512
- if (!pageData.has(link)) continue;
513
-
514
- const targetId = `${targetRef.category}/${targetRef.slug}`;
515
- const edgeKey = `${nodeId}->${targetId}`;
516
- if (seenEdges.has(edgeKey)) continue;
517
- seenEdges.add(edgeKey);
518
-
519
- allEdges.push({ source: nodeId, target: targetId });
520
-
521
- // Track connection counts for both source and target
522
- connectionCounts.set(nodeId, (connectionCounts.get(nodeId) ?? 0) + 1);
523
- connectionCounts.set(targetId, (connectionCounts.get(targetId) ?? 0) + 1);
524
- }
525
-
526
- allNodes.push({
527
- id: nodeId,
528
- title: ref.title,
529
- category: ref.category,
530
- slug: ref.slug,
531
- connectionCount: 0, // will be filled below
532
- });
533
- }
534
-
535
- // Fill connection counts and sort by most connected
536
- for (const node of allNodes) {
537
- node.connectionCount = connectionCounts.get(node.id) ?? 0;
538
- }
539
- allNodes.sort((a, b) => b.connectionCount - a.connectionCount);
540
-
541
- // Take top-N most connected nodes
542
- const topNodes = allNodes.slice(0, maxNodes);
543
- const topNodeIds = new Set(topNodes.map(n => n.id));
544
-
545
- // Filter edges to only include those between visible nodes
546
- const filteredEdges = allEdges.filter(
547
- e => topNodeIds.has(e.source) && topNodeIds.has(e.target)
548
- );
549
-
550
- // Strip connectionCount from response
551
- const nodes: GraphNode[] = topNodes.map(({ connectionCount, ...rest }) => rest);
552
-
553
- json(res, { nodes, edges: filteredEdges });
554
- }
555
-
556
- async function handleLog(
557
- wikiDir: string,
558
- _req: IncomingMessage,
559
- res: ServerResponse,
560
- ): Promise<void> {
561
- let content: string;
562
- try {
563
- content = await readFile(join(wikiDir, "log.md"), "utf-8");
564
- } catch {
565
- json(res, []);
566
- return;
567
- }
568
-
569
- const entries = parseLogEntries(content);
570
- // Return newest first
571
- entries.reverse();
572
- json(res, entries);
573
- }
574
-
575
- async function handleSessions(
576
- wikiDir: string,
577
- url: URL,
578
- _req: IncomingMessage,
579
- res: ServerResponse,
580
- ): Promise<void> {
581
- const limitParam = url.searchParams.get("limit");
582
- const scope = url.searchParams.get("scope") ?? undefined;
583
- const limit = limitParam ? parseInt(limitParam, 10) : undefined;
584
-
585
- const digests = await readSessionDigests(wikiDir, { limit, scope });
586
- // Return newest first
587
- digests.reverse();
588
- json(res, digests);
589
- }
590
-
591
- // -- Static file serving ----------------------------------------------------
592
-
593
- async function serveStaticFile(
594
- staticDir: string,
595
- pathname: string,
596
- res: ServerResponse,
597
- ): Promise<boolean> {
598
- // Prevent directory traversal
599
- const safePath = pathname.replace(/\.\./g, "").replace(/\/+/g, "/");
600
- const filePath = join(staticDir, safePath === "/" ? "index.html" : safePath);
601
-
602
- try {
603
- const stats = await stat(filePath);
604
- if (!stats.isFile()) return false;
605
-
606
- const ext = extname(filePath);
607
- const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
608
- const content = await readFile(filePath);
609
-
610
- res.writeHead(200, {
611
- "Content-Type": contentType,
612
- "Content-Length": content.length,
613
- "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
614
- });
615
- res.end(content);
616
- return true;
617
- } catch {
618
- return false;
619
- }
620
- }
621
-
622
- async function serveSpaFallback(
623
- staticDir: string,
624
- res: ServerResponse,
625
- ): Promise<void> {
626
- try {
627
- const indexPath = join(staticDir, "index.html");
628
- const content = await readFile(indexPath);
629
- res.writeHead(200, {
630
- "Content-Type": "text/html; charset=utf-8",
631
- "Content-Length": content.length,
632
- "Cache-Control": "no-cache",
633
- });
634
- res.end(content);
635
- } catch {
636
- errorResponse(res, 404, "SPA not built — run the client build first");
637
- }
638
- }
639
-
640
- // -- Server -----------------------------------------------------------------
641
-
642
- export function startServer(
643
- wikiDir: string,
644
- store: QMDStore | null,
645
- config: WebWikiConfig,
646
- ): ServerHandle {
647
- // Static files directory — built React SPA
648
- // Resolve relative to this file: src/webui/server.ts -> src/webui/client/dist
649
- const thisDir = typeof __dirname !== "undefined"
650
- ? __dirname
651
- : fileURLToPath(new URL(".", import.meta.url));
652
- const staticDir = join(thisDir, "client", "dist");
653
-
654
- const server: Server = createServer(async (req, res) => {
655
- setCorsHeaders(res);
656
-
657
- // Handle CORS preflight
658
- if (req.method === "OPTIONS") {
659
- res.writeHead(204);
660
- res.end();
661
- return;
662
- }
663
-
664
- try {
665
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
666
- const pathname = url.pathname;
667
- const method = req.method ?? "GET";
668
-
669
- // -- API routes -------------------------------------------------------
670
-
671
- // GET /api/pages
672
- if (method === "GET" && pathname === "/api/pages") {
673
- await handleListPages(wikiDir, url, req, res);
674
- return;
675
- }
676
-
677
- // GET /api/search?q=...
678
- if (method === "GET" && pathname === "/api/search") {
679
- const query = url.searchParams.get("q") ?? "";
680
- await handleSearch(wikiDir, store, query, req, res);
681
- return;
682
- }
683
-
684
- // GET /api/graph
685
- if (method === "GET" && pathname === "/api/graph") {
686
- await handleGraph(wikiDir, url, req, res);
687
- return;
688
- }
689
-
690
- // GET /api/log
691
- if (method === "GET" && pathname === "/api/log") {
692
- await handleLog(wikiDir, req, res);
693
- return;
694
- }
695
-
696
- // GET /api/sessions
697
- if (method === "GET" && pathname === "/api/sessions") {
698
- await handleSessions(wikiDir, url, req, res);
699
- return;
700
- }
701
-
702
- // POST /api/pages/:category/:slug/move
703
- const moveMatch = matchRoute("/api/pages/:category/:slug/move", pathname);
704
- if (method === "POST" && moveMatch) {
705
- await handleMovePage(wikiDir, store, moveMatch.params, req, res);
706
- return;
707
- }
708
-
709
- // GET /api/pages/:category/:slug
710
- const pageMatch = matchRoute("/api/pages/:category/:slug", pathname);
711
- if (method === "GET" && pageMatch) {
712
- await handleGetPage(wikiDir, pageMatch.params, req, res);
713
- return;
714
- }
715
-
716
- // PUT /api/pages/:category/:slug
717
- if (method === "PUT" && pageMatch) {
718
- await handleUpdatePage(wikiDir, store, pageMatch.params, req, res);
719
- return;
720
- }
721
-
722
- // -- Static files / SPA fallback --------------------------------------
723
-
724
- if (method === "GET") {
725
- // Try exact static file first
726
- const served = await serveStaticFile(staticDir, pathname, res);
727
- if (served) return;
728
-
729
- // SPA fallback — serve index.html for client-side routing
730
- // Only for non-API, non-asset paths
731
- if (!pathname.startsWith("/api/")) {
732
- await serveSpaFallback(staticDir, res);
733
- return;
734
- }
735
- }
736
-
737
- // -- 404 for unmatched routes -----------------------------------------
738
- errorResponse(res, 404, "Not found");
739
- } catch (err) {
740
- const message = err instanceof Error ? err.message : String(err);
741
- console.error(`[webui] Request error: ${message}`);
742
- errorResponse(res, 500, `Internal server error: ${message}`);
743
- }
744
- });
745
-
746
- const { host, port } = config;
747
-
748
- server.listen(port, host, () => {
749
- // Server is listening — logged by caller
750
- });
751
-
752
- const serverUrl = host === "0.0.0.0"
753
- ? `http://localhost:${port}`
754
- : `http://${host}:${port}`;
755
-
756
- return {
757
- url: serverUrl,
758
- close: () => new Promise<void>((resolve, reject) => {
759
- server.close((err) => {
760
- if (err) reject(err);
761
- else resolve();
762
- });
763
- }),
764
- };
765
- }