@mainahq/core 1.0.3 → 1.1.1

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 (71) hide show
  1. package/package.json +1 -1
  2. package/src/ai/__tests__/delegation.test.ts +55 -1
  3. package/src/ai/delegation.ts +5 -3
  4. package/src/context/__tests__/budget.test.ts +29 -6
  5. package/src/context/__tests__/engine.test.ts +1 -0
  6. package/src/context/__tests__/selector.test.ts +23 -3
  7. package/src/context/__tests__/wiki.test.ts +349 -0
  8. package/src/context/budget.ts +12 -8
  9. package/src/context/engine.ts +37 -0
  10. package/src/context/selector.ts +30 -4
  11. package/src/context/wiki.ts +296 -0
  12. package/src/db/index.ts +12 -0
  13. package/src/feedback/__tests__/capture.test.ts +166 -0
  14. package/src/feedback/__tests__/signals.test.ts +144 -0
  15. package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
  16. package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
  17. package/src/feedback/capture.ts +102 -0
  18. package/src/feedback/signals.ts +68 -0
  19. package/src/index.ts +104 -0
  20. package/src/init/__tests__/init.test.ts +400 -3
  21. package/src/init/index.ts +368 -12
  22. package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
  23. package/src/prompts/defaults/index.ts +3 -1
  24. package/src/prompts/defaults/wiki-compile.md +20 -0
  25. package/src/prompts/defaults/wiki-query.md +18 -0
  26. package/src/stats/__tests__/tool-usage.test.ts +133 -0
  27. package/src/stats/tracker.ts +92 -0
  28. package/src/verify/__tests__/pipeline.test.ts +11 -8
  29. package/src/verify/pipeline.ts +13 -1
  30. package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
  31. package/src/verify/tools/wiki-lint-runner.ts +38 -0
  32. package/src/verify/tools/wiki-lint.ts +898 -0
  33. package/src/wiki/__tests__/compiler.test.ts +389 -0
  34. package/src/wiki/__tests__/extractors/code.test.ts +99 -0
  35. package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
  36. package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
  37. package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
  38. package/src/wiki/__tests__/graph.test.ts +344 -0
  39. package/src/wiki/__tests__/hooks.test.ts +119 -0
  40. package/src/wiki/__tests__/indexer.test.ts +285 -0
  41. package/src/wiki/__tests__/linker.test.ts +230 -0
  42. package/src/wiki/__tests__/louvain.test.ts +229 -0
  43. package/src/wiki/__tests__/query.test.ts +316 -0
  44. package/src/wiki/__tests__/schema.test.ts +114 -0
  45. package/src/wiki/__tests__/signals.test.ts +474 -0
  46. package/src/wiki/__tests__/state.test.ts +168 -0
  47. package/src/wiki/__tests__/tracking.test.ts +118 -0
  48. package/src/wiki/__tests__/types.test.ts +387 -0
  49. package/src/wiki/compiler.ts +1075 -0
  50. package/src/wiki/extractors/code.ts +90 -0
  51. package/src/wiki/extractors/decision.ts +217 -0
  52. package/src/wiki/extractors/feature.ts +206 -0
  53. package/src/wiki/extractors/workflow.ts +112 -0
  54. package/src/wiki/graph.ts +445 -0
  55. package/src/wiki/hooks.ts +49 -0
  56. package/src/wiki/indexer.ts +105 -0
  57. package/src/wiki/linker.ts +117 -0
  58. package/src/wiki/louvain.ts +190 -0
  59. package/src/wiki/prompts/compile-architecture.md +59 -0
  60. package/src/wiki/prompts/compile-decision.md +66 -0
  61. package/src/wiki/prompts/compile-entity.md +56 -0
  62. package/src/wiki/prompts/compile-feature.md +60 -0
  63. package/src/wiki/prompts/compile-module.md +42 -0
  64. package/src/wiki/prompts/wiki-query.md +25 -0
  65. package/src/wiki/query.ts +338 -0
  66. package/src/wiki/schema.ts +111 -0
  67. package/src/wiki/signals.ts +368 -0
  68. package/src/wiki/state.ts +89 -0
  69. package/src/wiki/tracking.ts +30 -0
  70. package/src/wiki/types.ts +169 -0
  71. package/src/workflow/context.ts +26 -0
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Knowledge Graph — unified graph with 11 edge types for wiki compilation.
3
+ *
4
+ * Builds a graph from code entities, features, decisions, and workflow traces.
5
+ * Supports PageRank computation and mapping graph nodes to wiki article paths.
6
+ */
7
+
8
+ import type { CodeEntity } from "./extractors/code";
9
+ import type {
10
+ EdgeType,
11
+ ExtractedDecision,
12
+ ExtractedFeature,
13
+ ExtractedWorkflowTrace,
14
+ } from "./types";
15
+
16
+ // ─── Types ───────────────────────────────────────────────────────────────
17
+
18
+ export interface GraphNode {
19
+ id: string;
20
+ type: "entity" | "module" | "feature" | "decision" | "workflow";
21
+ label: string;
22
+ file?: string;
23
+ pageRank: number;
24
+ }
25
+
26
+ export interface GraphEdge {
27
+ source: string;
28
+ target: string;
29
+ type: EdgeType;
30
+ weight: number;
31
+ }
32
+
33
+ export interface KnowledgeGraph {
34
+ nodes: Map<string, GraphNode>;
35
+ edges: GraphEdge[];
36
+ adjacency: Map<string, Set<string>>;
37
+ }
38
+
39
+ // ─── Graph Construction ─────────────────────────────────────────────────
40
+
41
+ function addNode(graph: KnowledgeGraph, node: GraphNode): void {
42
+ if (!graph.nodes.has(node.id)) {
43
+ graph.nodes.set(node.id, node);
44
+ }
45
+ if (!graph.adjacency.has(node.id)) {
46
+ graph.adjacency.set(node.id, new Set<string>());
47
+ }
48
+ }
49
+
50
+ function addEdge(graph: KnowledgeGraph, edge: GraphEdge): void {
51
+ graph.edges.push(edge);
52
+
53
+ // Ensure both nodes are in adjacency
54
+ if (!graph.adjacency.has(edge.source)) {
55
+ graph.adjacency.set(edge.source, new Set<string>());
56
+ }
57
+ if (!graph.adjacency.has(edge.target)) {
58
+ graph.adjacency.set(edge.target, new Set<string>());
59
+ }
60
+
61
+ graph.adjacency.get(edge.source)?.add(edge.target);
62
+ graph.adjacency.get(edge.target)?.add(edge.source);
63
+ }
64
+
65
+ /**
66
+ * Derive a module name from a file path.
67
+ * "packages/core/src/wiki/state.ts" -> "wiki"
68
+ * "src/auth/jwt.ts" -> "auth"
69
+ */
70
+ function deriveModule(file: string): string {
71
+ const parts = file.replace(/\\/g, "/").split("/");
72
+ // Find the directory containing the file
73
+ if (parts.length >= 2) {
74
+ return parts[parts.length - 2] ?? "root";
75
+ }
76
+ return "root";
77
+ }
78
+
79
+ function addCodeEntities(graph: KnowledgeGraph, entities: CodeEntity[]): void {
80
+ // Group entities by module (directory)
81
+ const moduleEntities = new Map<string, CodeEntity[]>();
82
+
83
+ for (const entity of entities) {
84
+ const moduleName = deriveModule(entity.file);
85
+
86
+ // Add entity node
87
+ const entityId = `entity:${entity.name}`;
88
+ addNode(graph, {
89
+ id: entityId,
90
+ type: "entity",
91
+ label: entity.name,
92
+ file: entity.file,
93
+ pageRank: 0,
94
+ });
95
+
96
+ // Track module membership
97
+ const list = moduleEntities.get(moduleName) ?? [];
98
+ list.push(entity);
99
+ moduleEntities.set(moduleName, list);
100
+ }
101
+
102
+ // Create module nodes and member_of edges
103
+ for (const [moduleName, members] of moduleEntities) {
104
+ const moduleId = `module:${moduleName}`;
105
+ addNode(graph, {
106
+ id: moduleId,
107
+ type: "module",
108
+ label: moduleName,
109
+ pageRank: 0,
110
+ });
111
+
112
+ for (const member of members) {
113
+ addEdge(graph, {
114
+ source: `entity:${member.name}`,
115
+ target: moduleId,
116
+ type: "member_of",
117
+ weight: 1.0,
118
+ });
119
+ }
120
+
121
+ // Add imports edges between entities in the same file
122
+ // (simplified: entities from the same file reference each other)
123
+ const fileGroups = new Map<string, CodeEntity[]>();
124
+ for (const member of members) {
125
+ const fg = fileGroups.get(member.file) ?? [];
126
+ fg.push(member);
127
+ fileGroups.set(member.file, fg);
128
+ }
129
+
130
+ for (const fileEntities of fileGroups.values()) {
131
+ for (let i = 0; i < fileEntities.length; i++) {
132
+ for (let j = i + 1; j < fileEntities.length; j++) {
133
+ const a = fileEntities[i];
134
+ const b = fileEntities[j];
135
+ if (a && b) {
136
+ addEdge(graph, {
137
+ source: `entity:${a.name}`,
138
+ target: `entity:${b.name}`,
139
+ type: "references",
140
+ weight: 0.5,
141
+ });
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ function addFeatures(
150
+ graph: KnowledgeGraph,
151
+ features: ExtractedFeature[],
152
+ ): void {
153
+ for (const feature of features) {
154
+ const featureId = `feature:${feature.id}`;
155
+ addNode(graph, {
156
+ id: featureId,
157
+ type: "feature",
158
+ label: feature.title || feature.id,
159
+ pageRank: 0,
160
+ });
161
+
162
+ // Link to modified entities
163
+ for (const entityName of feature.entitiesModified) {
164
+ const entityId = `entity:${entityName}`;
165
+ if (graph.nodes.has(entityId)) {
166
+ addEdge(graph, {
167
+ source: entityId,
168
+ target: featureId,
169
+ type: "modified_by",
170
+ weight: 1.0,
171
+ });
172
+ }
173
+ }
174
+
175
+ // Link to created decisions
176
+ for (const decisionName of feature.decisionsCreated) {
177
+ const decisionId = `decision:${decisionName}`;
178
+ if (graph.nodes.has(decisionId)) {
179
+ addEdge(graph, {
180
+ source: featureId,
181
+ target: decisionId,
182
+ type: "motivated_by",
183
+ weight: 0.8,
184
+ });
185
+ }
186
+ }
187
+
188
+ // Spec assertions create specified_by edges
189
+ if (feature.specAssertions.length > 0) {
190
+ addEdge(graph, {
191
+ source: featureId,
192
+ target: featureId,
193
+ type: "specified_by",
194
+ weight: 0.3,
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ function addDecisions(
201
+ graph: KnowledgeGraph,
202
+ decisions: ExtractedDecision[],
203
+ ): void {
204
+ for (const decision of decisions) {
205
+ const decisionId = `decision:${decision.id}`;
206
+ addNode(graph, {
207
+ id: decisionId,
208
+ type: "decision",
209
+ label: decision.title || decision.id,
210
+ pageRank: 0,
211
+ });
212
+
213
+ // Link to entity mentions
214
+ for (const mention of decision.entityMentions) {
215
+ // Try to find the entity by matching the end of the path
216
+ for (const [nodeId, node] of graph.nodes) {
217
+ if (node.type === "entity" && node.file === mention) {
218
+ addEdge(graph, {
219
+ source: nodeId,
220
+ target: decisionId,
221
+ type: "decided_by",
222
+ weight: 0.8,
223
+ });
224
+ }
225
+ }
226
+ }
227
+
228
+ // Constitution alignment creates aligns_with edges
229
+ for (const alignment of decision.constitutionAlignment) {
230
+ if (alignment) {
231
+ addEdge(graph, {
232
+ source: decisionId,
233
+ target: decisionId,
234
+ type: "aligns_with",
235
+ weight: 0.2,
236
+ });
237
+ }
238
+ }
239
+
240
+ // If decision constrains entities, add constrains edges
241
+ if (decision.status === "accepted") {
242
+ for (const mention of decision.entityMentions) {
243
+ for (const [nodeId, node] of graph.nodes) {
244
+ if (node.type === "entity" && node.file === mention) {
245
+ addEdge(graph, {
246
+ source: decisionId,
247
+ target: nodeId,
248
+ type: "constrains",
249
+ weight: 0.6,
250
+ });
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ function addWorkflowTraces(
259
+ graph: KnowledgeGraph,
260
+ traces: ExtractedWorkflowTrace[],
261
+ ): void {
262
+ for (const trace of traces) {
263
+ if (!trace.featureId) continue;
264
+
265
+ const workflowId = `workflow:${trace.featureId}`;
266
+ addNode(graph, {
267
+ id: workflowId,
268
+ type: "workflow",
269
+ label: `Workflow: ${trace.featureId}`,
270
+ pageRank: 0,
271
+ });
272
+
273
+ // Link to the feature
274
+ const featureId = `feature:${trace.featureId}`;
275
+ if (graph.nodes.has(featureId)) {
276
+ addEdge(graph, {
277
+ source: workflowId,
278
+ target: featureId,
279
+ type: "references",
280
+ weight: 0.5,
281
+ });
282
+ }
283
+ }
284
+ }
285
+
286
+ // ─── Public API ──────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Build a unified knowledge graph from all extracted data.
290
+ */
291
+ export function buildKnowledgeGraph(
292
+ entities: CodeEntity[],
293
+ features: ExtractedFeature[],
294
+ decisions: ExtractedDecision[],
295
+ traces: ExtractedWorkflowTrace[],
296
+ ): KnowledgeGraph {
297
+ const graph: KnowledgeGraph = {
298
+ nodes: new Map(),
299
+ edges: [],
300
+ adjacency: new Map(),
301
+ };
302
+
303
+ addCodeEntities(graph, entities);
304
+ addDecisions(graph, decisions);
305
+ addFeatures(graph, features);
306
+ addWorkflowTraces(graph, traces);
307
+
308
+ return graph;
309
+ }
310
+
311
+ /**
312
+ * Compute PageRank scores for all nodes in the graph.
313
+ * Uses iterative power method with damping factor 0.85.
314
+ */
315
+ export function computePageRank(
316
+ graph: KnowledgeGraph,
317
+ iterations = 20,
318
+ ): Map<string, number> {
319
+ const nodeIds = [...graph.nodes.keys()];
320
+ const n = nodeIds.length;
321
+
322
+ if (n === 0) return new Map();
323
+
324
+ const damping = 0.85;
325
+ const scores = new Map<string, number>();
326
+
327
+ // Initialize with uniform distribution
328
+ for (const id of nodeIds) {
329
+ scores.set(id, 1 / n);
330
+ }
331
+
332
+ // Build outgoing edge map
333
+ const outgoing = new Map<string, string[]>();
334
+ for (const edge of graph.edges) {
335
+ const list = outgoing.get(edge.source) ?? [];
336
+ list.push(edge.target);
337
+ outgoing.set(edge.source, list);
338
+ }
339
+
340
+ // Iterative computation
341
+ for (let iter = 0; iter < iterations; iter++) {
342
+ const newScores = new Map<string, number>();
343
+
344
+ for (const id of nodeIds) {
345
+ newScores.set(id, (1 - damping) / n);
346
+ }
347
+
348
+ for (const id of nodeIds) {
349
+ const outs = outgoing.get(id) ?? [];
350
+ if (outs.length === 0) {
351
+ // Dangling node: distribute evenly
352
+ const share = (scores.get(id) ?? 0) / n;
353
+ for (const target of nodeIds) {
354
+ newScores.set(target, (newScores.get(target) ?? 0) + damping * share);
355
+ }
356
+ } else {
357
+ const share = (scores.get(id) ?? 0) / outs.length;
358
+ for (const target of outs) {
359
+ newScores.set(target, (newScores.get(target) ?? 0) + damping * share);
360
+ }
361
+ }
362
+ }
363
+
364
+ // Update scores
365
+ for (const [id, score] of newScores) {
366
+ scores.set(id, score);
367
+ }
368
+ }
369
+
370
+ // Update graph nodes with computed PageRank
371
+ for (const [id, score] of scores) {
372
+ const node = graph.nodes.get(id);
373
+ if (node) {
374
+ node.pageRank = score;
375
+ }
376
+ }
377
+
378
+ return scores;
379
+ }
380
+
381
+ /**
382
+ * Map graph nodes to wiki article paths based on PageRank and community assignment.
383
+ * - Top 20% PageRank entities -> wiki/entities/
384
+ * - Louvain clusters -> wiki/modules/
385
+ * - Features -> wiki/features/
386
+ * - Decisions -> wiki/decisions/
387
+ */
388
+ export function mapToArticles(
389
+ graph: KnowledgeGraph,
390
+ communities: Map<number, string[]>,
391
+ ): Map<string, string> {
392
+ const articleMap = new Map<string, string>();
393
+
394
+ // Compute PageRank threshold for top 20%
395
+ const entityNodes = [...graph.nodes.entries()]
396
+ .filter(([, node]) => node.type === "entity")
397
+ .sort(([, a], [, b]) => b.pageRank - a.pageRank);
398
+
399
+ const top20Idx = Math.max(1, Math.ceil(entityNodes.length * 0.2));
400
+ const threshold =
401
+ entityNodes.length > 0
402
+ ? (entityNodes[Math.min(top20Idx - 1, entityNodes.length - 1)]?.[1]
403
+ ?.pageRank ?? 0)
404
+ : 0;
405
+
406
+ // Map top 20% entities
407
+ for (const [id, node] of entityNodes) {
408
+ if (node.pageRank >= threshold && threshold > 0) {
409
+ const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
410
+ articleMap.set(id, `wiki/entities/${safeName}.md`);
411
+ }
412
+ }
413
+
414
+ // Map Louvain clusters to module articles
415
+ for (const [commId, members] of communities) {
416
+ // Find a representative label for the community
417
+ const moduleNodes = members.filter(
418
+ (m) => graph.nodes.get(m)?.type === "module",
419
+ );
420
+ const label =
421
+ moduleNodes.length > 0
422
+ ? (graph.nodes.get(moduleNodes[0] ?? "")?.label ?? `cluster-${commId}`)
423
+ : `cluster-${commId}`;
424
+ const safeName = label.replace(/[^a-zA-Z0-9_-]/g, "-");
425
+ articleMap.set(`community:${commId}`, `wiki/modules/${safeName}.md`);
426
+ }
427
+
428
+ // Map features
429
+ for (const [id, node] of graph.nodes) {
430
+ if (node.type === "feature") {
431
+ const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
432
+ articleMap.set(id, `wiki/features/${safeName}.md`);
433
+ }
434
+ }
435
+
436
+ // Map decisions
437
+ for (const [id, node] of graph.nodes) {
438
+ if (node.type === "decision") {
439
+ const safeName = node.label.replace(/[^a-zA-Z0-9_-]/g, "-");
440
+ articleMap.set(id, `wiki/decisions/${safeName}.md`);
441
+ }
442
+ }
443
+
444
+ return articleMap;
445
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Wiki Post-Commit Hook — triggers incremental compilation after commits.
3
+ *
4
+ * Runs silently in the background. Never breaks the commit flow —
5
+ * all errors are caught and swallowed.
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { compile } from "./compiler";
11
+ import { loadState } from "./state";
12
+
13
+ /**
14
+ * Post-commit hook for wiki incremental compilation.
15
+ *
16
+ * Checks if the wiki has been initialized (has .state.json),
17
+ * then runs an incremental compile. Swallows all errors to
18
+ * never break the commit flow.
19
+ */
20
+ export async function onPostCommit(
21
+ mainaDir: string,
22
+ repoRoot: string,
23
+ ): Promise<void> {
24
+ try {
25
+ const wikiDir = join(mainaDir, "wiki");
26
+
27
+ // Skip if wiki directory does not exist
28
+ if (!existsSync(wikiDir)) {
29
+ return;
30
+ }
31
+
32
+ // Skip if wiki has not been initialized (no .state.json)
33
+ const state = loadState(wikiDir);
34
+ if (!state) {
35
+ return;
36
+ }
37
+
38
+ // Run incremental compilation (not full)
39
+ await compile({
40
+ repoRoot,
41
+ mainaDir,
42
+ wikiDir,
43
+ full: false,
44
+ dryRun: false,
45
+ });
46
+ } catch {
47
+ // Swallow all errors — never break the commit flow
48
+ }
49
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Wiki Indexer — generates the index.md table of contents for the wiki.
3
+ *
4
+ * Groups articles by type, sorts by PageRank within each group,
5
+ * and includes freshness indicators based on last compilation time.
6
+ */
7
+
8
+ import type { ArticleType, WikiArticle } from "./types";
9
+
10
+ // ─── Freshness Indicators ───────────────────────────────────────────────
11
+
12
+ /**
13
+ * Compute a freshness indicator based on how recently the article was compiled.
14
+ * Returns a symbol: fresh (< 1 day), recent (< 7 days), aging (< 30 days), stale (> 30 days).
15
+ */
16
+ function freshnessIndicator(lastCompiled: string): string {
17
+ if (!lastCompiled) return "[stale]";
18
+
19
+ const compiled = new Date(lastCompiled).getTime();
20
+ const now = Date.now();
21
+ const daysAgo = (now - compiled) / (1000 * 60 * 60 * 24);
22
+
23
+ if (daysAgo < 1) return "[fresh]";
24
+ if (daysAgo < 7) return "[recent]";
25
+ if (daysAgo < 30) return "[aging]";
26
+ return "[stale]";
27
+ }
28
+
29
+ // ─── Type Labels ────────────────────────────────────────────────────────
30
+
31
+ const TYPE_LABELS: Record<ArticleType, string> = {
32
+ architecture: "Architecture",
33
+ module: "Modules",
34
+ entity: "Entities",
35
+ feature: "Features",
36
+ decision: "Decisions",
37
+ raw: "Other",
38
+ };
39
+
40
+ const TYPE_ORDER: ArticleType[] = [
41
+ "architecture",
42
+ "module",
43
+ "entity",
44
+ "feature",
45
+ "decision",
46
+ "raw",
47
+ ];
48
+
49
+ // ─── Public API ──────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Generate the wiki index.md content.
53
+ * Groups articles by type, sorts by PageRank (descending) within each group,
54
+ * and includes freshness indicators.
55
+ */
56
+ export function generateIndex(articles: WikiArticle[]): string {
57
+ const lines: string[] = [];
58
+
59
+ lines.push("# Wiki Index");
60
+ lines.push("");
61
+ lines.push(
62
+ `> Auto-generated index. ${articles.length} articles across ${countTypes(articles)} categories.`,
63
+ );
64
+ lines.push("");
65
+
66
+ // Group articles by type
67
+ const grouped = new Map<ArticleType, WikiArticle[]>();
68
+ for (const article of articles) {
69
+ const list = grouped.get(article.type) ?? [];
70
+ list.push(article);
71
+ grouped.set(article.type, list);
72
+ }
73
+
74
+ // Render each type section in defined order
75
+ for (const type of TYPE_ORDER) {
76
+ const group = grouped.get(type);
77
+ if (!group || group.length === 0) continue;
78
+
79
+ // Sort by PageRank descending
80
+ const sorted = [...group].sort((a, b) => b.pageRank - a.pageRank);
81
+
82
+ const label = TYPE_LABELS[type];
83
+ lines.push(`## ${label}`);
84
+ lines.push("");
85
+
86
+ for (const article of sorted) {
87
+ const freshness = freshnessIndicator(article.lastCompiled);
88
+ lines.push(`- [${article.title}](${article.path}) ${freshness}`);
89
+ }
90
+
91
+ lines.push("");
92
+ }
93
+
94
+ return lines.join("\n");
95
+ }
96
+
97
+ // ─── Helpers ─────────────────────────────────────────────────────────────
98
+
99
+ function countTypes(articles: WikiArticle[]): number {
100
+ const types = new Set<ArticleType>();
101
+ for (const article of articles) {
102
+ types.add(article.type);
103
+ }
104
+ return types.size;
105
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Wiki Linker — generates forward links and backlinks between wiki articles.
3
+ *
4
+ * Traverses the knowledge graph edges and maps them to wiki article paths
5
+ * to produce bidirectional link maps used for wikilink injection.
6
+ */
7
+
8
+ import type { KnowledgeGraph } from "./graph";
9
+ import type { WikiLink } from "./types";
10
+
11
+ // ─── Types ───────────────────────────────────────────────────────────────
12
+
13
+ export interface LinkResult {
14
+ forwardLinks: Map<string, WikiLink[]>;
15
+ backlinks: Map<string, WikiLink[]>;
16
+ }
17
+
18
+ // ─── Helpers ─────────────────────────────────────────────────────────────
19
+
20
+ function addToLinkMap(
21
+ map: Map<string, WikiLink[]>,
22
+ key: string,
23
+ link: WikiLink,
24
+ ): void {
25
+ const list = map.get(key) ?? [];
26
+ list.push(link);
27
+ map.set(key, list);
28
+ }
29
+
30
+ /**
31
+ * Find the article path for a given node ID.
32
+ * Checks direct mapping first, then checks if the node belongs to a community.
33
+ */
34
+ function resolveArticlePath(
35
+ nodeId: string,
36
+ articleMap: Map<string, string>,
37
+ graph: KnowledgeGraph,
38
+ ): string | null {
39
+ // Direct match
40
+ const direct = articleMap.get(nodeId);
41
+ if (direct) return direct;
42
+
43
+ // Check if the node is part of a community that has an article
44
+ for (const [key, path] of articleMap) {
45
+ if (key.startsWith("community:")) {
46
+ // This is a module article — check if the node is in this community's adjacency
47
+ const node = graph.nodes.get(nodeId);
48
+ if (node?.type === "module") {
49
+ const moduleName = node.label;
50
+ if (path.includes(moduleName)) {
51
+ return path;
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ // ─── Public API ──────────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Generate forward links and backlinks between wiki articles based on graph edges.
64
+ *
65
+ * For each edge in the knowledge graph, if both source and target map to
66
+ * wiki articles, create a forward link from source article to target article
67
+ * and a backlink from target article to source article.
68
+ */
69
+ export function generateLinks(
70
+ graph: KnowledgeGraph,
71
+ articleMap: Map<string, string>,
72
+ ): LinkResult {
73
+ const forwardLinks = new Map<string, WikiLink[]>();
74
+ const backlinks = new Map<string, WikiLink[]>();
75
+
76
+ for (const edge of graph.edges) {
77
+ const sourcePath = resolveArticlePath(edge.source, articleMap, graph);
78
+ const targetPath = resolveArticlePath(edge.target, articleMap, graph);
79
+
80
+ if (!sourcePath || !targetPath) continue;
81
+ if (sourcePath === targetPath) continue;
82
+
83
+ // Forward link: source -> target
84
+ addToLinkMap(forwardLinks, sourcePath, {
85
+ target: targetPath,
86
+ type: edge.type,
87
+ weight: edge.weight,
88
+ });
89
+
90
+ // Backlink: target -> source
91
+ addToLinkMap(backlinks, targetPath, {
92
+ target: sourcePath,
93
+ type: edge.type,
94
+ weight: edge.weight,
95
+ });
96
+ }
97
+
98
+ // Deduplicate links (same target + type = single link with max weight)
99
+ const dedup = (map: Map<string, WikiLink[]>): void => {
100
+ for (const [key, links] of map) {
101
+ const seen = new Map<string, WikiLink>();
102
+ for (const link of links) {
103
+ const dedupKey = `${link.target}:${link.type}`;
104
+ const existing = seen.get(dedupKey);
105
+ if (!existing || link.weight > existing.weight) {
106
+ seen.set(dedupKey, link);
107
+ }
108
+ }
109
+ map.set(key, [...seen.values()]);
110
+ }
111
+ };
112
+
113
+ dedup(forwardLinks);
114
+ dedup(backlinks);
115
+
116
+ return { forwardLinks, backlinks };
117
+ }