pi-jev-wiki 0.2.0

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,164 @@
1
+ /**
2
+ * Minimal YAML frontmatter support for the subset jev-wiki writes:
3
+ * flat scalars, inline arrays of scalars, and lists of flat maps (with inline arrays).
4
+ */
5
+
6
+ export interface Frontmatter {
7
+ data: Record<string, unknown>;
8
+ body: string;
9
+ }
10
+
11
+ const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/;
12
+
13
+ function parseScalar(raw: string): unknown {
14
+ const value = raw.trim();
15
+ if (value === "") return "";
16
+ if (value === "null" || value === "~") return null;
17
+ if (value === "true") return true;
18
+ if (value === "false") return false;
19
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
20
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
21
+ try {
22
+ return JSON.parse(value);
23
+ } catch {
24
+ return value.slice(1, -1);
25
+ }
26
+ }
27
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
28
+ return value.slice(1, -1).replace(/''/g, "'");
29
+ }
30
+ if (value.startsWith("[") && value.endsWith("]")) {
31
+ const inner = value.slice(1, -1).trim();
32
+ if (!inner) return [];
33
+ return splitInline(inner).map(parseScalar);
34
+ }
35
+ return value;
36
+ }
37
+
38
+ function splitInline(input: string): string[] {
39
+ const parts: string[] = [];
40
+ let current = "";
41
+ let quote: string | null = null;
42
+ for (const char of input) {
43
+ if (quote) {
44
+ current += char;
45
+ if (char === quote) quote = null;
46
+ continue;
47
+ }
48
+ if (char === '"' || char === "'") {
49
+ quote = char;
50
+ current += char;
51
+ continue;
52
+ }
53
+ if (char === ",") {
54
+ parts.push(current);
55
+ current = "";
56
+ continue;
57
+ }
58
+ current += char;
59
+ }
60
+ if (current.trim()) parts.push(current);
61
+ return parts.map((part) => part.trim());
62
+ }
63
+
64
+ export function parseFrontmatter(text: string): Frontmatter {
65
+ const normalized = text.replace(/^\uFEFF/, "");
66
+ if (!normalized.startsWith("---")) return { data: {}, body: normalized };
67
+ const end = normalized.indexOf("\n---", 3);
68
+ if (end === -1) return { data: {}, body: normalized };
69
+ const block = normalized.slice(4, end).replace(/\r/g, "");
70
+ const body = normalized.slice(end + 4).replace(/^\r?\n/, "");
71
+ return { data: parseYamlBlock(block), body };
72
+ }
73
+
74
+ export function parseYamlBlock(block: string): Record<string, unknown> {
75
+ const root: Record<string, unknown> = {};
76
+ let currentListKey: string | null = null;
77
+ let currentItem: Record<string, unknown> | null = null;
78
+
79
+ for (const rawLine of block.split("\n")) {
80
+ if (!rawLine.trim() || rawLine.trim().startsWith("#")) continue;
81
+ const indent = rawLine.match(/^ */)?.[0].length ?? 0;
82
+ const line = rawLine.trim();
83
+
84
+ if (indent === 0) {
85
+ currentListKey = null;
86
+ currentItem = null;
87
+ const match = line.match(KEY_RE);
88
+ if (!match) continue;
89
+ const [, key, rest] = match;
90
+ if (rest.trim() === "") {
91
+ root[key] = [];
92
+ currentListKey = key;
93
+ } else {
94
+ root[key] = parseScalar(rest);
95
+ }
96
+ continue;
97
+ }
98
+
99
+ if (line.startsWith("- ")) {
100
+ if (!currentListKey) continue;
101
+ const rest = line.slice(2).trim();
102
+ const list = root[currentListKey] as unknown[];
103
+ const match = rest.match(KEY_RE);
104
+ if (match) {
105
+ currentItem = { [match[1]]: parseScalar(match[2]) };
106
+ list.push(currentItem);
107
+ } else {
108
+ currentItem = null;
109
+ list.push(parseScalar(rest));
110
+ }
111
+ continue;
112
+ }
113
+
114
+ const match = line.match(KEY_RE);
115
+ if (match && currentItem) currentItem[match[1]] = parseScalar(match[2]);
116
+ }
117
+ return root;
118
+ }
119
+
120
+ function formatScalar(value: unknown): string {
121
+ if (value === null) return "null";
122
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
123
+ const text = String(value);
124
+ if (text === "") return '""';
125
+ if (/^[\s]|[\s]$|[:#\[\]{},"'|>&*!%@`]|^-|^[?]/.test(text) || text.includes("\n")) {
126
+ return JSON.stringify(text);
127
+ }
128
+ if (/^(true|false|null|~)$/i.test(text) || /^-?\d+(\.\d+)?$/.test(text)) return JSON.stringify(text);
129
+ return text;
130
+ }
131
+
132
+ function formatInlineArray(values: unknown[]): string {
133
+ return `[${values.map((value) => (Array.isArray(value) ? formatInlineArray(value) : formatScalar(value))).join(", ")}]`;
134
+ }
135
+
136
+ export function serializeFrontmatter(data: Record<string, unknown>, body: string): string {
137
+ const lines = ["---"];
138
+ for (const [key, value] of Object.entries(data)) {
139
+ if (value === undefined) continue;
140
+ if (Array.isArray(value)) {
141
+ if (value.length === 0) {
142
+ lines.push(`${key}: []`);
143
+ continue;
144
+ }
145
+ if (value.every((item) => item === null || typeof item !== "object" || Array.isArray(item))) {
146
+ lines.push(`${key}: ${formatInlineArray(value)}`);
147
+ continue;
148
+ }
149
+ lines.push(`${key}:`);
150
+ for (const item of value) {
151
+ const entries = Object.entries(item as Record<string, unknown>).filter(([, v]) => v !== undefined);
152
+ entries.forEach(([childKey, childValue], index) => {
153
+ const prefix = index === 0 ? " - " : " ";
154
+ const rendered = Array.isArray(childValue) ? formatInlineArray(childValue) : formatScalar(childValue);
155
+ lines.push(`${prefix}${childKey}: ${rendered}`);
156
+ });
157
+ }
158
+ continue;
159
+ }
160
+ lines.push(`${key}: ${formatScalar(value)}`);
161
+ }
162
+ lines.push("---", "", body.replace(/\s+$/, ""), "");
163
+ return lines.join("\n");
164
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Wiki filesystem layout: paths, atomic writes, hashing, and page I/O.
3
+ */
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync } from "node:fs";
6
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
7
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
9
+ import { parseFrontmatter, serializeFrontmatter, type Frontmatter } from "./frontmatter.ts";
10
+
11
+ export interface WikiLayout {
12
+ root: string;
13
+ rawDir: string;
14
+ wikiDir: string;
15
+ stateDir: string;
16
+ ledgerPath: string;
17
+ reviewQueuePath: string;
18
+ sessionLogPath: string;
19
+ }
20
+
21
+ export function resolveLayout(cwd: string, wikiRoot: string, stateRoot = ".jev-wiki"): WikiLayout {
22
+ const root = resolve(cwd, wikiRoot);
23
+ const stateDir = isAbsolute(stateRoot) ? stateRoot : join(root, stateRoot);
24
+ return {
25
+ root,
26
+ rawDir: join(root, "raw"),
27
+ wikiDir: join(root, "wiki"),
28
+ stateDir,
29
+ ledgerPath: join(stateDir, "decisions.jsonl"),
30
+ reviewQueuePath: join(stateDir, "review-queue.jsonl"),
31
+ sessionLogPath: join(stateDir, "session-log.jsonl"),
32
+ };
33
+ }
34
+
35
+ export async function ensureLayout(layout: WikiLayout): Promise<void> {
36
+ for (const dir of [layout.rawDir, layout.wikiDir, layout.stateDir]) {
37
+ await mkdir(dir, { recursive: true });
38
+ }
39
+ }
40
+
41
+ export function slugify(input: string, maxLength = 60): string {
42
+ const slug = input
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, maxLength)
47
+ .replace(/-+$/g, "");
48
+ return slug || "untitled";
49
+ }
50
+
51
+ export function todayISO(date = new Date()): string {
52
+ return date.toISOString().slice(0, 10);
53
+ }
54
+
55
+ export function timeStamp(date = new Date()): string {
56
+ return date.toISOString().slice(11, 16).replace(":", "");
57
+ }
58
+
59
+ export async function sha256Hex(text: string): Promise<string> {
60
+ return createHash("sha256").update(text, "utf8").digest("hex");
61
+ }
62
+
63
+ export async function writeTextAtomic(absPath: string, text: string): Promise<void> {
64
+ await withFileMutationQueue(absPath, async () => {
65
+ await mkdir(dirname(absPath), { recursive: true });
66
+ const temp = `${absPath}.tmp-${process.pid}-${Date.now()}`;
67
+ await writeFile(temp, text, "utf8");
68
+ await rename(temp, absPath);
69
+ });
70
+ }
71
+
72
+ export async function readPage(absPath: string): Promise<Frontmatter & { path: string }> {
73
+ const text = await readFile(absPath, "utf8");
74
+ const { data, body } = parseFrontmatter(text);
75
+ return { data, body, path: absPath };
76
+ }
77
+
78
+ export async function writePage(absPath: string, data: Record<string, unknown>, body: string): Promise<void> {
79
+ await writeTextAtomic(absPath, serializeFrontmatter(data, body));
80
+ }
81
+
82
+ export async function writeRawSource(
83
+ layout: WikiLayout,
84
+ topic: string,
85
+ slug: string,
86
+ data: Record<string, unknown>,
87
+ body: string,
88
+ ): Promise<string> {
89
+ const dir = join(layout.rawDir, slugify(topic, 40));
90
+ const filename = `${todayISO()}-${slugify(slug)}.md`;
91
+ const path = join(dir, filename);
92
+ await writePage(path, data, body);
93
+ return path;
94
+ }
95
+
96
+ export function relativeTo(fromDir: string, to: string): string {
97
+ const rel = relative(fromDir, to).split("\\").join("/");
98
+ return rel.startsWith(".") ? rel : `./${rel}`;
99
+ }
100
+
101
+ export function toPosix(path: string): string {
102
+ return path.split("\\").join("/");
103
+ }
104
+
105
+ export async function listMarkdownFiles(dir: string): Promise<string[]> {
106
+ if (!existsSync(dir)) return [];
107
+ const out: string[] = [];
108
+ async function walk(current: string): Promise<void> {
109
+ const entries = await readdir(current, { withFileTypes: true });
110
+ for (const entry of entries) {
111
+ const full = join(current, entry.name);
112
+ if (entry.isDirectory()) {
113
+ if (entry.name === ".jev-wiki" || entry.name === "raw" || entry.name === "toc") continue;
114
+ await walk(full);
115
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
116
+ out.push(full);
117
+ }
118
+ }
119
+ }
120
+ await walk(dir);
121
+ return out.sort();
122
+ }
123
+
124
+ export async function removeFileIfExists(path: string): Promise<void> {
125
+ if (existsSync(path)) await rm(path, { force: true });
126
+ }
@@ -0,0 +1,17 @@
1
+ /** Markdown link helpers shared by the extension and lint. */
2
+
3
+ export function extractMarkdownLinks(markdown: string): string[] {
4
+ const links: string[] = [];
5
+ for (const match of markdown.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
6
+ links.push(match[1].trim());
7
+ }
8
+ return links;
9
+ }
10
+
11
+ export function isExternalLink(link: string): boolean {
12
+ return /^[a-z][a-z0-9+.-]*:/i.test(link) || link.startsWith("#");
13
+ }
14
+
15
+ export function linkTarget(link: string): string {
16
+ return link.split("#")[0].trim();
17
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Cross-process wiki lock.
3
+ *
4
+ * `withFileMutationQueue` only serializes within one pi process; two sessions in
5
+ * the same project can otherwise interleave read-modify-write cycles on the TOC,
6
+ * log, review queue, and raw index. This is a simple exclusive-create lock file
7
+ * with stale takeover. Locks are held only around short mutations — never across
8
+ * model or Jev calls — and are not reentrant.
9
+ */
10
+ import { mkdir, open, readFile, rm } from "node:fs/promises";
11
+ import { dirname, join } from "node:path";
12
+ import type { WikiLayout } from "./layout.ts";
13
+
14
+ export interface LockOptions {
15
+ timeoutMs?: number;
16
+ staleMs?: number;
17
+ }
18
+
19
+ export class WikiLockError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = "WikiLockError";
23
+ }
24
+ }
25
+
26
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
27
+
28
+ export function lockPath(layout: WikiLayout): string {
29
+ return join(layout.stateDir, "lock");
30
+ }
31
+
32
+ export async function isLocked(layout: WikiLayout, staleMs = 30_000): Promise<{ locked: boolean; stale: boolean; ageMs?: number }> {
33
+ try {
34
+ const raw = await readFile(lockPath(layout), "utf8");
35
+ const info = JSON.parse(raw) as { ts?: number };
36
+ const ageMs = info.ts ? Date.now() - info.ts : undefined;
37
+ return { locked: true, stale: ageMs === undefined || ageMs > staleMs, ageMs };
38
+ } catch {
39
+ return { locked: false, stale: false };
40
+ }
41
+ }
42
+
43
+ /** Run `fn` while holding the wiki lock. Not reentrant. */
44
+ export async function withWikiLock<T>(layout: WikiLayout, fn: () => Promise<T>, options?: LockOptions): Promise<T> {
45
+ const path = lockPath(layout);
46
+ const timeoutMs = options?.timeoutMs ?? 15_000;
47
+ const staleMs = options?.staleMs ?? 30_000;
48
+ const started = Date.now();
49
+ await mkdir(dirname(path), { recursive: true });
50
+
51
+ let handle: Awaited<ReturnType<typeof open>>;
52
+ for (;;) {
53
+ try {
54
+ handle = await open(path, "wx");
55
+ await handle.writeFile(JSON.stringify({ pid: process.pid, ts: Date.now(), host: process.env.COMPUTERNAME ?? process.env.HOSTNAME ?? "" }));
56
+ break;
57
+ } catch (error) {
58
+ if ((error as { code?: string }).code !== "EEXIST") throw error;
59
+ // Existing lock: take it over if stale, otherwise wait.
60
+ try {
61
+ const raw = await readFile(path, "utf8");
62
+ const info = JSON.parse(raw) as { ts?: number };
63
+ if (!info.ts || Date.now() - info.ts > staleMs) {
64
+ await rm(path, { force: true });
65
+ continue;
66
+ }
67
+ } catch {
68
+ await rm(path, { force: true }).catch(() => undefined);
69
+ continue;
70
+ }
71
+ if (Date.now() - started > timeoutMs) {
72
+ throw new WikiLockError(
73
+ `Wiki is locked by another pi session (${path}). Retry in a moment, or delete the lock file if it is stale.`,
74
+ );
75
+ }
76
+ await sleep(100 + Math.floor(Math.random() * 150));
77
+ }
78
+ }
79
+
80
+ try {
81
+ return await fn();
82
+ } finally {
83
+ await handle.close().catch(() => undefined);
84
+ await rm(path, { force: true }).catch(() => undefined);
85
+ }
86
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Wiki search: pluggable engines behind one interface.
3
+ * index — table-of-contents matching plus naive token scan (small wikis)
4
+ * bm25 — in-process BM25 over page content (up to a few thousand pages)
5
+ * qmd — optional adapter to the qmd CLI (hybrid BM25 + vectors + rerank)
6
+ */
7
+ import { existsSync } from "node:fs";
8
+ import { readFile, stat } from "node:fs/promises";
9
+ import { relative } from "node:path";
10
+ import { execFile } from "node:child_process";
11
+ import { promisify } from "node:util";
12
+ import type { ResolvedConfig } from "../config.ts";
13
+ import { listMarkdownFiles, type WikiLayout } from "./layout.ts";
14
+ import { readIndex, isWikiMetaFile, type TocEntry } from "./toc.ts";
15
+
16
+ const run = promisify(execFile);
17
+
18
+ export interface SearchResult {
19
+ path: string;
20
+ title: string;
21
+ score: number;
22
+ excerpt: string;
23
+ source?: "project" | "global";
24
+ }
25
+
26
+ export interface SearchOptions {
27
+ limit?: number;
28
+ query: string;
29
+ }
30
+
31
+ export interface SearchEngine {
32
+ name: string;
33
+ search(options: SearchOptions): Promise<SearchResult[]>;
34
+ }
35
+
36
+ const STOPWORDS = new Set([
37
+ "the", "and", "for", "with", "that", "this", "from", "into", "when", "what", "where", "which", "does", "how", "why",
38
+ "are", "was", "were", "has", "have", "had", "not", "but", "can", "could", "should", "would", "about", "over", "under",
39
+ ]);
40
+
41
+ export function tokenize(text: string): string[] {
42
+ return text
43
+ .toLowerCase()
44
+ .split(/[^a-z0-9_]+/)
45
+ .filter((token) => token.length > 2 && !STOPWORDS.has(token));
46
+ }
47
+
48
+ interface Doc {
49
+ path: string;
50
+ title: string;
51
+ tags: string[];
52
+ summary: string;
53
+ text: string;
54
+ length: number;
55
+ }
56
+
57
+ async function loadDocs(layout: WikiLayout): Promise<Doc[]> {
58
+ const files = await listMarkdownFiles(layout.wikiDir);
59
+ const entries = await readIndex(layout);
60
+ const byPath = new Map<string, TocEntry>(entries.map((entry) => [entry.path, entry]));
61
+ const docs: Doc[] = [];
62
+ for (const file of files) {
63
+ const rel = relative(layout.wikiDir, file).split("\\").join("/");
64
+ if (isWikiMetaFile(rel)) continue;
65
+ try {
66
+ const raw = await readFile(file, "utf8");
67
+ const entry = byPath.get(rel);
68
+ docs.push({
69
+ path: rel,
70
+ title: entry?.title ?? rel,
71
+ tags: entry?.tags ?? [],
72
+ summary: entry?.summary ?? "",
73
+ text: raw,
74
+ length: tokenize(raw).length || 1,
75
+ });
76
+ } catch {
77
+ /* skip unreadable */
78
+ }
79
+ }
80
+ return docs;
81
+ }
82
+
83
+ function excerptFor(doc: Doc, queryTokens: Set<string>): string {
84
+ const lines = doc.text.split(/\r?\n/);
85
+ const hits = lines
86
+ .filter((line) => {
87
+ const lower = line.toLowerCase();
88
+ return [...queryTokens].some((token) => lower.includes(token));
89
+ })
90
+ .slice(0, 3);
91
+ return (hits.length > 0 ? hits : lines.filter((line) => line.trim()).slice(0, 2))
92
+ .map((line) => line.trim().slice(0, 240))
93
+ .join("\n");
94
+ }
95
+
96
+ async function directoryFingerprint(layout: WikiLayout): Promise<string> {
97
+ const files = await listMarkdownFiles(layout.wikiDir);
98
+ let newest = 0;
99
+ for (const file of files) {
100
+ const info = await stat(file).catch(() => undefined);
101
+ if (info && info.mtimeMs > newest) newest = info.mtimeMs;
102
+ }
103
+ return `${files.length}:${Math.round(newest)}`;
104
+ }
105
+
106
+ /** BM25 with field boosts: title x3, summary/tags x2, body x1. */
107
+ class Bm25Index {
108
+ private docs: Doc[] = [];
109
+ private df = new Map<string, number>();
110
+ private avgLength = 1;
111
+ private built = 0;
112
+ private fingerprint = "";
113
+ private checkedAt = 0;
114
+
115
+ async ensure(layout: WikiLayout): Promise<void> {
116
+ const now = Date.now();
117
+ if (this.built > 0 && now - this.checkedAt < 2000) return;
118
+ const fingerprint = await directoryFingerprint(layout);
119
+ this.checkedAt = now;
120
+ if (this.built > 0 && fingerprint === this.fingerprint) return;
121
+ this.docs = await loadDocs(layout);
122
+ this.df = new Map();
123
+ let total = 0;
124
+ for (const doc of this.docs) {
125
+ const unique = new Set(tokenize(doc.text));
126
+ for (const token of unique) this.df.set(token, (this.df.get(token) ?? 0) + 1);
127
+ total += doc.length;
128
+ }
129
+ this.avgLength = this.docs.length > 0 ? total / this.docs.length : 1;
130
+ this.fingerprint = fingerprint;
131
+ this.built = Date.now();
132
+ }
133
+
134
+ search(query: string, limit: number): SearchResult[] {
135
+ const tokens = tokenize(query);
136
+ if (tokens.length === 0) return [];
137
+ const querySet = new Set(tokens);
138
+ const k1 = 1.2;
139
+ const b = 0.75;
140
+ const scored = this.docs.map((doc) => {
141
+ const bodyTokens = tokenize(doc.text);
142
+ const counts = new Map<string, number>();
143
+ for (const token of bodyTokens) counts.set(token, (counts.get(token) ?? 0) + 1);
144
+ const titleTokens = new Set(tokenize(doc.title));
145
+ const metaTokens = new Set([...tokenize(doc.summary), ...tokenize(doc.tags.join(" "))]);
146
+ let score = 0;
147
+ for (const token of querySet) {
148
+ const df = this.df.get(token) ?? 0;
149
+ if (df === 0) continue;
150
+ const idf = Math.log(1 + (this.docs.length - df + 0.5) / (df + 0.5));
151
+ const tf = counts.get(token) ?? 0;
152
+ const bodyScore = (tf * (k1 + 1)) / (tf + k1 * (1 - b + (b * doc.length) / this.avgLength));
153
+ const boost = titleTokens.has(token) ? 3 : metaTokens.has(token) ? 2 : 1;
154
+ score += idf * bodyScore * boost;
155
+ }
156
+ return { doc, score };
157
+ });
158
+ return scored
159
+ .filter((entry) => entry.score > 0)
160
+ .sort((a, b2) => b2.score - a.score)
161
+ .slice(0, limit)
162
+ .map((entry) => ({
163
+ path: entry.doc.path,
164
+ title: entry.doc.title,
165
+ score: Number(entry.score.toFixed(3)),
166
+ excerpt: excerptFor(entry.doc, querySet),
167
+ }));
168
+ }
169
+ }
170
+
171
+ const bm25Cache = new Map<string, Bm25Index>();
172
+
173
+ export class IndexSearchEngine implements SearchEngine {
174
+ readonly name = "index";
175
+ constructor(private readonly layout: WikiLayout) {}
176
+ async search(options: SearchOptions): Promise<SearchResult[]> {
177
+ return this.indexFallback(options);
178
+ }
179
+ private async indexFallback(options: SearchOptions): Promise<SearchResult[]> {
180
+ const entries = await readIndex(this.layout);
181
+ const tokens = tokenize(options.query);
182
+ const scored = entries
183
+ .map((entry) => {
184
+ const haystack = `${entry.title} ${entry.summary} ${entry.tags.join(" ")}`.toLowerCase();
185
+ const score = tokens.reduce((sum, token) => sum + (haystack.includes(token) ? 1 : 0), 0);
186
+ return { entry, score };
187
+ })
188
+ .filter((item) => item.score > 0)
189
+ .sort((a, b) => b.score - a.score)
190
+ .slice(0, options.limit ?? 5);
191
+ return scored.map(({ entry, score }) => ({
192
+ path: entry.path,
193
+ title: entry.title,
194
+ score,
195
+ excerpt: entry.summary,
196
+ }));
197
+ }
198
+ }
199
+
200
+ export class Bm25SearchEngine implements SearchEngine {
201
+ readonly name = "bm25";
202
+ constructor(private readonly layout: WikiLayout) {}
203
+ async search(options: SearchOptions): Promise<SearchResult[]> {
204
+ const key = this.layout.wikiDir;
205
+ let index = bm25Cache.get(key);
206
+ if (!index) {
207
+ index = new Bm25Index();
208
+ bm25Cache.set(key, index);
209
+ }
210
+ await index.ensure(this.layout);
211
+ return index.search(options.query, options.limit ?? 5);
212
+ }
213
+ }
214
+
215
+ export class QmdSearchEngine implements SearchEngine {
216
+ readonly name = "qmd";
217
+ private readonly fallback: SearchEngine;
218
+ constructor(
219
+ private readonly layout: WikiLayout,
220
+ collection?: string,
221
+ ) {
222
+ this.collection = collection;
223
+ this.fallback = new Bm25SearchEngine(layout);
224
+ }
225
+ private collection?: string;
226
+ async search(options: SearchOptions): Promise<SearchResult[]> {
227
+ try {
228
+ const args = ["search", options.query, "--json", "-n", String(options.limit ?? 5)];
229
+ if (this.collection) args.push("-c", this.collection);
230
+ const { stdout } = await run("qmd", args, { maxBuffer: 4 * 1024 * 1024 });
231
+ const parsed = JSON.parse(stdout) as Array<{ path?: string; docid?: string; score?: number; snippet?: string; title?: string }>;
232
+ return parsed.slice(0, options.limit ?? 5).map((item) => ({
233
+ path: item.path ?? item.docid ?? "(unknown)",
234
+ title: item.title ?? item.path ?? "(unknown)",
235
+ score: typeof item.score === "number" ? item.score : 0,
236
+ excerpt: item.snippet ?? "",
237
+ }));
238
+ } catch {
239
+ return this.fallback.search(options);
240
+ }
241
+ }
242
+ }
243
+
244
+ export function createSearchEngine(config: ResolvedConfig, layout: WikiLayout, globalLayout?: WikiLayout): SearchEngine {
245
+ const engines: SearchEngine[] = [];
246
+ if (config.search.engine === "qmd") engines.push(new QmdSearchEngine(layout, config.search.qmdCollection));
247
+ if (config.search.engine === "bm25") engines.push(new Bm25SearchEngine(layout));
248
+ if (engines.length === 0) engines.push(new IndexSearchEngine(layout));
249
+ if (globalLayout && existsSync(globalLayout.wikiDir)) engines.push(new Bm25SearchEngine(globalLayout));
250
+ const primary = engines[0];
251
+ if (engines.length <= 1) return primary;
252
+ return {
253
+ name: `${primary.name}+global`,
254
+ async search(options) {
255
+ const results: SearchResult[] = [];
256
+ for (const [index, engine] of engines.entries()) {
257
+ const hits = await engine.search({ ...options, limit: Math.max(3, Math.round((options.limit ?? 5) / engines.length)) });
258
+ for (const hit of hits) results.push({ ...hit, source: index === 0 ? "project" : "global" });
259
+ }
260
+ return results.sort((a, b) => b.score - a.score).slice(0, options.limit ?? 5);
261
+ },
262
+ };
263
+ }