@jacebenson/jsn 3.2.0 → 3.3.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,135 @@
1
+ // Incremental refresh: sync docs.db to the current state of the markdown folder
2
+ // after a ServiceNow docs update (e.g. `git pull` in the docs repo).
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createHash } from 'node:crypto';
7
+ import matter from 'gray-matter';
8
+ import { openDocsDb, closeDocsDb, getDocsDbPath } from './db.js';
9
+ import { encodeText, phasesToBytes, docSurface, DEFAULT_DIM } from './hrr.js';
10
+
11
+ export function refreshDocs(opts = {}) {
12
+ const docsDir = opts.docsDir;
13
+ const dbPath = opts.dbPath || getDocsDbPath();
14
+
15
+ if (!fs.existsSync(dbPath)) {
16
+ throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first for the initial build.`);
17
+ }
18
+ if (!fs.existsSync(docsDir)) {
19
+ throw new Error(`Docs directory not found: ${docsDir}`);
20
+ }
21
+
22
+ const db = openDocsDb({ dbPath });
23
+ db.exec('PRAGMA journal_mode = WAL');
24
+
25
+ const cols = db.prepare('PRAGMA table_info(docs)').all().map((c) => c.name);
26
+ if (!cols.includes('hash')) db.exec('ALTER TABLE docs ADD COLUMN hash TEXT');
27
+ if (!cols.includes('hrr_vector')) db.exec('ALTER TABLE docs ADD COLUMN hrr_vector BLOB');
28
+
29
+ const embeddingsEnabled =
30
+ db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hrr_vector IS NOT NULL').get().n > 0 ||
31
+ !cols.includes('hrr_vector');
32
+
33
+ const isBackfill = db.prepare('SELECT COUNT(*) AS n FROM docs WHERE hash IS NOT NULL').get().n === 0
34
+ && db.prepare('SELECT COUNT(*) AS n FROM docs').get().n > 0;
35
+ const setHash = db.prepare('UPDATE docs SET hash = ? WHERE id = ?');
36
+
37
+ function* walk(dir) {
38
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
39
+ const full = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) yield* walk(full);
41
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) yield full;
42
+ }
43
+ }
44
+
45
+ const existing = new Map();
46
+ for (const row of db.prepare('SELECT id, path, hash FROM docs').all()) {
47
+ existing.set(row.path, { id: row.id, hash: row.hash });
48
+ }
49
+
50
+ const upsert = db.prepare(`
51
+ INSERT INTO docs (path, title, release, bundle, doc_type, locale, frontmatter, body, size, mtime, hash, hrr_vector)
52
+ VALUES (@path, @title, @release, @bundle, @doc_type, @locale, @frontmatter, @body, @size, @mtime, @hash, @hrr_vector)
53
+ ON CONFLICT(path) DO UPDATE SET
54
+ title=@title, release=@release, bundle=@bundle, doc_type=@doc_type, locale=@locale,
55
+ frontmatter=@frontmatter, body=@body, size=@size, mtime=@mtime, hash=@hash, hrr_vector=@hrr_vector
56
+ `);
57
+ const del = db.prepare('DELETE FROM docs WHERE id = ?');
58
+
59
+ let added = 0, updated = 0, removed = 0, unchanged = 0, errors = 0;
60
+ const seen = new Set();
61
+ const started = Date.now();
62
+
63
+ const files = [...walk(docsDir)];
64
+
65
+ db.exec('BEGIN');
66
+ for (const file of files) {
67
+ try {
68
+ const rel = path.relative(docsDir, file).split(path.sep).join('/');
69
+ seen.add(rel);
70
+ const raw = fs.readFileSync(file, 'utf8');
71
+ const hash = createHash('sha256').update(raw).digest('hex');
72
+
73
+ const prev = existing.get(rel);
74
+ if (prev && prev.hash === hash) {
75
+ unchanged++;
76
+ continue;
77
+ }
78
+
79
+ if (isBackfill && prev) {
80
+ setHash.run(hash, prev.id);
81
+ unchanged++;
82
+ continue;
83
+ }
84
+
85
+ const stat = fs.statSync(file);
86
+ let data = {}, body = raw;
87
+ try {
88
+ const parsed = matter(raw);
89
+ data = parsed.data || {};
90
+ body = parsed.content || '';
91
+ } catch {
92
+ // keep raw body on malformed frontmatter
93
+ }
94
+
95
+ const hrr = embeddingsEnabled
96
+ ? phasesToBytes(encodeText(docSurface(data.title ?? null, body), DEFAULT_DIM))
97
+ : null;
98
+
99
+ upsert.run({
100
+ path: rel,
101
+ title: data.title ?? null,
102
+ release: data.release ?? null,
103
+ bundle: data.bundle ?? null,
104
+ doc_type: data.doc_type ?? null,
105
+ locale: data.locale ?? null,
106
+ frontmatter: JSON.stringify(data),
107
+ body,
108
+ size: stat.size,
109
+ mtime: Math.floor(stat.mtimeMs),
110
+ hash,
111
+ hrr_vector: hrr,
112
+ });
113
+ if (prev) updated++; else added++;
114
+ } catch (err) {
115
+ errors++;
116
+ if (errors <= 10) process.stderr.write(`Skipped ${file}: ${err.message}\n`);
117
+ }
118
+ }
119
+
120
+ for (const [rel, row] of existing) {
121
+ if (!seen.has(rel)) {
122
+ del.run(row.id);
123
+ removed++;
124
+ }
125
+ }
126
+ db.exec('COMMIT');
127
+
128
+ db.exec("INSERT INTO docs_fts(docs_fts) VALUES('optimize');");
129
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
130
+ db.prepare("INSERT OR REPLACE INTO meta(key, value) VALUES('synced_at', ?)").run(String(Date.now()));
131
+ closeDocsDb(db);
132
+
133
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
134
+ return { added, updated, removed, unchanged, errors, secs, dbPath, embeddingsEnabled };
135
+ }
@@ -0,0 +1,139 @@
1
+ // CLI search against the docs SQLite database.
2
+
3
+ import fs from 'node:fs';
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ import { getDocsDbPath, hasEmbeddings } from './db.js';
6
+ import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
7
+
8
+ function safeParse(s) {
9
+ try {
10
+ return JSON.parse(s);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ export function searchDocs(opts = {}) {
17
+ const dbPath = opts.dbPath || getDocsDbPath();
18
+ const q = (opts.query || '').toString().trim();
19
+ if (!q) {
20
+ throw new Error('Query is required.');
21
+ }
22
+
23
+ if (!fs.existsSync(dbPath)) {
24
+ throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
25
+ }
26
+
27
+ const db = new DatabaseSync(dbPath, { readOnly: true });
28
+ const limit = Math.min(parseInt(opts.limit, 10) || 20, 100);
29
+ const offset = parseInt(opts.offset, 10) || 0;
30
+ const bundle = opts.bundle ? String(opts.bundle) : null;
31
+ const docType = opts.docType ? String(opts.docType) : null;
32
+ const mode = opts.mode === 'keyword' ? 'keyword' : 'hybrid';
33
+ const useHybrid = mode === 'hybrid' && hasEmbeddings(db);
34
+
35
+ const filters = [];
36
+ const params = { q, limit, offset };
37
+ if (bundle) {
38
+ filters.push('d.bundle = @bundle');
39
+ params.bundle = bundle;
40
+ }
41
+ if (docType) {
42
+ filters.push('d.doc_type = @docType');
43
+ params.docType = docType;
44
+ }
45
+ const where = filters.length ? ' AND ' + filters.join(' AND ') : '';
46
+
47
+ let rows;
48
+ if (!useHybrid) {
49
+ rows = db
50
+ .prepare(
51
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release,
52
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
53
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
54
+ bm25(docs_fts) AS score
55
+ FROM docs_fts
56
+ JOIN docs d ON d.id = docs_fts.rowid
57
+ WHERE docs_fts MATCH @q${where}
58
+ ORDER BY score
59
+ LIMIT @limit OFFSET @offset`
60
+ )
61
+ .all(params);
62
+ } else {
63
+ const candParams = { q, cand: (limit + offset) * 4 + 20 };
64
+ if (bundle) candParams.bundle = bundle;
65
+ if (docType) candParams.docType = docType;
66
+ const candidates = db
67
+ .prepare(
68
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release, d.hrr_vector,
69
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
70
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
71
+ bm25(docs_fts) AS bm25
72
+ FROM docs_fts
73
+ JOIN docs d ON d.id = docs_fts.rowid
74
+ WHERE docs_fts MATCH @q${where}
75
+ ORDER BY bm25
76
+ LIMIT @cand`
77
+ )
78
+ .all(candParams);
79
+
80
+ if (candidates.length === 0) {
81
+ rows = [];
82
+ } else {
83
+ const hrrDim = (() => {
84
+ try {
85
+ const row = db.prepare("SELECT value FROM meta WHERE key='hrr_dim'").get();
86
+ return row ? parseInt(row.value, 10) : DEFAULT_DIM;
87
+ } catch {
88
+ return DEFAULT_DIM;
89
+ }
90
+ })();
91
+ const queryVec = encodeText(q, hrrDim);
92
+ const queryTokens = new Set(q.toLowerCase().split(/\s+/).filter(Boolean));
93
+
94
+ const bmVals = candidates.map((c) => c.bm25);
95
+ const bmMin = Math.min(...bmVals);
96
+ const bmMax = Math.max(...bmVals);
97
+ const bmRange = bmMax - bmMin || 1;
98
+
99
+ const FTS_W = 0.4, JACCARD_W = 0.3, HRR_W = 0.3;
100
+
101
+ for (const c of candidates) {
102
+ const ftsScore = 1 - (c.bm25 - bmMin) / bmRange;
103
+ const contentTokens = new Set(
104
+ `${c.title || ''} ${c.path || ''}`.toLowerCase().split(/\s+/).filter(Boolean)
105
+ );
106
+ let inter = 0;
107
+ for (const t of queryTokens) if (contentTokens.has(t)) inter++;
108
+ const union = queryTokens.size + contentTokens.size - inter;
109
+ const jaccard = union ? inter / union : 0;
110
+
111
+ let hrrSim = 0.5;
112
+ if (c.hrr_vector) {
113
+ hrrSim = (similarity(queryVec, bytesToPhases(c.hrr_vector)) + 1) / 2;
114
+ }
115
+
116
+ c.score = FTS_W * ftsScore + JACCARD_W * jaccard + HRR_W * hrrSim;
117
+ delete c.hrr_vector;
118
+ delete c.bm25;
119
+ }
120
+
121
+ candidates.sort((a, b) => b.score - a.score);
122
+ rows = candidates.slice(offset, offset + limit);
123
+ }
124
+ }
125
+
126
+ db.close();
127
+
128
+ // Post-process canonical_url JSON strings that may contain escaped content.
129
+ for (const row of rows) {
130
+ if (row.canonical_url) row.canonical_url = safeParse(row.canonical_url) || row.canonical_url;
131
+ }
132
+
133
+ return {
134
+ query: q,
135
+ mode: useHybrid ? 'hybrid' : 'keyword',
136
+ count: rows.length,
137
+ results: rows,
138
+ };
139
+ }
@@ -0,0 +1,278 @@
1
+ // Built-in HTTP server for docs search (node:http only, no Express).
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import http from 'node:http';
6
+ import { URL } from 'node:url';
7
+ import { DatabaseSync } from 'node:sqlite';
8
+ import { getDocsDbPath, hasEmbeddings } from './db.js';
9
+ import { encodeText, similarity, bytesToPhases, DEFAULT_DIM } from './hrr.js';
10
+
11
+ const PUBLIC_DIR = path.join(import.meta.dirname, 'public');
12
+
13
+ function safeParse(s) {
14
+ try {
15
+ return JSON.parse(s);
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ function sendJSON(res, status, obj) {
22
+ const body = JSON.stringify(obj, null, 2);
23
+ res.writeHead(status, {
24
+ 'Content-Type': 'application/json',
25
+ 'Content-Length': Buffer.byteLength(body),
26
+ });
27
+ res.end(body);
28
+ }
29
+
30
+ function sendText(res, status, text, type = 'text/plain') {
31
+ res.writeHead(status, {
32
+ 'Content-Type': type,
33
+ 'Content-Length': Buffer.byteLength(text),
34
+ });
35
+ res.end(text);
36
+ }
37
+
38
+ function serveStatic(req, res, filePath) {
39
+ if (!fs.existsSync(filePath)) {
40
+ sendText(res, 404, 'Not found');
41
+ return;
42
+ }
43
+ const stat = fs.statSync(filePath);
44
+ if (stat.isDirectory()) {
45
+ sendText(res, 403, 'Forbidden');
46
+ return;
47
+ }
48
+ const ext = path.extname(filePath).toLowerCase();
49
+ const mime = {
50
+ '.html': 'text/html',
51
+ '.js': 'application/javascript',
52
+ '.css': 'text/css',
53
+ '.json': 'application/json',
54
+ '.png': 'image/png',
55
+ '.jpg': 'image/jpeg',
56
+ '.svg': 'image/svg+xml',
57
+ }[ext] || 'application/octet-stream';
58
+
59
+ res.writeHead(200, {
60
+ 'Content-Type': mime,
61
+ 'Content-Length': stat.size,
62
+ });
63
+ fs.createReadStream(filePath).pipe(res);
64
+ }
65
+
66
+ export function serveDocs(opts = {}) {
67
+ const dbPath = opts.dbPath || getDocsDbPath();
68
+ const port = parseInt(opts.port, 10) || 3000;
69
+ const host = opts.host || '127.0.0.1';
70
+
71
+ if (!fs.existsSync(dbPath)) {
72
+ throw new Error(`Database not found: ${dbPath}. Run "jsn docs sync" first.`);
73
+ }
74
+
75
+ const db = new DatabaseSync(dbPath, { readOnly: true });
76
+ const hasEmb = hasEmbeddings(db);
77
+ const hrrDim = (() => {
78
+ try {
79
+ const row = db.prepare("SELECT value FROM meta WHERE key='hrr_dim'").get();
80
+ return row ? parseInt(row.value, 10) : DEFAULT_DIM;
81
+ } catch {
82
+ return DEFAULT_DIM;
83
+ }
84
+ })();
85
+
86
+ const server = http.createServer((req, res) => {
87
+ try {
88
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
89
+ const pathname = decodeURIComponent(url.pathname);
90
+
91
+ if (pathname === '/health') {
92
+ return sendJSON(res, 200, { ok: true });
93
+ }
94
+
95
+ if (pathname === '/stats') {
96
+ const total = db.prepare('SELECT COUNT(*) AS n FROM docs').get().n;
97
+ return sendJSON(res, 200, { documents: total, db: path.resolve(dbPath), embeddings: hasEmb, hrr_dim: hrrDim });
98
+ }
99
+
100
+ if (pathname === '/bundles') {
101
+ const rows = db
102
+ .prepare('SELECT bundle, COUNT(*) AS count FROM docs WHERE bundle IS NOT NULL GROUP BY bundle ORDER BY count DESC')
103
+ .all();
104
+ return sendJSON(res, 200, rows);
105
+ }
106
+
107
+ if (pathname === '/search') {
108
+ const q = (url.searchParams.get('q') || '').trim();
109
+ if (!q) return sendJSON(res, 400, { error: "Missing query parameter 'q'." });
110
+
111
+ const limit = Math.min(parseInt(url.searchParams.get('limit'), 10) || 20, 100);
112
+ const offset = parseInt(url.searchParams.get('offset'), 10) || 0;
113
+ const bundle = url.searchParams.get('bundle') || null;
114
+ const docType = url.searchParams.get('doc_type') || null;
115
+ let mode = (url.searchParams.get('mode') || (hasEmb ? 'hybrid' : 'keyword')).toString();
116
+ if (mode === 'hybrid' && !hasEmb) mode = 'keyword';
117
+
118
+ const filters = [];
119
+ const params = { q, limit, offset };
120
+ if (bundle) {
121
+ filters.push('d.bundle = @bundle');
122
+ params.bundle = bundle;
123
+ }
124
+ if (docType) {
125
+ filters.push('d.doc_type = @docType');
126
+ params.docType = docType;
127
+ }
128
+ const where = filters.length ? ' AND ' + filters.join(' AND ') : '';
129
+
130
+ try {
131
+ if (mode === 'keyword') {
132
+ const rows = db
133
+ .prepare(
134
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release,
135
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
136
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
137
+ bm25(docs_fts) AS score
138
+ FROM docs_fts
139
+ JOIN docs d ON d.id = docs_fts.rowid
140
+ WHERE docs_fts MATCH @q${where}
141
+ ORDER BY score
142
+ LIMIT @limit OFFSET @offset`
143
+ )
144
+ .all(params);
145
+ return sendJSON(res, 200, { query: q, mode, count: rows.length, results: rows });
146
+ }
147
+
148
+ const candParams = { q, cand: (limit + offset) * 4 + 20 };
149
+ if (bundle) candParams.bundle = bundle;
150
+ if (docType) candParams.docType = docType;
151
+ const candidates = db
152
+ .prepare(
153
+ `SELECT d.id, d.path, d.title, d.bundle, d.doc_type, d.release, d.hrr_vector,
154
+ json_extract(d.frontmatter, '$.canonical_url') AS canonical_url,
155
+ snippet(docs_fts, 1, '[', ']', ' … ', 12) AS snippet,
156
+ bm25(docs_fts) AS bm25
157
+ FROM docs_fts
158
+ JOIN docs d ON d.id = docs_fts.rowid
159
+ WHERE docs_fts MATCH @q${where}
160
+ ORDER BY bm25
161
+ LIMIT @cand`
162
+ )
163
+ .all(candParams);
164
+
165
+ if (candidates.length === 0) {
166
+ return sendJSON(res, 200, { query: q, mode, count: 0, results: [] });
167
+ }
168
+
169
+ const queryVec = encodeText(q, hrrDim);
170
+ const queryTokens = new Set(q.toLowerCase().split(/\s+/).filter(Boolean));
171
+
172
+ const bmVals = candidates.map((c) => c.bm25);
173
+ const bmMin = Math.min(...bmVals);
174
+ const bmMax = Math.max(...bmVals);
175
+ const bmRange = bmMax - bmMin || 1;
176
+
177
+ const FTS_W = 0.4, JACCARD_W = 0.3, HRR_W = 0.3;
178
+
179
+ for (const c of candidates) {
180
+ const ftsScore = 1 - (c.bm25 - bmMin) / bmRange;
181
+ const contentTokens = new Set(
182
+ `${c.title || ''} ${c.path || ''}`.toLowerCase().split(/\s+/).filter(Boolean)
183
+ );
184
+ let inter = 0;
185
+ for (const t of queryTokens) if (contentTokens.has(t)) inter++;
186
+ const union = queryTokens.size + contentTokens.size - inter;
187
+ const jaccard = union ? inter / union : 0;
188
+
189
+ let hrrSim = 0.5;
190
+ if (c.hrr_vector) {
191
+ hrrSim = (similarity(queryVec, bytesToPhases(c.hrr_vector)) + 1) / 2;
192
+ }
193
+
194
+ c.score = FTS_W * ftsScore + JACCARD_W * jaccard + HRR_W * hrrSim;
195
+ delete c.hrr_vector;
196
+ delete c.bm25;
197
+ }
198
+
199
+ candidates.sort((a, b) => b.score - a.score);
200
+ const results = candidates.slice(offset, offset + limit);
201
+ return sendJSON(res, 200, { query: q, mode, count: results.length, results });
202
+ } catch (err) {
203
+ return sendJSON(res, 400, { error: `Invalid FTS query: ${err.message}` });
204
+ }
205
+ }
206
+
207
+ if (pathname.startsWith('/doc/')) {
208
+ const id = pathname.slice(5);
209
+ if (!id) return sendJSON(res, 400, { error: "Missing 'id'." });
210
+ const row = db.prepare('SELECT * FROM docs WHERE id = ?').get(id);
211
+ if (!row) return sendJSON(res, 404, { error: 'Not found' });
212
+ row.frontmatter = safeParse(row.frontmatter);
213
+ return sendJSON(res, 200, row);
214
+ }
215
+
216
+ if (pathname === '/doc') {
217
+ const p = url.searchParams.get('path') || '';
218
+ if (!p) return sendJSON(res, 400, { error: "Missing 'path'." });
219
+ const row = db.prepare('SELECT * FROM docs WHERE path = ?').get(p);
220
+ if (!row) return sendJSON(res, 404, { error: 'Not found' });
221
+ row.frontmatter = safeParse(row.frontmatter);
222
+ return sendJSON(res, 200, row);
223
+ }
224
+
225
+ // Static files.
226
+ let filePath = path.join(PUBLIC_DIR, pathname === '/' ? 'index.html' : pathname);
227
+ // Security: prevent escaping public dir.
228
+ filePath = path.resolve(filePath);
229
+ const publicResolved = path.resolve(PUBLIC_DIR);
230
+ if (!filePath.startsWith(publicResolved + path.sep) && filePath !== publicResolved) {
231
+ return sendText(res, 403, 'Forbidden');
232
+ }
233
+ return serveStatic(req, res, filePath);
234
+ } catch (err) {
235
+ sendJSON(res, 500, { error: err.message });
236
+ }
237
+ });
238
+
239
+ return new Promise((resolve, reject) => {
240
+ let currentPort = parseInt(port, 10);
241
+ const maxAttempts = 10;
242
+ let attempts = 0;
243
+ let resolved = false;
244
+
245
+ function onListening() {
246
+ if (resolved) return;
247
+ resolved = true;
248
+ server.removeListener('error', onError);
249
+ process.stderr.write(`ServiceNow docs search listening on http://${host}:${currentPort}\n`);
250
+ resolve({ server, port: currentPort, host, dbPath });
251
+ }
252
+
253
+ function onError(err) {
254
+ if (resolved) return;
255
+ server.removeListener('listening', onListening);
256
+ if (err.code === 'EADDRINUSE' && attempts < maxAttempts) {
257
+ process.stderr.write(`Port ${currentPort} in use, trying ${currentPort + 1}...\n`);
258
+ currentPort++;
259
+ tryListen();
260
+ return;
261
+ }
262
+ if (err.code === 'EADDRINUSE') {
263
+ err.message = `Could not find an open port between ${port} and ${currentPort}. Try specifying one with --port.`;
264
+ }
265
+ resolved = true;
266
+ reject(err);
267
+ }
268
+
269
+ function tryListen() {
270
+ attempts++;
271
+ server.once('error', onError);
272
+ server.once('listening', onListening);
273
+ server.listen(currentPort, host);
274
+ }
275
+
276
+ tryListen();
277
+ });
278
+ }
@@ -0,0 +1,75 @@
1
+ // Clone or update the ServiceNow docs repo locally.
2
+
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { getDocsSourceDir, getDocsSourceMarkdownDir, getDocsDbPath, docsDbExists } from './db.js';
7
+ import { ingestDocs } from './ingest.js';
8
+ import { refreshDocs } from './refresh.js';
9
+
10
+ const REPO_URL = 'https://github.com/ServiceNow/ServiceNowDocs';
11
+ const BRANCH = 'australia';
12
+
13
+ function git(args, cwd, stdio = 'inherit') {
14
+ const r = spawnSync('git', args, { cwd, stdio });
15
+ if (r.status !== 0) {
16
+ throw new Error(`git ${args[0]} failed (exit ${r.status}).`);
17
+ }
18
+ }
19
+
20
+ export function syncDocs(opts = {}) {
21
+ const repoDir = opts.repoDir || getDocsSourceDir();
22
+ const markdownDir = opts.markdownDir || getDocsSourceMarkdownDir();
23
+ const dbPath = opts.dbPath || getDocsDbPath();
24
+ const embed = opts.embed !== false;
25
+ const noIngest = opts.noIngest === true;
26
+
27
+ const initialStatus = {
28
+ clone: !fs.existsSync(path.join(repoDir, '.git')),
29
+ docsApprox: 45000,
30
+ willEmbed: embed && noIngest === false,
31
+ minutesHint: embed ? 3 : 1,
32
+ };
33
+
34
+ process.stderr.write('Note: docs sync downloads ~45k markdown files and builds an FTS5 + HRR index. ');
35
+ process.stderr.write(`This normally takes ${initialStatus.minutesHint}-${initialStatus.minutesHint + 2} minutes.\n`);
36
+ if (initialStatus.willEmbed) {
37
+ process.stderr.write('Pass --no-embed to skip semantic embeddings and finish faster.\n');
38
+ }
39
+ process.stderr.write('\n');
40
+
41
+ if (!initialStatus.clone) {
42
+ process.stderr.write(`Updating existing clone at ${repoDir} ...\n`);
43
+ git(['-C', repoDir, 'pull', '--ff-only'], repoDir, 'inherit');
44
+ } else {
45
+ fs.mkdirSync(path.dirname(repoDir), { recursive: true });
46
+ process.stderr.write(`Cloning ${REPO_URL} (branch ${BRANCH}) into ${repoDir} ...\n`);
47
+ git([
48
+ 'clone',
49
+ '--depth', '1',
50
+ '--branch', BRANCH,
51
+ '--config', 'core.longpaths=true',
52
+ REPO_URL,
53
+ repoDir,
54
+ ], undefined, 'inherit');
55
+ }
56
+
57
+ if (!fs.existsSync(markdownDir)) {
58
+ throw new Error(`Expected markdown folder not found at ${markdownDir}.`);
59
+ }
60
+
61
+ if (noIngest) {
62
+ return { repoDir, markdownDir, dbPath, ingested: false, note: 'Run without --no-ingest to build the index.' };
63
+ }
64
+
65
+ // If the DB already exists, use incremental refresh. Otherwise, full ingest.
66
+ if (docsDbExists()) {
67
+ process.stderr.write(`Docs DB found — running incremental refresh...\n`);
68
+ const result = refreshDocs({ docsDir: markdownDir, dbPath });
69
+ return { repoDir, markdownDir, dbPath, ingested: true, incremental: true, ...result };
70
+ }
71
+
72
+ process.stderr.write('No existing DB — running full ingest...\n');
73
+ const result = ingestDocs({ docsDir: markdownDir, dbPath, embed });
74
+ return { repoDir, markdownDir, dbPath, ingested: true, incremental: false, ...result };
75
+ }
@@ -0,0 +1,13 @@
1
+ // Suppresses the node:sqlite ExperimentalWarning on Node 22.x.
2
+ // Loaded via NODE_OPTIONS=--require before the main ESM entry point.
3
+
4
+ const original = process.emitWarning;
5
+ process.emitWarning = function emitWarning(warning, ...args) {
6
+ const message = typeof warning === 'string'
7
+ ? warning
8
+ : (warning?.message || '');
9
+ if (message.includes('SQLite')) {
10
+ return;
11
+ }
12
+ return original.apply(this, [warning, ...args]);
13
+ };