@mainahq/core 1.1.1 → 1.1.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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Wiki Consult — pre-command wiki consultation for plan, design, and brainstorm.
3
+ *
4
+ * Searches wiki articles by keyword overlap to surface existing modules,
5
+ * decisions, and features relevant to a proposed change. All operations
6
+ * are synchronous file reads + keyword matching — no AI calls.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ // ─── Types ───────────────────────────────────────────────────────────────
13
+
14
+ export interface WikiConsultResult {
15
+ relatedModules: Array<{ name: string; path: string; entities: number }>;
16
+ relatedDecisions: Array<{ id: string; title: string; status: string }>;
17
+ relatedFeatures: Array<{ id: string; title: string }>;
18
+ suggestions: string[];
19
+ }
20
+
21
+ export interface WikiDesignConsultResult {
22
+ conflicts: Array<{ adr: string; title: string; reason: string }>;
23
+ alignments: Array<{ adr: string; title: string }>;
24
+ }
25
+
26
+ export interface WikiBrainstormContext {
27
+ architecture: string;
28
+ moduleCount: number;
29
+ decisionCount: number;
30
+ recentFeatures: string[];
31
+ }
32
+
33
+ // ─── Constants ───────────────────────────────────────────────────────────
34
+
35
+ const NOISE_WORDS = new Set([
36
+ "a",
37
+ "an",
38
+ "the",
39
+ "to",
40
+ "in",
41
+ "on",
42
+ "for",
43
+ "and",
44
+ "or",
45
+ "with",
46
+ "from",
47
+ "is",
48
+ "it",
49
+ "be",
50
+ "as",
51
+ "at",
52
+ "by",
53
+ "of",
54
+ "that",
55
+ "this",
56
+ "was",
57
+ "are",
58
+ "will",
59
+ "can",
60
+ "has",
61
+ "have",
62
+ "not",
63
+ "but",
64
+ "all",
65
+ "new",
66
+ "add",
67
+ "use",
68
+ ]);
69
+
70
+ // ─── Helpers ─────────────────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Tokenize text into lowercase keywords, removing punctuation and noise words.
74
+ */
75
+ function tokenize(text: string): string[] {
76
+ return text
77
+ .toLowerCase()
78
+ .replace(/[^a-z0-9\s-]/g, " ")
79
+ .split(/\s+/)
80
+ .filter((w) => w.length > 2 && !NOISE_WORDS.has(w));
81
+ }
82
+
83
+ /**
84
+ * Score content against query keywords. Returns 0-1 based on keyword overlap.
85
+ */
86
+ function scoreByKeywords(content: string, keywords: string[]): number {
87
+ if (keywords.length === 0) return 0;
88
+ const contentTokens = new Set(tokenize(content));
89
+ let matches = 0;
90
+ for (const kw of keywords) {
91
+ for (const ct of contentTokens) {
92
+ if (ct.includes(kw) || kw.includes(ct)) {
93
+ matches++;
94
+ break;
95
+ }
96
+ }
97
+ }
98
+ return matches / keywords.length;
99
+ }
100
+
101
+ /**
102
+ * Safely read all .md files in a wiki subdirectory.
103
+ */
104
+ function readArticles(
105
+ wikiDir: string,
106
+ subdir: string,
107
+ ): Array<{ filename: string; content: string }> {
108
+ const dir = join(wikiDir, subdir);
109
+ if (!existsSync(dir)) return [];
110
+
111
+ let entries: string[];
112
+ try {
113
+ entries = readdirSync(dir);
114
+ } catch {
115
+ return [];
116
+ }
117
+
118
+ const articles: Array<{ filename: string; content: string }> = [];
119
+ for (const entry of entries) {
120
+ if (!entry.endsWith(".md")) continue;
121
+ try {
122
+ const content = readFileSync(join(dir, entry), "utf-8");
123
+ articles.push({ filename: entry, content });
124
+ } catch {
125
+ // skip unreadable files
126
+ }
127
+ }
128
+ return articles;
129
+ }
130
+
131
+ /**
132
+ * Extract the first heading from markdown.
133
+ */
134
+ function extractTitle(content: string): string {
135
+ const firstLine = content.split("\n")[0] ?? "";
136
+ return firstLine.replace(/^#+\s*/, "").trim();
137
+ }
138
+
139
+ /**
140
+ * Count entities listed in a module article (lines matching `- **name** (kind)`).
141
+ */
142
+ function countEntities(content: string): number {
143
+ const entityPattern = /^- \*\*.+\*\* \(.+\)/gm;
144
+ const matches = content.match(entityPattern);
145
+ return matches?.length ?? 0;
146
+ }
147
+
148
+ /**
149
+ * Extract the status from a decision article (e.g., `> Status: **accepted**`).
150
+ */
151
+ function extractStatus(content: string): string {
152
+ const statusMatch = content.match(/>\s*Status:\s*\*\*(\w+)\*\*/);
153
+ return statusMatch?.[1] ?? "unknown";
154
+ }
155
+
156
+ /**
157
+ * Extract key assertions from a decision article for conflict detection.
158
+ * Returns lowercased phrases from the Decision and Context sections.
159
+ */
160
+ function extractDecisionAssertions(content: string): string[] {
161
+ const assertions: string[] = [];
162
+
163
+ // Extract from "## Decision" section
164
+ const decisionMatch = content.match(
165
+ /## Decision\n\n([\s\S]*?)(?=\n## |\n---|$)/,
166
+ );
167
+ if (decisionMatch?.[1]) {
168
+ assertions.push(decisionMatch[1].trim().toLowerCase());
169
+ }
170
+
171
+ // Extract from "## Context" section
172
+ const contextMatch = content.match(
173
+ /## Context\n\n([\s\S]*?)(?=\n## |\n---|$)/,
174
+ );
175
+ if (contextMatch?.[1]) {
176
+ assertions.push(contextMatch[1].trim().toLowerCase());
177
+ }
178
+
179
+ return assertions;
180
+ }
181
+
182
+ // ─── Public API ──────────────────────────────────────────────────────────
183
+
184
+ /**
185
+ * Consult the wiki before creating a new feature plan.
186
+ * Searches for existing modules, decisions, and features that overlap.
187
+ */
188
+ export function consultWikiForPlan(
189
+ wikiDir: string,
190
+ featureDescription: string,
191
+ ): WikiConsultResult {
192
+ const result: WikiConsultResult = {
193
+ relatedModules: [],
194
+ relatedDecisions: [],
195
+ relatedFeatures: [],
196
+ suggestions: [],
197
+ };
198
+
199
+ if (!existsSync(wikiDir)) return result;
200
+
201
+ const keywords = tokenize(featureDescription);
202
+ if (keywords.length === 0) return result;
203
+
204
+ // Score modules
205
+ const modules = readArticles(wikiDir, "modules");
206
+ for (const mod of modules) {
207
+ const score = scoreByKeywords(mod.content, keywords);
208
+ if (score > 0.2) {
209
+ const name = mod.filename.replace(/\.md$/, "");
210
+ const entities = countEntities(mod.content);
211
+ result.relatedModules.push({
212
+ name,
213
+ path: `modules/${mod.filename}`,
214
+ entities,
215
+ });
216
+ }
217
+ }
218
+ result.relatedModules.sort((a, b) => b.entities - a.entities);
219
+
220
+ // Score decisions
221
+ const decisions = readArticles(wikiDir, "decisions");
222
+ for (const dec of decisions) {
223
+ const score = scoreByKeywords(dec.content, keywords);
224
+ if (score > 0.2) {
225
+ const title = extractTitle(dec.content)
226
+ .replace(/^Decision:\s*/i, "")
227
+ .trim();
228
+ const id = dec.filename.replace(/\.md$/, "");
229
+ const status = extractStatus(dec.content);
230
+ result.relatedDecisions.push({ id, title, status });
231
+ }
232
+ }
233
+
234
+ // Score features
235
+ const features = readArticles(wikiDir, "features");
236
+ for (const feat of features) {
237
+ const score = scoreByKeywords(feat.content, keywords);
238
+ if (score > 0.2) {
239
+ const title = extractTitle(feat.content)
240
+ .replace(/^Feature:\s*/i, "")
241
+ .trim();
242
+ const id = feat.filename.replace(/\.md$/, "");
243
+ result.relatedFeatures.push({ id, title });
244
+ }
245
+ }
246
+
247
+ // Generate suggestions
248
+ for (const mod of result.relatedModules) {
249
+ if (mod.entities > 5) {
250
+ result.suggestions.push(
251
+ `Module '${mod.name}' already has ${mod.entities} entities — consider extending it`,
252
+ );
253
+ }
254
+ }
255
+
256
+ for (const feat of result.relatedFeatures) {
257
+ result.suggestions.push(
258
+ `Feature ${feat.id} did something similar — check wiki/features/${feat.id}.md`,
259
+ );
260
+ }
261
+
262
+ for (const dec of result.relatedDecisions) {
263
+ if (dec.status === "accepted") {
264
+ result.suggestions.push(
265
+ `ADR ${dec.id} (${dec.title}) is accepted — ensure compatibility`,
266
+ );
267
+ }
268
+ }
269
+
270
+ return result;
271
+ }
272
+
273
+ /**
274
+ * Check existing ADRs for potential conflicts with a proposed design decision.
275
+ */
276
+ export function consultWikiForDesign(
277
+ wikiDir: string,
278
+ proposedDecision: string,
279
+ ): WikiDesignConsultResult {
280
+ const result: WikiDesignConsultResult = {
281
+ conflicts: [],
282
+ alignments: [],
283
+ };
284
+
285
+ if (!existsSync(wikiDir)) return result;
286
+
287
+ const proposedLower = proposedDecision.toLowerCase();
288
+ const proposedKeywords = tokenize(proposedDecision);
289
+ if (proposedKeywords.length === 0) return result;
290
+
291
+ const decisions = readArticles(wikiDir, "decisions");
292
+
293
+ // Known tool/pattern pairs that conflict
294
+ const CONFLICT_PAIRS: Array<[string, string]> = [
295
+ ["biome", "eslint"],
296
+ ["biome", "prettier"],
297
+ ["jest", "bun:test"],
298
+ ["vitest", "bun:test"],
299
+ ["node", "bun"],
300
+ ["npm", "bun"],
301
+ ["yarn", "bun"],
302
+ ["pnpm", "bun"],
303
+ ["mongodb", "sqlite"],
304
+ ["postgres", "sqlite"],
305
+ ];
306
+
307
+ for (const dec of decisions) {
308
+ const status = extractStatus(dec.content);
309
+ if (status !== "accepted" && status !== "proposed") continue;
310
+
311
+ const title = extractTitle(dec.content)
312
+ .replace(/^Decision:\s*/i, "")
313
+ .trim();
314
+ const id = dec.filename.replace(/\.md$/, "");
315
+ const assertions = extractDecisionAssertions(dec.content);
316
+ const assertionText = assertions.join(" ");
317
+
318
+ // Check for keyword alignment
319
+ const score = scoreByKeywords(dec.content, proposedKeywords);
320
+
321
+ // Check for conflicts via known pairs
322
+ let conflictFound = false;
323
+ for (const [toolA, toolB] of CONFLICT_PAIRS) {
324
+ const adrHasA = assertionText.includes(toolA);
325
+ const adrHasB = assertionText.includes(toolB);
326
+ const proposedHasA = proposedLower.includes(toolA);
327
+ const proposedHasB = proposedLower.includes(toolB);
328
+
329
+ if ((adrHasA && proposedHasB) || (adrHasB && proposedHasA)) {
330
+ const adrTool = adrHasA ? toolA : toolB;
331
+ const proposedTool = proposedHasA ? toolA : toolB;
332
+ result.conflicts.push({
333
+ adr: id,
334
+ title,
335
+ reason: `ADR chose ${adrTool}, proposal uses ${proposedTool}`,
336
+ });
337
+ conflictFound = true;
338
+ break;
339
+ }
340
+ }
341
+
342
+ // If no conflict but keywords overlap, it's an alignment
343
+ if (!conflictFound && score > 0.2) {
344
+ result.alignments.push({ adr: id, title });
345
+ }
346
+ }
347
+
348
+ return result;
349
+ }
350
+
351
+ /**
352
+ * Load architecture context for brainstorming.
353
+ */
354
+ export function consultWikiForBrainstorm(
355
+ wikiDir: string,
356
+ ): WikiBrainstormContext {
357
+ const result: WikiBrainstormContext = {
358
+ architecture: "",
359
+ moduleCount: 0,
360
+ decisionCount: 0,
361
+ recentFeatures: [],
362
+ };
363
+
364
+ if (!existsSync(wikiDir)) return result;
365
+
366
+ // Load architecture articles
367
+ const archArticles = readArticles(wikiDir, "architecture");
368
+ if (archArticles.length > 0) {
369
+ result.architecture = archArticles
370
+ .map((a) => a.content)
371
+ .join("\n\n---\n\n");
372
+ }
373
+
374
+ // Count modules
375
+ const modules = readArticles(wikiDir, "modules");
376
+ result.moduleCount = modules.length;
377
+
378
+ // Count decisions
379
+ const decisions = readArticles(wikiDir, "decisions");
380
+ result.decisionCount = decisions.length;
381
+
382
+ // Load recent features (last 5 by filename sort)
383
+ const features = readArticles(wikiDir, "features");
384
+ const sorted = features
385
+ .map((f) => ({
386
+ id: f.filename.replace(/\.md$/, ""),
387
+ title: extractTitle(f.content),
388
+ }))
389
+ .sort((a, b) => b.id.localeCompare(a.id))
390
+ .slice(0, 5);
391
+
392
+ result.recentFeatures = sorted.map((f) => `${f.id}: ${f.title}`);
393
+
394
+ return result;
395
+ }
package/src/wiki/query.ts CHANGED
@@ -276,8 +276,34 @@ export async function queryWiki(
276
276
  };
277
277
  }
278
278
 
279
- // Score and rank
280
- const scored = scoreArticles(articles, question);
279
+ // Try Orama search first (BM25 + fuzzy matching), fall back to keyword matching
280
+ let scored: ScoredArticle[];
281
+ try {
282
+ const { searchWiki } = await import("./search");
283
+ const oramaResults = await searchWiki(wikiDir, question, {
284
+ limit: maxArticles,
285
+ });
286
+
287
+ if (oramaResults.length > 0) {
288
+ // Map Orama results back to ScoredArticle format for downstream compatibility
289
+ scored = oramaResults.map((r) => {
290
+ const article = articles.find((a) => a.path === r.path);
291
+ return {
292
+ path: r.path,
293
+ content: article?.content ?? "",
294
+ title: r.title,
295
+ score: r.score,
296
+ excerpt: r.excerpt,
297
+ };
298
+ });
299
+ } else {
300
+ scored = scoreArticles(articles, question);
301
+ }
302
+ } catch {
303
+ // Orama unavailable — fall back to keyword matching
304
+ scored = scoreArticles(articles, question);
305
+ }
306
+
281
307
  if (scored.length === 0) {
282
308
  return {
283
309
  ok: true,