readthemanual 0.0.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.
- package/README.md +196 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +134 -0
- package/dist/db.d.ts +33 -0
- package/dist/db.js +153 -0
- package/dist/ingest.d.ts +9 -0
- package/dist/ingest.js +163 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +105 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +111 -0
- package/docs-search/package-lock.json +1090 -0
- package/docs-search/package.json +30 -0
- package/docs-search/src/cli.ts +129 -0
- package/docs-search/src/db.ts +192 -0
- package/docs-search/src/ingest.ts +168 -0
- package/docs-search/src/server.ts +145 -0
- package/docs-search/tsconfig.json +17 -0
- package/package.json +32 -0
- package/src/cli.ts +221 -0
- package/src/config.ts +143 -0
- package/src/db.ts +207 -0
- package/src/ingest.ts +200 -0
- package/src/mcp.ts +124 -0
- package/src/server.ts +145 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docs-search",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "dist/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"docs-search": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/cli.ts",
|
|
12
|
+
"start": "node dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [],
|
|
15
|
+
"author": "",
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"type": "commonjs",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@hono/node-server": "^1.19.9",
|
|
20
|
+
"better-sqlite3": "^12.6.2",
|
|
21
|
+
"commander": "^14.0.3",
|
|
22
|
+
"hono": "^4.12.3"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
26
|
+
"@types/node": "^25.3.3",
|
|
27
|
+
"tsx": "^4.21.0",
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { ingest } from "./ingest";
|
|
4
|
+
import { startServer } from "./server";
|
|
5
|
+
import { openDb, search, getDocument, listDocuments, DEFAULT_DB_PATH } from "./db";
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name("docs-search")
|
|
11
|
+
.description("SQLite FTS5 search service for ZeroClaw docs")
|
|
12
|
+
.version("1.0.0");
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command("ingest")
|
|
16
|
+
.description("Fetch and index ZeroClaw docs from GitHub")
|
|
17
|
+
.option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
|
|
18
|
+
.option("--all-langs", "Index all languages, not just English", false)
|
|
19
|
+
.option("-v, --verbose", "Verbose output", false)
|
|
20
|
+
.action(async (opts) => {
|
|
21
|
+
const result = await ingest({
|
|
22
|
+
dbPath: opts.db,
|
|
23
|
+
allLangs: opts.allLangs,
|
|
24
|
+
verbose: opts.verbose,
|
|
25
|
+
});
|
|
26
|
+
console.log(
|
|
27
|
+
`Indexed ${result.filesProcessed} files (${result.sectionsInserted} sections).`
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
program
|
|
32
|
+
.command("serve")
|
|
33
|
+
.description("Start the JSON-RPC HTTP server")
|
|
34
|
+
.option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
|
|
35
|
+
.option("-p, --port <number>", "Port to listen on", "3777")
|
|
36
|
+
.action((opts) => {
|
|
37
|
+
startServer({ dbPath: opts.db, port: parseInt(opts.port, 10) });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
program
|
|
41
|
+
.command("search <query>")
|
|
42
|
+
.description("Search the docs index")
|
|
43
|
+
.option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
|
|
44
|
+
.option("-l, --lang <language>", "Filter by language")
|
|
45
|
+
.option("-n, --limit <number>", "Max results", "10")
|
|
46
|
+
.option("--json", "Output raw JSON", false)
|
|
47
|
+
.action((query, opts) => {
|
|
48
|
+
const db = openDb(opts.db);
|
|
49
|
+
const { results, total } = search(db, query, {
|
|
50
|
+
language: opts.lang,
|
|
51
|
+
limit: parseInt(opts.limit, 10),
|
|
52
|
+
});
|
|
53
|
+
db.close();
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
console.log(JSON.stringify({ results, total }, null, 2));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (results.length === 0) {
|
|
59
|
+
console.log("No results found.");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const r of results) {
|
|
63
|
+
const snippet = r.snippet.replace(/<\/?mark>/g, (m) => m === "<mark>" ? "\x1b[1;33m" : "\x1b[0m");
|
|
64
|
+
console.log(`\x1b[36m[${r.id}]\x1b[0m \x1b[1m${r.heading}\x1b[0m`);
|
|
65
|
+
console.log(` ${r.file_path} (${r.language})`);
|
|
66
|
+
console.log(` ${snippet}`);
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
console.log(`${results.length} of ${total} match(es)`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
program
|
|
73
|
+
.command("get <id>")
|
|
74
|
+
.description("Get a document section by ID")
|
|
75
|
+
.option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
|
|
76
|
+
.option("--json", "Output raw JSON", false)
|
|
77
|
+
.action((id, opts) => {
|
|
78
|
+
const db = openDb(opts.db);
|
|
79
|
+
const doc = getDocument(db, parseInt(id, 10));
|
|
80
|
+
db.close();
|
|
81
|
+
if (!doc) {
|
|
82
|
+
console.error(`Document ${id} not found.`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
if (opts.json) {
|
|
86
|
+
console.log(JSON.stringify(doc, null, 2));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(`\x1b[1m${doc.heading}\x1b[0m`);
|
|
90
|
+
console.log(`${doc.file_path} (${doc.language})`);
|
|
91
|
+
console.log();
|
|
92
|
+
console.log(doc.content);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
program
|
|
96
|
+
.command("list")
|
|
97
|
+
.description("List indexed documents")
|
|
98
|
+
.option("-d, --db <path>", "SQLite database path", DEFAULT_DB_PATH)
|
|
99
|
+
.option("-l, --lang <language>", "Filter by language")
|
|
100
|
+
.option("-n, --limit <number>", "Max results", "50")
|
|
101
|
+
.option("-o, --offset <number>", "Offset", "0")
|
|
102
|
+
.option("--json", "Output raw JSON", false)
|
|
103
|
+
.action((opts) => {
|
|
104
|
+
const db = openDb(opts.db);
|
|
105
|
+
const result = listDocuments(db, {
|
|
106
|
+
language: opts.lang,
|
|
107
|
+
limit: parseInt(opts.limit, 10),
|
|
108
|
+
offset: parseInt(opts.offset, 10),
|
|
109
|
+
});
|
|
110
|
+
db.close();
|
|
111
|
+
if (opts.json) {
|
|
112
|
+
console.log(JSON.stringify(result, null, 2));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const d of result.docs) {
|
|
116
|
+
console.log(`\x1b[36m[${d.id}]\x1b[0m ${d.file_path} — ${d.heading} (${d.language})`);
|
|
117
|
+
}
|
|
118
|
+
console.log(`\nShowing ${result.docs.length} of ${result.total} total sections`);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Default: bare args with no subcommand → search
|
|
122
|
+
const knownCommands = ["ingest", "serve", "search", "get", "list", "help"];
|
|
123
|
+
const firstArg = process.argv[2];
|
|
124
|
+
if (firstArg && !firstArg.startsWith("-") && !knownCommands.includes(firstArg)) {
|
|
125
|
+
// Rewrite argv to inject "search" before the query
|
|
126
|
+
process.argv.splice(2, 0, "search");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
program.parse();
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DB_DIR = path.join(os.homedir(), ".local", "docs-search");
|
|
7
|
+
const DEFAULT_DB_PATH = path.join(DEFAULT_DB_DIR, "docs-search.db");
|
|
8
|
+
|
|
9
|
+
export interface DocSection {
|
|
10
|
+
id: number;
|
|
11
|
+
file_path: string;
|
|
12
|
+
heading: string;
|
|
13
|
+
content: string;
|
|
14
|
+
language: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SearchResult extends DocSection {
|
|
18
|
+
rank: number;
|
|
19
|
+
snippet: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { DEFAULT_DB_PATH };
|
|
23
|
+
|
|
24
|
+
export function openDb(dbPath: string = DEFAULT_DB_PATH): Database.Database {
|
|
25
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
26
|
+
const db = new Database(dbPath);
|
|
27
|
+
db.pragma("journal_mode = WAL");
|
|
28
|
+
db.pragma("foreign_keys = ON");
|
|
29
|
+
initSchema(db);
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initSchema(db: Database.Database): void {
|
|
34
|
+
db.exec(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS docs (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
file_path TEXT NOT NULL,
|
|
38
|
+
heading TEXT NOT NULL,
|
|
39
|
+
content TEXT NOT NULL,
|
|
40
|
+
language TEXT NOT NULL DEFAULT 'en'
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_docs_path ON docs(file_path);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_docs_lang ON docs(language);
|
|
45
|
+
|
|
46
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
|
|
47
|
+
heading,
|
|
48
|
+
content,
|
|
49
|
+
content='docs',
|
|
50
|
+
content_rowid='id',
|
|
51
|
+
tokenize='porter unicode61'
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
|
|
55
|
+
INSERT INTO docs_fts(rowid, heading, content)
|
|
56
|
+
VALUES (new.id, new.heading, new.content);
|
|
57
|
+
END;
|
|
58
|
+
|
|
59
|
+
CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
|
|
60
|
+
INSERT INTO docs_fts(docs_fts, rowid, heading, content)
|
|
61
|
+
VALUES ('delete', old.id, old.heading, old.content);
|
|
62
|
+
END;
|
|
63
|
+
|
|
64
|
+
CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
|
|
65
|
+
INSERT INTO docs_fts(docs_fts, rowid, heading, content)
|
|
66
|
+
VALUES ('delete', old.id, old.heading, old.content);
|
|
67
|
+
INSERT INTO docs_fts(rowid, heading, content)
|
|
68
|
+
VALUES (new.id, new.heading, new.content);
|
|
69
|
+
END;
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearDocs(db: Database.Database): void {
|
|
74
|
+
db.exec("DELETE FROM docs");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function insertSection(
|
|
78
|
+
db: Database.Database,
|
|
79
|
+
section: Omit<DocSection, "id">
|
|
80
|
+
): number {
|
|
81
|
+
const stmt = db.prepare(
|
|
82
|
+
"INSERT INTO docs (file_path, heading, content, language) VALUES (?, ?, ?, ?)"
|
|
83
|
+
);
|
|
84
|
+
const result = stmt.run(
|
|
85
|
+
section.file_path,
|
|
86
|
+
section.heading,
|
|
87
|
+
section.content,
|
|
88
|
+
section.language
|
|
89
|
+
);
|
|
90
|
+
return Number(result.lastInsertRowid);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface SearchResponse {
|
|
94
|
+
results: SearchResult[];
|
|
95
|
+
total: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitizeFtsQuery(query: string): string {
|
|
99
|
+
// Split into tokens and wrap each in double quotes so FTS5 treats them as
|
|
100
|
+
// literals (handles dashes, slashes, backslashes, etc.). Any embedded
|
|
101
|
+
// double-quote characters are removed to avoid breaking the quoting.
|
|
102
|
+
const tokens = query
|
|
103
|
+
.replace(/"/g, "")
|
|
104
|
+
.split(/\s+/)
|
|
105
|
+
.filter(Boolean);
|
|
106
|
+
if (tokens.length === 0) return '""';
|
|
107
|
+
return tokens.map((t) => `"${t}"`).join(" ");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function search(
|
|
111
|
+
db: Database.Database,
|
|
112
|
+
query: string,
|
|
113
|
+
opts: { language?: string; limit?: number } = {}
|
|
114
|
+
): SearchResponse {
|
|
115
|
+
const limit = opts.limit ?? 20;
|
|
116
|
+
const ftsQuery = sanitizeFtsQuery(query);
|
|
117
|
+
|
|
118
|
+
let countSql = `
|
|
119
|
+
SELECT COUNT(*) as total
|
|
120
|
+
FROM docs_fts
|
|
121
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
122
|
+
WHERE docs_fts MATCH ?
|
|
123
|
+
`;
|
|
124
|
+
let sql = `
|
|
125
|
+
SELECT
|
|
126
|
+
d.id, d.file_path, d.heading, d.content, d.language,
|
|
127
|
+
docs_fts.rank AS rank,
|
|
128
|
+
snippet(docs_fts, 1, '<mark>', '</mark>', '...', 40) AS snippet
|
|
129
|
+
FROM docs_fts
|
|
130
|
+
JOIN docs d ON d.id = docs_fts.rowid
|
|
131
|
+
WHERE docs_fts MATCH ?
|
|
132
|
+
`;
|
|
133
|
+
const params: (string | number)[] = [ftsQuery];
|
|
134
|
+
const countParams: (string | number)[] = [ftsQuery];
|
|
135
|
+
|
|
136
|
+
if (opts.language) {
|
|
137
|
+
countSql += " AND d.language = ?";
|
|
138
|
+
sql += " AND d.language = ?";
|
|
139
|
+
params.push(opts.language);
|
|
140
|
+
countParams.push(opts.language);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
sql += " ORDER BY docs_fts.rank LIMIT ?";
|
|
144
|
+
params.push(limit);
|
|
145
|
+
|
|
146
|
+
const { total } = db.prepare(countSql).get(...countParams) as { total: number };
|
|
147
|
+
const results = db.prepare(sql).all(...params) as SearchResult[];
|
|
148
|
+
|
|
149
|
+
return { results, total };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getDocument(
|
|
153
|
+
db: Database.Database,
|
|
154
|
+
id: number
|
|
155
|
+
): DocSection | null {
|
|
156
|
+
return (
|
|
157
|
+
(db.prepare("SELECT * FROM docs WHERE id = ?").get(id) as
|
|
158
|
+
| DocSection
|
|
159
|
+
| undefined) ?? null
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function listDocuments(
|
|
164
|
+
db: Database.Database,
|
|
165
|
+
opts: { language?: string; limit?: number; offset?: number } = {}
|
|
166
|
+
): { docs: Pick<DocSection, "id" | "file_path" | "heading" | "language">[]; total: number } {
|
|
167
|
+
const limit = opts.limit ?? 100;
|
|
168
|
+
const offset = opts.offset ?? 0;
|
|
169
|
+
|
|
170
|
+
let countSql = "SELECT COUNT(*) as total FROM docs";
|
|
171
|
+
let sql = "SELECT id, file_path, heading, language FROM docs";
|
|
172
|
+
const params: (string | number)[] = [];
|
|
173
|
+
const countParams: string[] = [];
|
|
174
|
+
|
|
175
|
+
if (opts.language) {
|
|
176
|
+
countSql += " WHERE language = ?";
|
|
177
|
+
sql += " WHERE language = ?";
|
|
178
|
+
params.push(opts.language);
|
|
179
|
+
countParams.push(opts.language);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
sql += " ORDER BY file_path, id LIMIT ? OFFSET ?";
|
|
183
|
+
params.push(limit, offset);
|
|
184
|
+
|
|
185
|
+
const { total } = db.prepare(countSql).get(...countParams) as { total: number };
|
|
186
|
+
const docs = db.prepare(sql).all(...params) as Pick<
|
|
187
|
+
DocSection,
|
|
188
|
+
"id" | "file_path" | "heading" | "language"
|
|
189
|
+
>[];
|
|
190
|
+
|
|
191
|
+
return { docs, total };
|
|
192
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { openDb, clearDocs, insertSection } from "./db";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
const DOCS_DIR = path.join(os.homedir(), ".local", "docs-search", "docs");
|
|
8
|
+
|
|
9
|
+
const REPO = "zeroclaw-labs/zeroclaw";
|
|
10
|
+
const BRANCH = "main";
|
|
11
|
+
const DOCS_PREFIX = "docs/";
|
|
12
|
+
|
|
13
|
+
interface TreeEntry {
|
|
14
|
+
path: string;
|
|
15
|
+
type: string;
|
|
16
|
+
url: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fetchTree(): Promise<TreeEntry[]> {
|
|
20
|
+
const url = `https://api.github.com/repos/${REPO}/git/trees/${BRANCH}?recursive=1`;
|
|
21
|
+
const res = await fetch(url, {
|
|
22
|
+
headers: {
|
|
23
|
+
Accept: "application/vnd.github.v3+json",
|
|
24
|
+
...(process.env.GITHUB_TOKEN
|
|
25
|
+
? { Authorization: `token ${process.env.GITHUB_TOKEN}` }
|
|
26
|
+
: {}),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) throw new Error(`GitHub tree API: ${res.status} ${res.statusText}`);
|
|
30
|
+
const data = (await res.json()) as { tree: TreeEntry[] };
|
|
31
|
+
return data.tree;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fetchRawFile(filePath: string): Promise<string> {
|
|
35
|
+
const url = `https://raw.githubusercontent.com/${REPO}/${BRANCH}/${filePath}`;
|
|
36
|
+
const res = await fetch(url);
|
|
37
|
+
if (!res.ok) throw new Error(`Failed to fetch ${filePath}: ${res.status}`);
|
|
38
|
+
return res.text();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detectLanguage(filePath: string): string {
|
|
42
|
+
const match = filePath.match(/^docs\/i18n\/([a-z]{2}(?:-[A-Z]{2})?)\//);
|
|
43
|
+
if (match) return match[1];
|
|
44
|
+
return "en";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface Section {
|
|
48
|
+
heading: string;
|
|
49
|
+
content: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function splitByHeadings(markdown: string): Section[] {
|
|
53
|
+
const lines = markdown.split("\n");
|
|
54
|
+
const sections: Section[] = [];
|
|
55
|
+
let currentHeading = "(intro)";
|
|
56
|
+
let currentLines: string[] = [];
|
|
57
|
+
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
|
|
60
|
+
if (headingMatch) {
|
|
61
|
+
if (currentLines.length > 0) {
|
|
62
|
+
const content = currentLines.join("\n").trim();
|
|
63
|
+
if (content.length > 0) {
|
|
64
|
+
sections.push({ heading: currentHeading, content });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
currentHeading = headingMatch[2];
|
|
68
|
+
currentLines = [];
|
|
69
|
+
} else {
|
|
70
|
+
currentLines.push(line);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (currentLines.length > 0) {
|
|
75
|
+
const content = currentLines.join("\n").trim();
|
|
76
|
+
if (content.length > 0) {
|
|
77
|
+
sections.push({ heading: currentHeading, content });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return sections;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function ingest(opts: {
|
|
85
|
+
dbPath?: string;
|
|
86
|
+
allLangs?: boolean;
|
|
87
|
+
verbose?: boolean;
|
|
88
|
+
}): Promise<{ filesProcessed: number; sectionsInserted: number }> {
|
|
89
|
+
const db = openDb(opts.dbPath);
|
|
90
|
+
const log = opts.verbose ? console.log : () => {};
|
|
91
|
+
|
|
92
|
+
log("Fetching file tree from GitHub...");
|
|
93
|
+
const tree = await fetchTree();
|
|
94
|
+
|
|
95
|
+
const mdFiles = tree.filter((entry) => {
|
|
96
|
+
if (entry.type !== "blob") return false;
|
|
97
|
+
if (!entry.path.startsWith(DOCS_PREFIX)) return false;
|
|
98
|
+
if (!entry.path.endsWith(".md")) return false;
|
|
99
|
+
// Skip images and non-text
|
|
100
|
+
if (/\.(jpg|png|svg|gif)$/i.test(entry.path)) return false;
|
|
101
|
+
// Filter by language
|
|
102
|
+
if (!opts.allLangs) {
|
|
103
|
+
const lang = detectLanguage(entry.path);
|
|
104
|
+
if (lang !== "en") return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
log(`Found ${mdFiles.length} markdown files to index.`);
|
|
110
|
+
log(`Saving docs to ${DOCS_DIR}`);
|
|
111
|
+
|
|
112
|
+
// Clean and recreate local docs directory
|
|
113
|
+
fs.rmSync(DOCS_DIR, { recursive: true, force: true });
|
|
114
|
+
fs.mkdirSync(DOCS_DIR, { recursive: true });
|
|
115
|
+
|
|
116
|
+
clearDocs(db);
|
|
117
|
+
|
|
118
|
+
const insertMany = db.transaction(
|
|
119
|
+
(sections: { file_path: string; heading: string; content: string; language: string }[]) => {
|
|
120
|
+
for (const s of sections) {
|
|
121
|
+
insertSection(db, s);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
let filesProcessed = 0;
|
|
127
|
+
let sectionsInserted = 0;
|
|
128
|
+
const allSections: { file_path: string; heading: string; content: string; language: string }[] = [];
|
|
129
|
+
|
|
130
|
+
// Fetch in batches of 10 to avoid rate limits
|
|
131
|
+
const batchSize = 10;
|
|
132
|
+
for (let i = 0; i < mdFiles.length; i += batchSize) {
|
|
133
|
+
const batch = mdFiles.slice(i, i + batchSize);
|
|
134
|
+
const results = await Promise.all(
|
|
135
|
+
batch.map(async (entry) => {
|
|
136
|
+
const content = await fetchRawFile(entry.path);
|
|
137
|
+
return { path: entry.path, content };
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
for (const { path: filePath, content } of results) {
|
|
142
|
+
// Save raw markdown to ~/.local/docs-search/docs/
|
|
143
|
+
const localPath = path.join(DOCS_DIR, filePath);
|
|
144
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
145
|
+
fs.writeFileSync(localPath, content, "utf-8");
|
|
146
|
+
|
|
147
|
+
const lang = detectLanguage(filePath);
|
|
148
|
+
const sections = splitByHeadings(content);
|
|
149
|
+
for (const section of sections) {
|
|
150
|
+
allSections.push({
|
|
151
|
+
file_path: filePath,
|
|
152
|
+
heading: section.heading,
|
|
153
|
+
content: section.content,
|
|
154
|
+
language: lang,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
filesProcessed++;
|
|
158
|
+
log(` [${filesProcessed}/${mdFiles.length}] ${filePath} (${sections.length} sections)`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
insertMany(allSections);
|
|
163
|
+
sectionsInserted = allSections.length;
|
|
164
|
+
|
|
165
|
+
db.close();
|
|
166
|
+
log(`Done. ${filesProcessed} files, ${sectionsInserted} sections indexed.`);
|
|
167
|
+
return { filesProcessed, sectionsInserted };
|
|
168
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { openDb, search, getDocument, listDocuments } from "./db";
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
interface JsonRpcRequest {
|
|
7
|
+
jsonrpc: "2.0";
|
|
8
|
+
id: string | number | null;
|
|
9
|
+
method: string;
|
|
10
|
+
params?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JsonRpcResponse {
|
|
14
|
+
jsonrpc: "2.0";
|
|
15
|
+
id: string | number | null;
|
|
16
|
+
result?: unknown;
|
|
17
|
+
error?: { code: number; message: string; data?: unknown };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rpcError(
|
|
21
|
+
id: string | number | null,
|
|
22
|
+
code: number,
|
|
23
|
+
message: string
|
|
24
|
+
): JsonRpcResponse {
|
|
25
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rpcResult(id: string | number | null, result: unknown): JsonRpcResponse {
|
|
29
|
+
return { jsonrpc: "2.0", id, result };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleRpc(db: Database.Database, req: JsonRpcRequest): JsonRpcResponse {
|
|
33
|
+
const { id, method, params } = req;
|
|
34
|
+
|
|
35
|
+
switch (method) {
|
|
36
|
+
case "search": {
|
|
37
|
+
const query = params?.query;
|
|
38
|
+
if (typeof query !== "string" || query.trim().length === 0) {
|
|
39
|
+
return rpcError(id, -32602, "params.query is required and must be a non-empty string");
|
|
40
|
+
}
|
|
41
|
+
const language = typeof params?.language === "string" ? params.language : undefined;
|
|
42
|
+
const limit = typeof params?.limit === "number" ? params.limit : undefined;
|
|
43
|
+
const { results, total } = search(db, query, { language, limit });
|
|
44
|
+
return rpcResult(id, { results, total, count: results.length });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "get_document": {
|
|
48
|
+
const docId = params?.id;
|
|
49
|
+
if (typeof docId !== "number") {
|
|
50
|
+
return rpcError(id, -32602, "params.id is required and must be a number");
|
|
51
|
+
}
|
|
52
|
+
const doc = getDocument(db, docId);
|
|
53
|
+
if (!doc) {
|
|
54
|
+
return rpcError(id, -32602, `Document with id ${docId} not found`);
|
|
55
|
+
}
|
|
56
|
+
return rpcResult(id, doc);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case "list_documents": {
|
|
60
|
+
const language = typeof params?.language === "string" ? params.language : undefined;
|
|
61
|
+
const limit = typeof params?.limit === "number" ? params.limit : undefined;
|
|
62
|
+
const offset = typeof params?.offset === "number" ? params.offset : undefined;
|
|
63
|
+
const result = listDocuments(db, { language, limit, offset });
|
|
64
|
+
return rpcResult(id, result);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
default:
|
|
68
|
+
return rpcError(id, -32601, `Method '${method}' not found`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createApp(db: Database.Database): Hono {
|
|
73
|
+
const app = new Hono();
|
|
74
|
+
|
|
75
|
+
app.post("/rpc", async (c) => {
|
|
76
|
+
let body: unknown;
|
|
77
|
+
try {
|
|
78
|
+
body = await c.req.json();
|
|
79
|
+
} catch {
|
|
80
|
+
return c.json(rpcError(null, -32700, "Parse error"), 200);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Batch support
|
|
84
|
+
if (Array.isArray(body)) {
|
|
85
|
+
const responses = body.map((req) => {
|
|
86
|
+
if (!isValidRequest(req)) {
|
|
87
|
+
return rpcError(req?.id ?? null, -32600, "Invalid JSON-RPC request");
|
|
88
|
+
}
|
|
89
|
+
return handleRpc(db, req);
|
|
90
|
+
});
|
|
91
|
+
return c.json(responses, 200);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!isValidRequest(body)) {
|
|
95
|
+
return c.json(
|
|
96
|
+
rpcError((body as any)?.id ?? null, -32600, "Invalid JSON-RPC request"),
|
|
97
|
+
200
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return c.json(handleRpc(db, body), 200);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Health check
|
|
105
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
106
|
+
|
|
107
|
+
return app;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isValidRequest(obj: unknown): obj is JsonRpcRequest {
|
|
111
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
112
|
+
const r = obj as Record<string, unknown>;
|
|
113
|
+
return r.jsonrpc === "2.0" && typeof r.method === "string";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
import net from "net";
|
|
117
|
+
|
|
118
|
+
function isPortInUse(port: number): Promise<boolean> {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const server = net.createServer();
|
|
121
|
+
server.once("error", () => resolve(true));
|
|
122
|
+
server.once("listening", () => {
|
|
123
|
+
server.close();
|
|
124
|
+
resolve(false);
|
|
125
|
+
});
|
|
126
|
+
server.listen(port);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function startServer(opts: { dbPath?: string; port?: number }): Promise<void> {
|
|
131
|
+
const port = opts.port ?? 3777;
|
|
132
|
+
|
|
133
|
+
if (await isPortInUse(port)) {
|
|
134
|
+
console.error(`Port ${port} is already in use. Another instance may be running.`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const db = openDb(opts.dbPath);
|
|
139
|
+
const app = createApp(db);
|
|
140
|
+
|
|
141
|
+
serve({ fetch: app.fetch, port }, () => {
|
|
142
|
+
console.log(`docs-search server listening on http://localhost:${port}`);
|
|
143
|
+
console.log(`JSON-RPC endpoint: POST http://localhost:${port}/rpc`);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|