@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,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiki Search — Orama-powered full-text search over wiki articles.
|
|
3
|
+
*
|
|
4
|
+
* Provides BM25 scoring, fuzzy matching, and typo tolerance via @orama/orama.
|
|
5
|
+
* Falls back gracefully when Orama is unavailable or the index is missing.
|
|
6
|
+
*
|
|
7
|
+
* Index is persisted to `.search-index.json` for fast startup and rebuilt
|
|
8
|
+
* during `wiki init` and `wiki compile`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
// ─── Types ───────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface WikiSearchResult {
|
|
17
|
+
path: string;
|
|
18
|
+
title: string;
|
|
19
|
+
type: string;
|
|
20
|
+
score: number;
|
|
21
|
+
excerpt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WikiSearchIndex {
|
|
25
|
+
search(
|
|
26
|
+
query: string,
|
|
27
|
+
options?: { limit?: number; type?: string },
|
|
28
|
+
): WikiSearchResult[];
|
|
29
|
+
articleCount: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Orama document schema for wiki articles. */
|
|
33
|
+
interface WikiDoc {
|
|
34
|
+
id: string;
|
|
35
|
+
path: string;
|
|
36
|
+
title: string;
|
|
37
|
+
type: string;
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Constants ───────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const INDEX_FILENAME = ".search-index.json";
|
|
44
|
+
|
|
45
|
+
const ARTICLE_SUBDIRS = [
|
|
46
|
+
"modules",
|
|
47
|
+
"entities",
|
|
48
|
+
"features",
|
|
49
|
+
"decisions",
|
|
50
|
+
"architecture",
|
|
51
|
+
"raw",
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
const ORAMA_SCHEMA = {
|
|
55
|
+
path: "string" as const,
|
|
56
|
+
title: "string" as const,
|
|
57
|
+
type: "enum" as const,
|
|
58
|
+
content: "string" as const,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extract the first heading from markdown content.
|
|
65
|
+
*/
|
|
66
|
+
function extractTitle(content: string): string {
|
|
67
|
+
const firstLine = content.split("\n")[0] ?? "";
|
|
68
|
+
return firstLine.replace(/^#+\s*/, "").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Determine article type from its subdirectory name.
|
|
73
|
+
*/
|
|
74
|
+
function typeFromSubdir(subdir: string): string {
|
|
75
|
+
// Strip trailing 's' for consistency: "modules" → "module"
|
|
76
|
+
if (subdir.endsWith("s") && subdir !== "raw") {
|
|
77
|
+
return subdir.slice(0, -1);
|
|
78
|
+
}
|
|
79
|
+
return subdir;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract the most relevant excerpt around matching terms.
|
|
84
|
+
*/
|
|
85
|
+
function extractExcerpt(
|
|
86
|
+
content: string,
|
|
87
|
+
query: string,
|
|
88
|
+
maxLength = 200,
|
|
89
|
+
): string {
|
|
90
|
+
const queryWords = query
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.split(/\s+/)
|
|
93
|
+
.filter((w) => w.length > 2);
|
|
94
|
+
|
|
95
|
+
const paragraphs = content.split(/\n\n+/).filter((p) => p.trim().length > 0);
|
|
96
|
+
|
|
97
|
+
let bestParagraph = "";
|
|
98
|
+
let bestScore = -1;
|
|
99
|
+
|
|
100
|
+
for (const paragraph of paragraphs) {
|
|
101
|
+
if (paragraph.trim().startsWith("#") && !paragraph.includes("\n")) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lower = paragraph.toLowerCase();
|
|
106
|
+
let matchCount = 0;
|
|
107
|
+
for (const word of queryWords) {
|
|
108
|
+
if (lower.includes(word)) {
|
|
109
|
+
matchCount++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (matchCount > bestScore) {
|
|
114
|
+
bestScore = matchCount;
|
|
115
|
+
bestParagraph = paragraph;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cleaned = bestParagraph.replace(/\n/g, " ").trim();
|
|
120
|
+
if (cleaned.length > maxLength) {
|
|
121
|
+
return `${cleaned.slice(0, maxLength)}...`;
|
|
122
|
+
}
|
|
123
|
+
return cleaned;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Walk all .md files in wiki subdirectories and return article metadata.
|
|
128
|
+
*/
|
|
129
|
+
function walkArticles(
|
|
130
|
+
wikiDir: string,
|
|
131
|
+
): Array<{ path: string; title: string; type: string; content: string }> {
|
|
132
|
+
const articles: Array<{
|
|
133
|
+
path: string;
|
|
134
|
+
title: string;
|
|
135
|
+
type: string;
|
|
136
|
+
content: string;
|
|
137
|
+
}> = [];
|
|
138
|
+
|
|
139
|
+
for (const subdir of ARTICLE_SUBDIRS) {
|
|
140
|
+
const dir = join(wikiDir, subdir);
|
|
141
|
+
if (!existsSync(dir)) continue;
|
|
142
|
+
|
|
143
|
+
let entries: string[];
|
|
144
|
+
try {
|
|
145
|
+
entries = readdirSync(dir);
|
|
146
|
+
} catch {
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
if (!entry.endsWith(".md")) continue;
|
|
152
|
+
const fullPath = join(dir, entry);
|
|
153
|
+
try {
|
|
154
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
155
|
+
const title = extractTitle(content);
|
|
156
|
+
const type = typeFromSubdir(subdir);
|
|
157
|
+
articles.push({
|
|
158
|
+
path: `${subdir}/${entry}`,
|
|
159
|
+
title,
|
|
160
|
+
type,
|
|
161
|
+
content,
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
// skip unreadable files
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return articles;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Core Functions ─────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a search index from all wiki articles on disk.
|
|
176
|
+
* Called during wiki compile and wiki init.
|
|
177
|
+
*/
|
|
178
|
+
export async function buildSearchIndex(
|
|
179
|
+
wikiDir: string,
|
|
180
|
+
): Promise<WikiSearchIndex> {
|
|
181
|
+
const { create, insert, search: oramaSearch } = await import("@orama/orama");
|
|
182
|
+
|
|
183
|
+
const db = create({ schema: ORAMA_SCHEMA });
|
|
184
|
+
const articles = walkArticles(wikiDir);
|
|
185
|
+
|
|
186
|
+
for (const article of articles) {
|
|
187
|
+
insert(db, {
|
|
188
|
+
path: article.path,
|
|
189
|
+
title: article.title,
|
|
190
|
+
type: article.type,
|
|
191
|
+
content: article.content,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
articleCount: articles.length,
|
|
197
|
+
search(
|
|
198
|
+
query: string,
|
|
199
|
+
options?: { limit?: number; type?: string },
|
|
200
|
+
): WikiSearchResult[] {
|
|
201
|
+
const limit = options?.limit ?? 10;
|
|
202
|
+
const searchParams: Record<string, unknown> = {
|
|
203
|
+
term: query,
|
|
204
|
+
properties: ["title", "content"],
|
|
205
|
+
boost: { title: 3 },
|
|
206
|
+
tolerance: 1,
|
|
207
|
+
limit,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (options?.type) {
|
|
211
|
+
searchParams.where = { type: { eq: options.type } };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Orama search is synchronous in Bun but types include Promise union
|
|
215
|
+
const results = oramaSearch(db, searchParams as never) as {
|
|
216
|
+
hits: Array<{ score: number; document: WikiDoc }>;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return results.hits.map((hit) => ({
|
|
220
|
+
path: hit.document.path,
|
|
221
|
+
title: hit.document.title,
|
|
222
|
+
type: hit.document.type,
|
|
223
|
+
score: hit.score,
|
|
224
|
+
excerpt: extractExcerpt(hit.document.content, query),
|
|
225
|
+
}));
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Save the search index to disk for fast startup.
|
|
232
|
+
*/
|
|
233
|
+
export async function saveSearchIndex(
|
|
234
|
+
wikiDir: string,
|
|
235
|
+
index: WikiSearchIndex,
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
// We need the underlying Orama DB to save — rebuild it
|
|
238
|
+
// The save format is the Orama RawData JSON-serializable object
|
|
239
|
+
const { create, insert, save } = await import("@orama/orama");
|
|
240
|
+
|
|
241
|
+
const db = create({ schema: ORAMA_SCHEMA });
|
|
242
|
+
const articles = walkArticles(wikiDir);
|
|
243
|
+
|
|
244
|
+
for (const article of articles) {
|
|
245
|
+
insert(db, {
|
|
246
|
+
path: article.path,
|
|
247
|
+
title: article.title,
|
|
248
|
+
type: article.type,
|
|
249
|
+
content: article.content,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const raw = save(db);
|
|
254
|
+
const indexPath = join(wikiDir, INDEX_FILENAME);
|
|
255
|
+
writeFileSync(
|
|
256
|
+
indexPath,
|
|
257
|
+
JSON.stringify({ articleCount: index.articleCount, raw }),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Load a previously saved search index.
|
|
263
|
+
* Returns null if no saved index exists.
|
|
264
|
+
*/
|
|
265
|
+
export async function loadSearchIndex(
|
|
266
|
+
wikiDir: string,
|
|
267
|
+
): Promise<WikiSearchIndex | null> {
|
|
268
|
+
const indexPath = join(wikiDir, INDEX_FILENAME);
|
|
269
|
+
if (!existsSync(indexPath)) return null;
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const { create, load, search: oramaSearch } = await import("@orama/orama");
|
|
273
|
+
const data = JSON.parse(readFileSync(indexPath, "utf-8")) as {
|
|
274
|
+
articleCount: number;
|
|
275
|
+
raw: unknown;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const db = create({ schema: ORAMA_SCHEMA });
|
|
279
|
+
load(db, data.raw as never);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
articleCount: data.articleCount,
|
|
283
|
+
search(
|
|
284
|
+
query: string,
|
|
285
|
+
options?: { limit?: number; type?: string },
|
|
286
|
+
): WikiSearchResult[] {
|
|
287
|
+
const limit = options?.limit ?? 10;
|
|
288
|
+
const searchParams: Record<string, unknown> = {
|
|
289
|
+
term: query,
|
|
290
|
+
properties: ["title", "content"],
|
|
291
|
+
boost: { title: 3 },
|
|
292
|
+
tolerance: 1,
|
|
293
|
+
limit,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (options?.type) {
|
|
297
|
+
searchParams.where = { type: { eq: options.type } };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Orama search is synchronous in Bun but types include Promise union
|
|
301
|
+
const results = oramaSearch(db, searchParams as never) as {
|
|
302
|
+
hits: Array<{ score: number; document: WikiDoc }>;
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return results.hits.map((hit) => ({
|
|
306
|
+
path: hit.document.path,
|
|
307
|
+
title: hit.document.title,
|
|
308
|
+
type: hit.document.type,
|
|
309
|
+
score: hit.score,
|
|
310
|
+
excerpt: extractExcerpt(hit.document.content, query),
|
|
311
|
+
}));
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Universal search function for wiki articles.
|
|
321
|
+
*
|
|
322
|
+
* Tries the persisted Orama index first, then falls back to building
|
|
323
|
+
* a fresh in-memory index. Both query.ts and consult.ts can use this.
|
|
324
|
+
*/
|
|
325
|
+
export async function searchWiki(
|
|
326
|
+
wikiDir: string,
|
|
327
|
+
query: string,
|
|
328
|
+
options?: { limit?: number; type?: string },
|
|
329
|
+
): Promise<WikiSearchResult[]> {
|
|
330
|
+
if (!existsSync(wikiDir)) return [];
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
// Try loading persisted index
|
|
334
|
+
const index = await loadSearchIndex(wikiDir);
|
|
335
|
+
if (index) {
|
|
336
|
+
return index.search(query, options);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Fall back to building in-memory
|
|
340
|
+
const freshIndex = await buildSearchIndex(wikiDir);
|
|
341
|
+
return freshIndex.search(query, options);
|
|
342
|
+
} catch {
|
|
343
|
+
// Orama completely unavailable — return empty
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}
|