@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.
- package/package.json +2 -1
- package/src/ai/delegation.ts +14 -3
- package/src/cloud/client.ts +11 -1
- package/src/index.ts +18 -0
- package/src/init/index.ts +7 -0
- package/src/review/index.ts +86 -0
- package/src/wiki/__tests__/consult.test.ts +341 -0
- package/src/wiki/__tests__/search.test.ts +384 -0
- package/src/wiki/compiler.ts +11 -0
- package/src/wiki/consult.ts +395 -0
- package/src/wiki/query.ts +28 -2
- package/src/wiki/search.ts +346 -0
|
@@ -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
|
-
//
|
|
280
|
-
|
|
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,
|