@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,338 @@
1
+ /**
2
+ * Wiki Query — AI-powered question answering over wiki articles.
3
+ *
4
+ * Loads relevant wiki articles, scores them by keyword relevance,
5
+ * then uses AI to synthesize a coherent answer citing sources.
6
+ * Falls back to keyword excerpts when AI is unavailable.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import type { Result } from "../db/index";
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────
14
+
15
+ export interface WikiQueryResult {
16
+ answer: string;
17
+ sources: string[];
18
+ cached: boolean;
19
+ }
20
+
21
+ export interface WikiQueryOptions {
22
+ wikiDir: string;
23
+ question: string;
24
+ maxArticles?: number;
25
+ repoRoot?: string;
26
+ /** Optional override for AI generation (used in tests). */
27
+ _aiGenerate?: (
28
+ task: string,
29
+ mainaDir: string,
30
+ variables: Record<string, string>,
31
+ userPrompt: string,
32
+ ) => Promise<{ text: string | null; fromAI: boolean }>;
33
+ }
34
+
35
+ interface LoadedArticle {
36
+ path: string;
37
+ content: string;
38
+ title: string;
39
+ }
40
+
41
+ interface ScoredArticle extends LoadedArticle {
42
+ score: number;
43
+ excerpt: string;
44
+ }
45
+
46
+ // ─── Constants ───────────────────────────────────────────────────────────
47
+
48
+ const DEFAULT_MAX_ARTICLES = 10;
49
+
50
+ const ARTICLE_SUBDIRS = [
51
+ "modules",
52
+ "entities",
53
+ "features",
54
+ "decisions",
55
+ "architecture",
56
+ "raw",
57
+ ] as const;
58
+
59
+ // ─── Helpers ─────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Tokenize text into lowercase words, removing punctuation and short words.
63
+ */
64
+ function tokenize(text: string): string[] {
65
+ return text
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9\s]/g, " ")
68
+ .split(/\s+/)
69
+ .filter((w) => w.length > 2);
70
+ }
71
+
72
+ /**
73
+ * Extract the first heading from markdown content.
74
+ */
75
+ function extractTitle(content: string): string {
76
+ const firstLine = content.split("\n")[0] ?? "";
77
+ return firstLine.replace(/^#+\s*/, "").trim();
78
+ }
79
+
80
+ /**
81
+ * Extract the most relevant excerpt from content given query tokens.
82
+ */
83
+ function extractExcerpt(
84
+ content: string,
85
+ queryTokens: string[],
86
+ maxLength = 200,
87
+ ): string {
88
+ const paragraphs = content.split(/\n\n+/).filter((p) => p.trim().length > 0);
89
+
90
+ let bestParagraph = "";
91
+ let bestScore = -1;
92
+
93
+ for (const paragraph of paragraphs) {
94
+ if (paragraph.trim().startsWith("#") && !paragraph.includes("\n")) {
95
+ continue;
96
+ }
97
+
98
+ const words = tokenize(paragraph);
99
+ const matchCount = queryTokens.filter((qt) =>
100
+ words.some((w) => w.includes(qt)),
101
+ ).length;
102
+
103
+ if (matchCount > bestScore) {
104
+ bestScore = matchCount;
105
+ bestParagraph = paragraph;
106
+ }
107
+ }
108
+
109
+ const cleaned = bestParagraph.replace(/\n/g, " ").trim();
110
+ if (cleaned.length > maxLength) {
111
+ return `${cleaned.slice(0, maxLength)}...`;
112
+ }
113
+ return cleaned;
114
+ }
115
+
116
+ /**
117
+ * Load all markdown articles from wiki subdirectories.
118
+ */
119
+ function loadArticles(wikiDir: string): LoadedArticle[] {
120
+ const articles: LoadedArticle[] = [];
121
+
122
+ for (const subdir of ARTICLE_SUBDIRS) {
123
+ const dir = join(wikiDir, subdir);
124
+ if (!existsSync(dir)) continue;
125
+
126
+ let entries: string[];
127
+ try {
128
+ entries = readdirSync(dir);
129
+ } catch {
130
+ continue;
131
+ }
132
+
133
+ for (const entry of entries) {
134
+ if (!entry.endsWith(".md")) continue;
135
+ const fullPath = join(dir, entry);
136
+ try {
137
+ const content = readFileSync(fullPath, "utf-8");
138
+ const title = extractTitle(content);
139
+ articles.push({ path: `${subdir}/${entry}`, content, title });
140
+ } catch {
141
+ // skip unreadable files
142
+ }
143
+ }
144
+ }
145
+
146
+ return articles;
147
+ }
148
+
149
+ /**
150
+ * Score articles by keyword relevance to the question.
151
+ */
152
+ function scoreArticles(
153
+ articles: LoadedArticle[],
154
+ question: string,
155
+ ): ScoredArticle[] {
156
+ const queryTokens = tokenize(question);
157
+ if (queryTokens.length === 0) return [];
158
+
159
+ const scored: ScoredArticle[] = [];
160
+
161
+ for (const article of articles) {
162
+ const contentTokens = tokenize(article.content);
163
+ const titleTokens = tokenize(article.title);
164
+
165
+ let matchCount = 0;
166
+ for (const qt of queryTokens) {
167
+ if (contentTokens.some((ct) => ct.includes(qt))) {
168
+ matchCount++;
169
+ }
170
+ }
171
+
172
+ // Bonus for title matches
173
+ for (const qt of queryTokens) {
174
+ if (titleTokens.some((tt) => tt.includes(qt))) {
175
+ matchCount += 2;
176
+ }
177
+ }
178
+
179
+ if (matchCount > 0) {
180
+ const score = matchCount / queryTokens.length;
181
+ const excerpt = extractExcerpt(article.content, queryTokens);
182
+ scored.push({ ...article, score, excerpt });
183
+ }
184
+ }
185
+
186
+ scored.sort((a, b) => b.score - a.score);
187
+ return scored;
188
+ }
189
+
190
+ /**
191
+ * Format article contents for the AI prompt context window.
192
+ */
193
+ function formatArticlesForPrompt(articles: ScoredArticle[]): string {
194
+ return articles.map((a) => `## ${a.path}\n${a.content}\n---`).join("\n\n");
195
+ }
196
+
197
+ /**
198
+ * Build a fallback answer from keyword-scored articles (no AI).
199
+ */
200
+ function buildFallbackAnswer(
201
+ scored: ScoredArticle[],
202
+ maxArticles: number,
203
+ ): WikiQueryResult {
204
+ const topResults = scored.slice(0, maxArticles);
205
+
206
+ if (topResults.length === 0) {
207
+ return {
208
+ answer: "No relevant articles found.",
209
+ sources: [],
210
+ cached: false,
211
+ };
212
+ }
213
+
214
+ const parts: string[] = [];
215
+ parts.push(
216
+ `Found ${scored.length} relevant article(s) (keyword match, AI unavailable):\n`,
217
+ );
218
+
219
+ for (let i = 0; i < topResults.length; i++) {
220
+ const result = topResults[i];
221
+ if (!result) continue;
222
+ parts.push(
223
+ `${i + 1}. **${result.title}** (\`${result.path}\`, score: ${result.score.toFixed(2)})`,
224
+ );
225
+ if (result.excerpt) {
226
+ parts.push(` ${result.excerpt}`);
227
+ }
228
+ parts.push("");
229
+ }
230
+
231
+ return {
232
+ answer: parts.join("\n"),
233
+ sources: topResults.map((r) => r.path),
234
+ cached: false,
235
+ };
236
+ }
237
+
238
+ // ─── Main ────────────────────────────────────────────────────────────────
239
+
240
+ /**
241
+ * Query the wiki with a natural-language question.
242
+ *
243
+ * 1. Loads all wiki articles
244
+ * 2. Scores by keyword relevance
245
+ * 3. Takes top N articles as context
246
+ * 4. Calls AI to synthesize an answer citing sources
247
+ * 5. Falls back to keyword excerpts when AI is unavailable
248
+ */
249
+ export async function queryWiki(
250
+ options: WikiQueryOptions,
251
+ ): Promise<Result<WikiQueryResult>> {
252
+ const { wikiDir, question, maxArticles = DEFAULT_MAX_ARTICLES } = options;
253
+
254
+ // Check wiki exists
255
+ if (!existsSync(wikiDir)) {
256
+ return {
257
+ ok: true,
258
+ value: {
259
+ answer: "Wiki not initialized. Run `maina wiki init` first.",
260
+ sources: [],
261
+ cached: false,
262
+ },
263
+ };
264
+ }
265
+
266
+ // Load articles
267
+ const articles = loadArticles(wikiDir);
268
+ if (articles.length === 0) {
269
+ return {
270
+ ok: true,
271
+ value: {
272
+ answer: "Wiki is empty. Run `maina wiki compile` to generate articles.",
273
+ sources: [],
274
+ cached: false,
275
+ },
276
+ };
277
+ }
278
+
279
+ // Score and rank
280
+ const scored = scoreArticles(articles, question);
281
+ if (scored.length === 0) {
282
+ return {
283
+ ok: true,
284
+ value: {
285
+ answer: `No articles match the query: "${question}"`,
286
+ sources: [],
287
+ cached: false,
288
+ },
289
+ };
290
+ }
291
+
292
+ const topArticles = scored.slice(0, maxArticles);
293
+ const sources = topArticles.map((a) => a.path);
294
+
295
+ // Try AI synthesis
296
+ try {
297
+ const aiGenerate =
298
+ options._aiGenerate ?? (await import("../ai/try-generate")).tryAIGenerate;
299
+ const mainaDir = options.repoRoot
300
+ ? join(options.repoRoot, ".maina")
301
+ : join(wikiDir, "..");
302
+
303
+ const articlesContext = formatArticlesForPrompt(topArticles);
304
+ const userPrompt = [
305
+ `Question: ${question}`,
306
+ "",
307
+ "Here are relevant wiki articles:",
308
+ "",
309
+ articlesContext,
310
+ ].join("\n");
311
+
312
+ const aiResult = await aiGenerate(
313
+ "wiki-query",
314
+ mainaDir,
315
+ { question },
316
+ userPrompt,
317
+ );
318
+
319
+ if (aiResult.text) {
320
+ return {
321
+ ok: true,
322
+ value: {
323
+ answer: aiResult.text,
324
+ sources,
325
+ cached: false,
326
+ },
327
+ };
328
+ }
329
+ } catch {
330
+ // AI unavailable — fall through to fallback
331
+ }
332
+
333
+ // Fallback: keyword-based excerpts
334
+ return {
335
+ ok: true,
336
+ value: buildFallbackAnswer(scored, maxArticles),
337
+ };
338
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Wiki Schema — defines article structure, max lengths, and linking conventions.
3
+ *
4
+ * The schema co-evolves with compilation prompts. It defines what valid
5
+ * wiki articles look like for each article type.
6
+ */
7
+
8
+ import type { ArticleType } from "./types";
9
+
10
+ // ─── Types ───────────────────────────────────────────────────────────────
11
+
12
+ export interface ArticleRule {
13
+ maxLength: number;
14
+ requiredSections: string[];
15
+ linkPrefix: string;
16
+ }
17
+
18
+ export interface WikiSchema {
19
+ version: string;
20
+ articleRules: Record<ArticleType, ArticleRule>;
21
+ }
22
+
23
+ export interface ValidationResult {
24
+ valid: boolean;
25
+ issues: string[];
26
+ }
27
+
28
+ // ─── Default Schema ──────────────────────────────────────────────────────
29
+
30
+ export const DEFAULT_SCHEMA: WikiSchema = {
31
+ version: "1.0.0",
32
+ articleRules: {
33
+ module: {
34
+ maxLength: 10_000,
35
+ requiredSections: [],
36
+ linkPrefix: "module",
37
+ },
38
+ entity: {
39
+ maxLength: 5_000,
40
+ requiredSections: [],
41
+ linkPrefix: "entity",
42
+ },
43
+ feature: {
44
+ maxLength: 8_000,
45
+ requiredSections: [],
46
+ linkPrefix: "feature",
47
+ },
48
+ decision: {
49
+ maxLength: 8_000,
50
+ requiredSections: [],
51
+ linkPrefix: "decision",
52
+ },
53
+ architecture: {
54
+ maxLength: 10_000,
55
+ requiredSections: [],
56
+ linkPrefix: "architecture",
57
+ },
58
+ raw: {
59
+ maxLength: 10_000,
60
+ requiredSections: [],
61
+ linkPrefix: "raw",
62
+ },
63
+ },
64
+ };
65
+
66
+ // ─── Helpers ─────────────────────────────────────────────────────────────
67
+
68
+ export function getArticleMaxLength(type: ArticleType): number {
69
+ return DEFAULT_SCHEMA.articleRules[type].maxLength;
70
+ }
71
+
72
+ export function getLinkSyntax(type: ArticleType, id: string): string {
73
+ const prefix = DEFAULT_SCHEMA.articleRules[type].linkPrefix;
74
+ return `[[${prefix}:${id}]]`;
75
+ }
76
+
77
+ /**
78
+ * Validate an article's structure against the schema rules for its type.
79
+ * Returns validation result with any issues found.
80
+ */
81
+ export function validateArticleStructure(
82
+ type: ArticleType,
83
+ content: string,
84
+ ): ValidationResult {
85
+ const issues: string[] = [];
86
+ const rule = DEFAULT_SCHEMA.articleRules[type];
87
+
88
+ // Check for title (must start with # heading)
89
+ if (!content.trimStart().startsWith("#")) {
90
+ issues.push("Article must start with a markdown heading (# Title)");
91
+ }
92
+
93
+ // Check max length
94
+ if (content.length > rule.maxLength) {
95
+ issues.push(
96
+ `Article exceeds max length: ${content.length} > ${rule.maxLength}`,
97
+ );
98
+ }
99
+
100
+ // Check required sections
101
+ for (const section of rule.requiredSections) {
102
+ if (!content.includes(`## ${section}`)) {
103
+ issues.push(`Missing required section: ## ${section}`);
104
+ }
105
+ }
106
+
107
+ return {
108
+ valid: issues.length === 0,
109
+ issues,
110
+ };
111
+ }