@mainahq/core 1.1.0 → 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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for wiki search — Orama-powered full-text search over wiki articles.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
buildSearchIndex,
|
|
10
|
+
loadSearchIndex,
|
|
11
|
+
saveSearchIndex,
|
|
12
|
+
searchWiki,
|
|
13
|
+
} from "../search";
|
|
14
|
+
|
|
15
|
+
// ── Test Helpers ────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
let wikiDir: string;
|
|
19
|
+
|
|
20
|
+
function createTmpDir(): string {
|
|
21
|
+
const dir = join(
|
|
22
|
+
import.meta.dir,
|
|
23
|
+
`tmp-search-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
24
|
+
);
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function seedWikiArticles(wiki: string): void {
|
|
30
|
+
const subdirs = [
|
|
31
|
+
"modules",
|
|
32
|
+
"entities",
|
|
33
|
+
"features",
|
|
34
|
+
"decisions",
|
|
35
|
+
"architecture",
|
|
36
|
+
"raw",
|
|
37
|
+
];
|
|
38
|
+
for (const subdir of subdirs) {
|
|
39
|
+
mkdirSync(join(wiki, subdir), { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
writeFileSync(
|
|
43
|
+
join(wiki, "modules", "core.md"),
|
|
44
|
+
[
|
|
45
|
+
"# Core Module",
|
|
46
|
+
"",
|
|
47
|
+
"The core module provides authentication and caching functionality.",
|
|
48
|
+
"It manages user sessions and token validation.",
|
|
49
|
+
"",
|
|
50
|
+
"## Entities",
|
|
51
|
+
"",
|
|
52
|
+
"- `authenticate` (function)",
|
|
53
|
+
"- `CacheManager` (class)",
|
|
54
|
+
"- `Config` (interface)",
|
|
55
|
+
].join("\n"),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(wiki, "modules", "verify.md"),
|
|
60
|
+
[
|
|
61
|
+
"# Verify Module",
|
|
62
|
+
"",
|
|
63
|
+
"The verify module runs the verification pipeline.",
|
|
64
|
+
"It includes syntax guard, slop detection, and AI review.",
|
|
65
|
+
"",
|
|
66
|
+
"## Entities",
|
|
67
|
+
"",
|
|
68
|
+
"- `runPipeline` (function)",
|
|
69
|
+
"- `syntaxGuard` (function)",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
writeFileSync(
|
|
74
|
+
join(wiki, "entities", "user.md"),
|
|
75
|
+
[
|
|
76
|
+
"# User",
|
|
77
|
+
"",
|
|
78
|
+
"**Kind:** interface",
|
|
79
|
+
"**File:** `packages/core/src/user.ts:5`",
|
|
80
|
+
"",
|
|
81
|
+
"Represents an authenticated user in the system.",
|
|
82
|
+
"Contains email, role, and session token fields.",
|
|
83
|
+
].join("\n"),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
writeFileSync(
|
|
87
|
+
join(wiki, "features", "login-flow.md"),
|
|
88
|
+
[
|
|
89
|
+
"# Feature: Login Flow",
|
|
90
|
+
"",
|
|
91
|
+
"Implements the authentication login flow with OAuth2.",
|
|
92
|
+
"Users can sign in via Google or GitHub providers.",
|
|
93
|
+
"",
|
|
94
|
+
"## Tasks",
|
|
95
|
+
"",
|
|
96
|
+
"- [x] OAuth2 provider setup",
|
|
97
|
+
"- [x] Session management",
|
|
98
|
+
"- [ ] Remember me functionality",
|
|
99
|
+
].join("\n"),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
writeFileSync(
|
|
103
|
+
join(wiki, "decisions", "use-bun.md"),
|
|
104
|
+
[
|
|
105
|
+
"# Decision: Use Bun Runtime",
|
|
106
|
+
"",
|
|
107
|
+
"> Status: **accepted**",
|
|
108
|
+
"",
|
|
109
|
+
"## Context",
|
|
110
|
+
"",
|
|
111
|
+
"We needed a fast JavaScript runtime for the CLI tool.",
|
|
112
|
+
"",
|
|
113
|
+
"## Decision",
|
|
114
|
+
"",
|
|
115
|
+
"Use Bun instead of Node.js for faster startup and built-in tools.",
|
|
116
|
+
"",
|
|
117
|
+
"## Rationale",
|
|
118
|
+
"",
|
|
119
|
+
"Bun provides built-in bundling, testing, and SQLite support.",
|
|
120
|
+
].join("\n"),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
writeFileSync(
|
|
124
|
+
join(wiki, "architecture", "three-engines.md"),
|
|
125
|
+
[
|
|
126
|
+
"# Architecture: Three Engines",
|
|
127
|
+
"",
|
|
128
|
+
"Maina uses three engines: Context, Prompt, and Verify.",
|
|
129
|
+
"Context observes, Prompt learns, Verify verifies.",
|
|
130
|
+
].join("\n"),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Setup / Teardown ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
tmpDir = createTmpDir();
|
|
138
|
+
wikiDir = join(tmpDir, "wiki");
|
|
139
|
+
mkdirSync(wikiDir, { recursive: true });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
try {
|
|
144
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore cleanup errors
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── buildSearchIndex ─────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe("buildSearchIndex", () => {
|
|
153
|
+
test("creates index from wiki articles", async () => {
|
|
154
|
+
seedWikiArticles(wikiDir);
|
|
155
|
+
const index = await buildSearchIndex(wikiDir);
|
|
156
|
+
|
|
157
|
+
expect(index.articleCount).toBe(6);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("handles empty wiki", async () => {
|
|
161
|
+
// wikiDir exists but has no subdirectories with articles
|
|
162
|
+
const index = await buildSearchIndex(wikiDir);
|
|
163
|
+
|
|
164
|
+
expect(index.articleCount).toBe(0);
|
|
165
|
+
const results = index.search("anything");
|
|
166
|
+
expect(results).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("handles missing wiki directory", async () => {
|
|
170
|
+
const index = await buildSearchIndex(join(tmpDir, "nonexistent"));
|
|
171
|
+
|
|
172
|
+
expect(index.articleCount).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── search (BM25 relevance) ─────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("search", () => {
|
|
179
|
+
test("returns results sorted by relevance (BM25)", async () => {
|
|
180
|
+
seedWikiArticles(wikiDir);
|
|
181
|
+
const index = await buildSearchIndex(wikiDir);
|
|
182
|
+
|
|
183
|
+
const results = index.search("authentication");
|
|
184
|
+
expect(results.length).toBeGreaterThan(0);
|
|
185
|
+
|
|
186
|
+
// Scores should be descending
|
|
187
|
+
for (let i = 1; i < results.length; i++) {
|
|
188
|
+
const prev = results[i - 1];
|
|
189
|
+
const curr = results[i];
|
|
190
|
+
expect(prev?.score ?? 0).toBeGreaterThanOrEqual(curr?.score ?? 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Core module and login flow should be top hits for "authentication"
|
|
194
|
+
const paths = results.map((r) => r.path);
|
|
195
|
+
expect(paths.some((p) => p.includes("core") || p.includes("login"))).toBe(
|
|
196
|
+
true,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("supports type filtering (only decisions)", async () => {
|
|
201
|
+
seedWikiArticles(wikiDir);
|
|
202
|
+
const index = await buildSearchIndex(wikiDir);
|
|
203
|
+
|
|
204
|
+
const results = index.search("bun runtime", { type: "decision" });
|
|
205
|
+
expect(results.length).toBeGreaterThan(0);
|
|
206
|
+
for (const result of results) {
|
|
207
|
+
expect(result.type).toBe("decision");
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("supports type filtering (only modules)", async () => {
|
|
212
|
+
seedWikiArticles(wikiDir);
|
|
213
|
+
const index = await buildSearchIndex(wikiDir);
|
|
214
|
+
|
|
215
|
+
const results = index.search("verification pipeline", { type: "module" });
|
|
216
|
+
for (const result of results) {
|
|
217
|
+
expect(result.type).toBe("module");
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("handles typos (fuzzy matching)", async () => {
|
|
222
|
+
seedWikiArticles(wikiDir);
|
|
223
|
+
const index = await buildSearchIndex(wikiDir);
|
|
224
|
+
|
|
225
|
+
// "autentication" is a typo for "authentication"
|
|
226
|
+
const results = index.search("autentication");
|
|
227
|
+
expect(results.length).toBeGreaterThan(0);
|
|
228
|
+
|
|
229
|
+
const paths = results.map((r) => r.path);
|
|
230
|
+
expect(paths.some((p) => p.includes("core") || p.includes("user"))).toBe(
|
|
231
|
+
true,
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("respects limit option", async () => {
|
|
236
|
+
seedWikiArticles(wikiDir);
|
|
237
|
+
const index = await buildSearchIndex(wikiDir);
|
|
238
|
+
|
|
239
|
+
const results = index.search("module", { limit: 2 });
|
|
240
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("returns excerpt for each result", async () => {
|
|
244
|
+
seedWikiArticles(wikiDir);
|
|
245
|
+
const index = await buildSearchIndex(wikiDir);
|
|
246
|
+
|
|
247
|
+
const results = index.search("authentication");
|
|
248
|
+
for (const result of results) {
|
|
249
|
+
expect(typeof result.excerpt).toBe("string");
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("returns title and path for each result", async () => {
|
|
254
|
+
seedWikiArticles(wikiDir);
|
|
255
|
+
const index = await buildSearchIndex(wikiDir);
|
|
256
|
+
|
|
257
|
+
const results = index.search("verify");
|
|
258
|
+
for (const result of results) {
|
|
259
|
+
expect(result.path).toBeTruthy();
|
|
260
|
+
expect(result.title).toBeTruthy();
|
|
261
|
+
expect(typeof result.score).toBe("number");
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── saveSearchIndex + loadSearchIndex round-trip ─────────────────────────
|
|
267
|
+
|
|
268
|
+
describe("saveSearchIndex + loadSearchIndex", () => {
|
|
269
|
+
test("round-trips correctly", async () => {
|
|
270
|
+
seedWikiArticles(wikiDir);
|
|
271
|
+
const index = await buildSearchIndex(wikiDir);
|
|
272
|
+
|
|
273
|
+
await saveSearchIndex(wikiDir, index);
|
|
274
|
+
const loaded = await loadSearchIndex(wikiDir);
|
|
275
|
+
|
|
276
|
+
expect(loaded).not.toBeNull();
|
|
277
|
+
expect(loaded?.articleCount).toBe(index.articleCount);
|
|
278
|
+
|
|
279
|
+
// Search should return the same results
|
|
280
|
+
const original = index.search("authentication");
|
|
281
|
+
const restored = loaded?.search("authentication") ?? [];
|
|
282
|
+
|
|
283
|
+
expect(restored.length).toBe(original.length);
|
|
284
|
+
expect(restored.map((r) => r.path)).toEqual(original.map((r) => r.path));
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("loadSearchIndex returns null when no index file exists", async () => {
|
|
288
|
+
const loaded = await loadSearchIndex(wikiDir);
|
|
289
|
+
expect(loaded).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("loadSearchIndex returns null on corrupted index", async () => {
|
|
293
|
+
writeFileSync(join(wikiDir, ".search-index.json"), "not valid json{{{");
|
|
294
|
+
const loaded = await loadSearchIndex(wikiDir);
|
|
295
|
+
expect(loaded).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ── searchWiki convenience function ──────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
describe("searchWiki", () => {
|
|
302
|
+
test("works with persisted index", async () => {
|
|
303
|
+
seedWikiArticles(wikiDir);
|
|
304
|
+
const index = await buildSearchIndex(wikiDir);
|
|
305
|
+
await saveSearchIndex(wikiDir, index);
|
|
306
|
+
|
|
307
|
+
const results = await searchWiki(wikiDir, "authentication");
|
|
308
|
+
expect(results.length).toBeGreaterThan(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("works without persisted index (builds in-memory)", async () => {
|
|
312
|
+
seedWikiArticles(wikiDir);
|
|
313
|
+
// No saveSearchIndex call — should fall back to building fresh
|
|
314
|
+
const results = await searchWiki(wikiDir, "verify");
|
|
315
|
+
expect(results.length).toBeGreaterThan(0);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("returns empty for missing wiki directory", async () => {
|
|
319
|
+
const results = await searchWiki(join(tmpDir, "nonexistent"), "anything");
|
|
320
|
+
expect(results).toEqual([]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("supports type filtering", async () => {
|
|
324
|
+
seedWikiArticles(wikiDir);
|
|
325
|
+
|
|
326
|
+
const results = await searchWiki(wikiDir, "bun", { type: "decision" });
|
|
327
|
+
for (const result of results) {
|
|
328
|
+
expect(result.type).toBe("decision");
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("supports limit option", async () => {
|
|
333
|
+
seedWikiArticles(wikiDir);
|
|
334
|
+
|
|
335
|
+
const results = await searchWiki(wikiDir, "module", { limit: 1 });
|
|
336
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ── Performance ──────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
describe("performance", () => {
|
|
343
|
+
test("indexing 400 articles < 500ms", async () => {
|
|
344
|
+
// Create 400 articles across subdirectories
|
|
345
|
+
const subdirs = ["modules", "entities", "features", "decisions"];
|
|
346
|
+
for (const subdir of subdirs) {
|
|
347
|
+
mkdirSync(join(wikiDir, subdir), { recursive: true });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < 400; i++) {
|
|
351
|
+
const subdir = subdirs[i % subdirs.length] ?? "modules";
|
|
352
|
+
const content = [
|
|
353
|
+
`# Article ${i}`,
|
|
354
|
+
"",
|
|
355
|
+
`This is article number ${i} about topic ${i % 20}.`,
|
|
356
|
+
`It discusses various aspects of software development.`,
|
|
357
|
+
`Keywords: authentication, caching, verification, pipeline, module.`,
|
|
358
|
+
`More content to make the article realistic with enough text.`,
|
|
359
|
+
`The ${subdir} subsystem handles ${i % 10 === 0 ? "authentication" : "processing"}.`,
|
|
360
|
+
].join("\n");
|
|
361
|
+
writeFileSync(join(wikiDir, subdir, `article-${i}.md`), content);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const start = performance.now();
|
|
365
|
+
const index = await buildSearchIndex(wikiDir);
|
|
366
|
+
const elapsed = performance.now() - start;
|
|
367
|
+
|
|
368
|
+
expect(index.articleCount).toBe(400);
|
|
369
|
+
expect(elapsed).toBeLessThan(500);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("search < 10ms", async () => {
|
|
373
|
+
// Reuse a seeded wiki
|
|
374
|
+
seedWikiArticles(wikiDir);
|
|
375
|
+
const index = await buildSearchIndex(wikiDir);
|
|
376
|
+
|
|
377
|
+
const start = performance.now();
|
|
378
|
+
const results = index.search("authentication caching");
|
|
379
|
+
const elapsed = performance.now() - start;
|
|
380
|
+
|
|
381
|
+
expect(results.length).toBeGreaterThan(0);
|
|
382
|
+
expect(elapsed).toBeLessThan(10);
|
|
383
|
+
});
|
|
384
|
+
});
|
package/src/wiki/compiler.ts
CHANGED
|
@@ -1032,6 +1032,17 @@ export async function compile(
|
|
|
1032
1032
|
}
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
+
// ── Step 9b: Build and save search index ───────────────────────
|
|
1036
|
+
if (!dryRun) {
|
|
1037
|
+
try {
|
|
1038
|
+
const { buildSearchIndex, saveSearchIndex } = await import("./search");
|
|
1039
|
+
const searchIndex = await buildSearchIndex(wikiDir);
|
|
1040
|
+
await saveSearchIndex(wikiDir, searchIndex);
|
|
1041
|
+
} catch {
|
|
1042
|
+
// Search index is optional — continue if Orama is unavailable
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1035
1046
|
// ── Step 10: Save state ────────────────────────────────────────
|
|
1036
1047
|
const state = loadState(wikiDir) ?? createEmptyState();
|
|
1037
1048
|
state.lastFullCompile = new Date().toISOString();
|