@adityanair98/api-oracle 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@ An MCP server that gives Claude Code the ability to find, evaluate, and recommen
4
4
 
5
5
  When a developer asks *"what should I use to send emails?"* — API Oracle returns a structured recommendation with working code, honest gotchas, pricing details, and everything needed to get started immediately.
6
6
 
7
- [![CI](https://github.com/adityanair/api-oracle/actions/workflows/ci.yml/badge.svg)](https://github.com/adityanair/api-oracle/actions/workflows/ci.yml)
7
+ [![CI](https://github.com/adityanair98/api-oracle/actions/workflows/ci.yml/badge.svg)](https://github.com/adityanair98/api-oracle/actions/workflows/ci.yml)
8
8
 
9
9
  ---
10
10
 
@@ -49,35 +49,57 @@ When a developer asks *"what should I use to send emails?"* — API Oracle retur
49
49
 
50
50
  ## Quick Start
51
51
 
52
- ### Option A — npx (no install)
52
+ API Oracle runs as a local MCP server over stdio. There's no setup step. The 68 bundled entries are loaded into an in-memory database when the server starts, and nothing is written to your working directory.
53
+
54
+ ### Claude Code
55
+
56
+ ```bash
57
+ claude mcp add api-oracle -- npx -y @adityanair98/api-oracle serve
58
+ ```
59
+
60
+ This adds the server to the current project (local scope). To make it available in every project, use `--scope user`:
53
61
 
54
62
  ```bash
55
- npx @adityanair98/api-oracle serve
63
+ claude mcp add --scope user api-oracle -- npx -y @adityanair98/api-oracle serve
56
64
  ```
57
65
 
58
- ### Option B — global install
66
+ To check that it's registered, run `claude mcp list`, or `/mcp` inside Claude Code.
67
+
68
+ On native Windows (not WSL), wrap `npx` with `cmd /c`:
59
69
 
60
70
  ```bash
61
- npm install -g @adityanair98/api-oracle
62
- api-oracle serve
71
+ claude mcp add api-oracle -- cmd /c npx -y @adityanair98/api-oracle serve
63
72
  ```
64
73
 
65
- ### Connect to Claude Code
74
+ ### Claude Desktop
66
75
 
67
- Add to `~/.claude/claude_desktop_config.json` under `mcpServers`:
76
+ Claude Desktop reads its MCP servers from `claude_desktop_config.json`. (Claude Code doesn't use this file.)
77
+
78
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
79
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
80
+
81
+ Add the server under `mcpServers`, then restart Claude Desktop:
68
82
 
69
83
  ```json
70
84
  {
71
85
  "mcpServers": {
72
86
  "api-oracle": {
73
87
  "command": "npx",
74
- "args": ["@adityanair98/api-oracle", "serve"]
88
+ "args": ["-y", "@adityanair98/api-oracle", "serve"]
75
89
  }
76
90
  }
77
91
  }
78
92
  ```
79
93
 
80
- Restart Claude Code. The `api-oracle` server will appear in your MCP servers list.
94
+ ### Run it directly
95
+
96
+ ```bash
97
+ npx -y @adityanair98/api-oracle serve # MCP server on stdio (what the clients above launch)
98
+ npx -y @adityanair98/api-oracle dashboard # web dashboard at http://localhost:3737
99
+ npx -y @adityanair98/api-oracle --help
100
+ ```
101
+
102
+ Or install it globally with `npm install -g @adityanair98/api-oracle`, then run `api-oracle serve`.
81
103
 
82
104
  ---
83
105
 
@@ -105,18 +127,19 @@ Dashboard features:
105
127
  ### 1. Clone and install
106
128
 
107
129
  ```bash
108
- git clone https://github.com/adityanair/api-oracle.git
130
+ git clone https://github.com/adityanair98/api-oracle.git
109
131
  cd api-oracle
110
132
  npm install
111
133
  ```
112
134
 
113
- ### 2. Build and seed
135
+ ### 2. Build
114
136
 
115
137
  ```bash
116
138
  npm run build
117
- npm run seed
118
139
  ```
119
140
 
141
+ No seeding step is needed: entries are loaded from `src/entries/` at startup.
142
+
120
143
  ### 3. Run
121
144
 
122
145
  ```bash
@@ -131,10 +154,11 @@ node dist/cli.js dashboard
131
154
 
132
155
  ```bash
133
156
  npm run dev # Run MCP server with tsx (no build needed)
134
- npm test # Run all tests (160 tests)
157
+ npm test # Run all tests (228 tests)
135
158
  npm run validate # Validate all JSON entries + cross-references
136
159
  npm run lint-entries # Content quality linter (0 errors, 15 warnings)
137
160
  npm run e2e # End-to-end query scenarios (43 assertions)
161
+ npm run eval # Retrieval accuracy on the dev and held-out query sets
138
162
  npm run dashboard # Start dashboard with tsx
139
163
  ```
140
164
 
@@ -144,8 +168,8 @@ npm run dashboard # Start dashboard with tsx
144
168
 
145
169
  | Variable | Default | Description |
146
170
  |----------|---------|-------------|
147
- | `DB_PATH` | `./data/api-oracle.db` | SQLite database path |
148
- | `PORT` | `3737` | Dashboard server port |
171
+ | `DB_PATH` | _(unset)_: in-memory | Optional SQLite file. If it's unset, the bundled entries are loaded into memory at startup. If it's set, they're upserted into that file and other rows are kept. |
172
+ | `PORT` | `3737` | Dashboard server port (`0` picks any free port; the banner shows the one in use) |
149
173
  | `LOG_LEVEL` | `info` | Log level: debug \| info \| warn \| error |
150
174
 
151
175
  ---
@@ -155,7 +179,7 @@ npm run dashboard # Start dashboard with tsx
155
179
  1. Create `src/entries/<category>/<slug>.json`
156
180
  2. Fill in all schema fields (see `docs/adding-apis.md`)
157
181
  3. `npm run validate` — verify schema and cross-references
158
- 4. `npm run seed` — load into database
182
+ 4. Restart the server. Entries are loaded at startup.
159
183
  5. `npm test` — run tests
160
184
 
161
185
  See [docs/adding-apis.md](docs/adding-apis.md) for the full guide and JSON template.
@@ -177,10 +201,12 @@ src/
177
201
  ├── knowledge/ # Core logic
178
202
  │ ├── schema.ts # Zod schema + TypeScript types
179
203
  │ ├── db.ts # SQLite CRUD (better-sqlite3)
204
+ │ ├── loader.ts # Loads + validates bundled entries at startup
205
+ │ ├── relevance.ts # Query relevance: IDF-weighted coverage, phrases
180
206
  │ ├── search.ts # Search pipeline orchestration
181
- │ ├── scorer.ts # Weighted multi-factor ranking engine
207
+ │ ├── scorer.ts # Relevance-gated ranking (relevance × prior)
182
208
  │ ├── synonyms.ts # Query expansion + category detection
183
- │ └── tfidf.ts # TF-IDF in-memory search index
209
+ │ └── tfidf.ts # TF-IDF index (candidate pre-selection for large KBs)
184
210
  ├── updater/ # Freshness and quality system
185
211
  │ ├── staleness.ts # Staleness level detection
186
212
  │ ├── report.ts # Freshness report builder
@@ -199,16 +225,42 @@ src/
199
225
 
200
226
  ## How Search Works
201
227
 
202
- 1. **Phrase detection** — extracts `preferFree`, `preferOpenSource`, etc. from natural language
203
- 2. **Synonym expansion** — "crashes" → error tracking; "log in" → auth; "billing" → payments
204
- 3. **Category detection** — maps query to a category with confidence score
205
- 4. **TF-IDF candidate selection** — field-weighted index pre-ranks all 68 entries
206
- 5. **Multi-factor scoring** — useCaseFit (40%) + quality (25%) + devExperience (20%) + pricingFit (15%)
207
- 6. **Category boost** — entries in the detected category receive up to 12% score boost
208
- 7. **Confidence** — top result gets a 0–1 confidence score
228
+ Relevance gates the score. An API's quality, developer experience, pricing and freshness can adjust its rank among relevant APIs, but they can't make an unrelated API show up.
229
+
230
+ 1. **Relevance.** This is the IDF-weighted share of your request's words that an entry covers. It checks the entry's name, subcategory, `bestFor` and use cases, with the description counting less. Rare words like "transcription" count more than common ones like "users". Synonyms count partially, and word order counts too, so "speech to text" and "text to speech" give different results.
231
+ 2. **Category fit.** The request is mapped to a category (email, payments, …). APIs in unrelated categories are down-weighted.
232
+ 3. **Prior.** A weighted mean of quality score, developer experience, pricing fit and recency. It scales a score between 0.75× and 1.0× of its relevance: `score = relevance × (0.75 + 0.25 × prior)`. If you name an API ("firebase", "send emails with resend"), a small keyword term ranks that API above entries that merely mention it.
233
+ 4. **Gate.** Candidates below a minimum relevance, below 60% of the best candidate's relevance, or in a clearly unrelated category are dropped.
234
+ 5. **Response.** `find_best_api` returns the top result with quick-start code, up to 2 alternatives, and `candidates`: up to 5 relevant APIs with key facts and relevance scores, so the calling model can make the final choice. `totalEvaluated` is the number of entries scored.
209
235
 
210
236
  See [docs/scoring-logic.md](docs/scoring-logic.md) for full details.
211
237
 
238
+ ## Accuracy
239
+
240
+ `npm run eval` measures retrieval on two query sets in `tests/eval/`. **dev** (26 queries) is the tuning set. **holdout** (42 queries) was written before the scorer was changed and is never tuned on.
241
+
242
+ | Scorer | Set | Top-1 | Top-3 | Must-not violations |
243
+ |--------|-----|-------|-------|---------------------|
244
+ | v0.5.0 | dev | 80.8% | 96.2% | 38.1% |
245
+ | v0.5.0 | holdout | **38.1%** | 71.4% | 31.0% |
246
+ | v0.6.0 | dev | 96.2% | 100% | 0.0% |
247
+ | v0.6.0 | holdout | **92.9%** | 95.2% | 2.4% |
248
+
249
+ "Must-not violations" is the share of queries with a must-not list (21 of 26 dev queries, all 42 holdout queries) where a clearly wrong API appeared in the top 3. Earlier versions of this project reported 20/20 queries passing. That was measured on the same queries the weights were tuned on, and the held-out top-1 for that scorer was 38.1%.
250
+
251
+ Caveats: the holdout labels were written from the entries' own descriptions and use cases, which is also what the scorer matches on. Real-world phrasing is likely to score lower. With 42 queries, each one is worth about 2.4 percentage points. Full per-query results are in [`tests/eval/results/`](tests/eval/results/).
252
+
253
+ ## Data freshness
254
+
255
+ Every entry has a `lastVerified` date: the last time someone checked its pricing, limits and SDK details against the provider's own docs. Dates are only changed when an entry is actually re-verified. Nothing updates them automatically.
256
+
257
+ - `find_best_api` returns `lastVerified` for every API it lists. For the recommendation it also returns `freshness` (`lastVerified`, `daysSinceVerified`, `level`), plus a `freshnessNote` when the entry isn't fresh.
258
+ - Levels: **fresh** < 90 days, **aging** 90–180, **stale** 180–365, **critical** > 365.
259
+ - Recency is a small part of the ranking prior (full credit within 30 days, falling to zero at 365 days).
260
+ - `check_api_freshness` (MCP tool), `npm run refresh-report` and the dashboard list entries that need re-verification.
261
+
262
+ As of v0.6.0, all 68 entries were last verified on 2026-02-25, so they report as **stale**. Check pricing and limits on the provider's site before relying on them.
263
+
212
264
  ---
213
265
 
214
266
  ## License
package/dist/cli.js CHANGED
@@ -9,11 +9,12 @@
9
9
  * api-oracle --version — Print version
10
10
  */
11
11
  import { createRequire } from "module";
12
- import { initDb } from "./knowledge/db.js";
13
- import { config } from "./utils/config.js";
12
+ import { bootstrapKnowledgeBase } from "./knowledge/loader.js";
14
13
  const require = createRequire(import.meta.url);
15
14
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-require-imports
16
15
  const pkg = require("../package.json");
16
+ /** The one-line Claude Code setup command (package name from package.json) */
17
+ const CLAUDE_CODE_CMD = `claude mcp add api-oracle -- npx -y ${pkg.name} serve`;
17
18
  const HELP = `
18
19
  API Oracle — MCP server for API discovery and recommendations
19
20
 
@@ -29,15 +30,27 @@ const HELP = `
29
30
  --version Show version
30
31
 
31
32
  Environment variables:
32
- DB_PATH Path to SQLite database (default: ./data/api-oracle.db)
33
- PORT Dashboard port (default: 3737)
33
+ DB_PATH Optional SQLite file to use. By default the bundled entries
34
+ are loaded into an in-memory database at startup and no
35
+ file is written. If set, bundled entries are upserted into it.
36
+ PORT Dashboard port (default: 3737; 0 picks any free port)
34
37
  LOG_LEVEL Log level: debug | info | warn | error (default: info)
35
38
 
36
- Example Claude Code configuration (~/.claude/claude_desktop_config.json):
39
+ Add to Claude Code:
40
+ ${CLAUDE_CODE_CMD}
41
+ For every project, add --scope user right after "claude mcp add".
42
+ On native Windows (not WSL), put "cmd /c" before npx.
43
+ Check it with: claude mcp list
44
+
45
+ Add to Claude Desktop — edit claude_desktop_config.json
46
+ (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json,
47
+ Windows: %APPDATA%\\Claude\\claude_desktop_config.json):
37
48
  {
38
- "api-oracle": {
39
- "command": "npx",
40
- "args": ["@adityanair98/api-oracle", "serve"]
49
+ "mcpServers": {
50
+ "api-oracle": {
51
+ "command": "npx",
52
+ "args": ["-y", "${pkg.name}", "serve"]
53
+ }
41
54
  }
42
55
  }
43
56
  `.trimStart();
@@ -53,14 +66,14 @@ async function main() {
53
66
  process.exit(0);
54
67
  }
55
68
  if (command === "dashboard") {
56
- initDb(config.dbPath);
69
+ bootstrapKnowledgeBase();
57
70
  const { startDashboard } = await import("./dashboard/server.js");
58
71
  startDashboard();
59
72
  return;
60
73
  }
61
74
  if (command === "serve" || command === "") {
62
- // Default: MCP server on stdio
63
- initDb(config.dbPath);
75
+ // Default: MCP server on stdio. Load the bundled entries before serving.
76
+ bootstrapKnowledgeBase();
64
77
  const { startServer } = await import("./server.js");
65
78
  await startServer();
66
79
  return;
@@ -216,7 +216,7 @@ apiRouter.post("/entries/:slug/verify", (req, res) => {
216
216
  slug,
217
217
  lastVerified: today,
218
218
  staleness,
219
- note: `To persist: update lastVerified in src/entries/**/${slug}.json and run npm run seed`,
219
+ note: `To persist: update lastVerified in src/entries/**/${slug}.json and restart the server (entries are loaded at startup)`,
220
220
  });
221
221
  }
222
222
  catch (e) {
@@ -6,4 +6,10 @@
6
6
  *
7
7
  * Exports: startDashboard
8
8
  */
9
- export declare function startDashboard(): void;
9
+ import type { Server } from "http";
10
+ /**
11
+ * Port from PORT (default 3737). 0 asks the OS for a free port; the banner
12
+ * prints the port actually bound. Returns null for an invalid value.
13
+ */
14
+ export declare function resolveDashboardPort(raw?: string | undefined): number | null;
15
+ export declare function startDashboard(): Server | null;
@@ -9,14 +9,33 @@
9
9
  import express from "express";
10
10
  import { join, dirname } from "path";
11
11
  import { fileURLToPath } from "url";
12
- import { initDb } from "../knowledge/db.js";
12
+ import { getAllEntries } from "../knowledge/db.js";
13
+ import { bootstrapKnowledgeBase } from "../knowledge/loader.js";
13
14
  import { config } from "../utils/config.js";
14
15
  import { createLogger } from "../utils/logger.js";
15
16
  import { apiRouter } from "./routes/api.js";
16
17
  const logger = createLogger("dashboard");
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
- const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3737;
19
+ const DEFAULT_PORT = 3737;
20
+ /**
21
+ * Port from PORT (default 3737). 0 asks the OS for a free port; the banner
22
+ * prints the port actually bound. Returns null for an invalid value.
23
+ */
24
+ export function resolveDashboardPort(raw = process.env.PORT) {
25
+ if (raw === undefined || raw.trim() === "")
26
+ return DEFAULT_PORT;
27
+ if (!/^\d+$/.test(raw.trim()))
28
+ return null;
29
+ const port = Number(raw.trim());
30
+ return port <= 65535 ? port : null;
31
+ }
19
32
  export function startDashboard() {
33
+ const port = resolveDashboardPort();
34
+ if (port === null) {
35
+ logger.error(`Invalid PORT "${process.env.PORT ?? ""}": use a number from 0 to 65535.`);
36
+ process.exitCode = 1;
37
+ return null;
38
+ }
20
39
  const app = express();
21
40
  // Parse JSON bodies
22
41
  app.use(express.json());
@@ -25,21 +44,40 @@ export function startDashboard() {
25
44
  app.use(express.static(publicDir));
26
45
  // Mount REST API
27
46
  app.use("/api", apiRouter);
28
- // Catch-all: serve index.html for client-side routing
29
- app.get("*", (_req, res) => {
30
- res.sendFile(join(publicDir, "index.html"));
47
+ // Catch-all: serve index.html for client-side routing.
48
+ // Express 5 (path-to-regexp v8) requires a named wildcard — a bare "*" throws at startup.
49
+ app.get("/{*splat}", (_req, res) => {
50
+ // Resolve relative to `root`: with an absolute path, send() refuses any
51
+ // dot-directory in it (npx installs under ~/.npm/_npx/…) and returns 404.
52
+ res.sendFile("index.html", { root: publicDir });
31
53
  });
32
- app.listen(PORT, () => {
54
+ const entries = getAllEntries();
55
+ const categoryCount = new Set(entries.map((e) => e.category)).size;
56
+ // Express 5 passes listen errors (e.g. EADDRINUSE) to this callback instead
57
+ // of throwing. Ignoring them printed the "Local:" banner and then exited 0.
58
+ const server = app.listen(port, (err) => {
59
+ if (err) {
60
+ const code = err.code;
61
+ const hint = code === "EADDRINUSE"
62
+ ? ` Port ${port} is already in use. Stop the other process or set PORT, e.g. PORT=3738 api-oracle dashboard.`
63
+ : "";
64
+ logger.error(`Dashboard could not start: ${err.message}.${hint}`);
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ const address = server.address();
69
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
33
70
  process.stdout.write(`\n API Oracle Dashboard\n`);
34
71
  process.stdout.write(` ───────────────────────────────\n`);
35
- process.stdout.write(` Local: http://localhost:${PORT}\n`);
36
- process.stdout.write(` Entries: 68 APIs across 23 categories\n\n`);
72
+ process.stdout.write(` Local: http://localhost:${boundPort}\n`);
73
+ process.stdout.write(` Entries: ${entries.length} APIs across ${categoryCount} categories\n\n`);
37
74
  });
75
+ return server;
38
76
  }
39
77
  // When run directly (npm run dashboard)
40
78
  const isMain = process.argv[1] === fileURLToPath(import.meta.url);
41
79
  if (isMain) {
42
- logger.info("Initializing database for dashboard", { dbPath: config.dbPath });
43
- initDb(config.dbPath);
80
+ logger.info("Loading knowledge base for dashboard", { dbPath: config.dbPath });
81
+ bootstrapKnowledgeBase();
44
82
  startDashboard();
45
83
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Entry point — initializes the SQLite database and starts the MCP server.
2
+ * Entry point — loads the bundled knowledge base and starts the MCP server.
3
3
  * Run with: npm run dev (via tsx) or node dist/index.js (compiled).
4
4
  */
5
5
  export {};
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Entry point — initializes the SQLite database and starts the MCP server.
2
+ * Entry point — loads the bundled knowledge base and starts the MCP server.
3
3
  * Run with: npm run dev (via tsx) or node dist/index.js (compiled).
4
4
  */
5
- import { initDb } from "./knowledge/db.js";
5
+ import { bootstrapKnowledgeBase } from "./knowledge/loader.js";
6
6
  import { startServer } from "./server.js";
7
7
  import { config } from "./utils/config.js";
8
8
  import { logger } from "./utils/logger.js";
@@ -11,9 +11,8 @@ async function main() {
11
11
  dbPath: config.dbPath,
12
12
  nodeEnv: config.nodeEnv,
13
13
  });
14
- // Initialize SQLite database (creates tables if they don't exist)
15
- initDb(config.dbPath);
16
- logger.info("Database initialized");
14
+ // Open the database and load the bundled entries (in-memory unless DB_PATH is set)
15
+ bootstrapKnowledgeBase();
17
16
  // Start the MCP server on stdio
18
17
  await startServer();
19
18
  }
@@ -8,12 +8,22 @@
8
8
  */
9
9
  import Database from "better-sqlite3";
10
10
  import { type ApiEntry } from "./schema.js";
11
+ /**
12
+ * Open the database. Defaults to an in-memory database — callers that want
13
+ * data must load it (see bootstrapKnowledgeBase in loader.ts).
14
+ */
11
15
  export declare function initDb(dbPath?: string): void;
12
16
  export declare function getDb(): Database.Database;
13
17
  export declare function closeDb(): void;
14
18
  /** Allows tests to inject their own in-memory database instance */
15
19
  export declare function _setDbForTesting(testDb: Database.Database): void;
16
20
  export declare function insertEntry(entry: ApiEntry): void;
21
+ /**
22
+ * Insert or replace many entries in one transaction (quiet: one log line).
23
+ * Entries are re-validated; returns the number written.
24
+ */
25
+ export declare function upsertEntries(entries: ApiEntry[]): number;
26
+ export declare function countEntries(): number;
17
27
  export declare function getBySlug(slug: string): ApiEntry | null;
18
28
  export declare function getByCategory(category: string): ApiEntry[];
19
29
  export declare function searchByText(query: string): ApiEntry[];
@@ -83,14 +83,20 @@ function deserializeRow(row) {
83
83
  return result.data;
84
84
  }
85
85
  // ─── Public API ───────────────────────────────────────────────────────────────
86
- export function initDb(dbPath = "./data/api-oracle.db") {
86
+ /**
87
+ * Open the database. Defaults to an in-memory database — callers that want
88
+ * data must load it (see bootstrapKnowledgeBase in loader.ts).
89
+ */
90
+ export function initDb(dbPath = ":memory:") {
87
91
  if (db)
88
92
  return; // already initialized
89
93
  if (dbPath !== ":memory:") {
90
94
  mkdirSync(dirname(dbPath), { recursive: true });
91
95
  }
92
96
  db = new Database(dbPath);
93
- db.pragma("journal_mode = WAL");
97
+ if (dbPath !== ":memory:") {
98
+ db.pragma("journal_mode = WAL");
99
+ }
94
100
  db.pragma("foreign_keys = ON");
95
101
  db.exec(CREATE_TABLE_SQL);
96
102
  db.exec(CREATE_CATEGORY_INDEX_SQL);
@@ -129,6 +135,36 @@ export function insertEntry(entry) {
129
135
  stmt.run(row);
130
136
  logger.info("Inserted entry", { slug: entry.slug });
131
137
  }
138
+ /**
139
+ * Insert or replace many entries in one transaction (quiet: one log line).
140
+ * Entries are re-validated; returns the number written.
141
+ */
142
+ export function upsertEntries(entries) {
143
+ const database = getDb();
144
+ const rows = entries.map((entry) => {
145
+ const validation = validateEntry(entry);
146
+ if (!validation.success) {
147
+ throw new Error(`Invalid entry ${entry.slug}: ${validation.errors.join("; ")}`);
148
+ }
149
+ return serializeEntry(validation.data);
150
+ });
151
+ if (rows.length === 0)
152
+ return 0;
153
+ const columns = Object.keys(rows[0]);
154
+ const placeholders = columns.map((c) => `@${c}`).join(", ");
155
+ const stmt = database.prepare(`INSERT OR REPLACE INTO apis (${columns.join(", ")}) VALUES (${placeholders})`);
156
+ const writeAll = database.transaction((batch) => {
157
+ for (const row of batch)
158
+ stmt.run(row);
159
+ });
160
+ writeAll(rows);
161
+ logger.debug("Upserted entries", { count: rows.length });
162
+ return rows.length;
163
+ }
164
+ export function countEntries() {
165
+ const row = getDb().prepare("SELECT COUNT(*) AS n FROM apis").get();
166
+ return row.n;
167
+ }
132
168
  export function getBySlug(slug) {
133
169
  const database = getDb();
134
170
  const row = database
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Knowledge-base loader — reads the bundled JSON entries that ship with the
3
+ * package (src/entries/<category>/<slug>.json), validates each one, and loads
4
+ * them into the SQLite database at startup.
5
+ *
6
+ * The entries directory is resolved relative to this compiled module
7
+ * (import.meta.url), never the process cwd, so running the package via npx
8
+ * works from any folder.
9
+ *
10
+ * Exports: resolveEntriesDir, loadEntriesFromDir, bootstrapKnowledgeBase,
11
+ * LoadResult, SkippedEntry, BootstrapResult
12
+ */
13
+ import { type ApiEntry } from "./schema.js";
14
+ export interface SkippedEntry {
15
+ file: string;
16
+ reason: string;
17
+ }
18
+ export interface LoadResult {
19
+ dir: string;
20
+ entries: ApiEntry[];
21
+ skipped: SkippedEntry[];
22
+ }
23
+ export interface BootstrapResult {
24
+ dbPath: string;
25
+ entriesDir: string;
26
+ loaded: number;
27
+ skipped: SkippedEntry[];
28
+ totalInDb: number;
29
+ }
30
+ /**
31
+ * Absolute path of the bundled entries directory.
32
+ *
33
+ * This module lives two levels below the package root in both layouts:
34
+ * <pkg>/dist/knowledge/loader.js (compiled, what npm users run)
35
+ * <pkg>/src/knowledge/loader.ts (tsx, local development)
36
+ * and package.json "files" ships the entries at <pkg>/src/entries.
37
+ */
38
+ export declare function resolveEntriesDir(): string;
39
+ /**
40
+ * Read and validate every JSON entry under `dir`.
41
+ * Invalid JSON, schema failures and duplicate slugs are skipped (and reported),
42
+ * never thrown — one bad entry must not take the server down.
43
+ */
44
+ export declare function loadEntriesFromDir(dir: string): LoadResult;
45
+ /**
46
+ * Open the database and load the bundled entries into it.
47
+ *
48
+ * - Default (DB_PATH unset): an in-memory database, rebuilt on every start, so
49
+ * no file is ever written to the user's working directory.
50
+ * - DB_PATH set explicitly: that file is opened and the bundled entries are
51
+ * upserted into it (bundled data wins for matching slugs; other rows are kept).
52
+ */
53
+ export declare function bootstrapKnowledgeBase(options?: {
54
+ dbPath?: string;
55
+ entriesDir?: string;
56
+ }): BootstrapResult;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Knowledge-base loader — reads the bundled JSON entries that ship with the
3
+ * package (src/entries/<category>/<slug>.json), validates each one, and loads
4
+ * them into the SQLite database at startup.
5
+ *
6
+ * The entries directory is resolved relative to this compiled module
7
+ * (import.meta.url), never the process cwd, so running the package via npx
8
+ * works from any folder.
9
+ *
10
+ * Exports: resolveEntriesDir, loadEntriesFromDir, bootstrapKnowledgeBase,
11
+ * LoadResult, SkippedEntry, BootstrapResult
12
+ */
13
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
14
+ import { join, relative } from "path";
15
+ import { fileURLToPath } from "url";
16
+ import { initDb, upsertEntries, countEntries } from "./db.js";
17
+ import { validateEntry } from "./schema.js";
18
+ import { resetTfIdfEngine } from "./tfidf.js";
19
+ import { config } from "../utils/config.js";
20
+ import { createLogger } from "../utils/logger.js";
21
+ const logger = createLogger("loader");
22
+ /**
23
+ * Absolute path of the bundled entries directory.
24
+ *
25
+ * This module lives two levels below the package root in both layouts:
26
+ * <pkg>/dist/knowledge/loader.js (compiled, what npm users run)
27
+ * <pkg>/src/knowledge/loader.ts (tsx, local development)
28
+ * and package.json "files" ships the entries at <pkg>/src/entries.
29
+ */
30
+ export function resolveEntriesDir() {
31
+ return fileURLToPath(new URL("../../src/entries/", import.meta.url));
32
+ }
33
+ function findJsonFiles(dir) {
34
+ const results = [];
35
+ for (const name of readdirSync(dir).sort()) {
36
+ const full = join(dir, name);
37
+ if (statSync(full).isDirectory()) {
38
+ results.push(...findJsonFiles(full));
39
+ }
40
+ else if (name.endsWith(".json")) {
41
+ results.push(full);
42
+ }
43
+ }
44
+ return results;
45
+ }
46
+ /**
47
+ * Read and validate every JSON entry under `dir`.
48
+ * Invalid JSON, schema failures and duplicate slugs are skipped (and reported),
49
+ * never thrown — one bad entry must not take the server down.
50
+ */
51
+ export function loadEntriesFromDir(dir) {
52
+ if (!existsSync(dir)) {
53
+ throw new Error(`Entries directory not found: ${dir}`);
54
+ }
55
+ const entries = [];
56
+ const skipped = [];
57
+ const seen = new Map();
58
+ for (const file of findJsonFiles(dir)) {
59
+ const rel = relative(dir, file);
60
+ let raw;
61
+ try {
62
+ raw = JSON.parse(readFileSync(file, "utf-8"));
63
+ }
64
+ catch (e) {
65
+ skipped.push({ file: rel, reason: `invalid JSON: ${String(e)}` });
66
+ continue;
67
+ }
68
+ const validation = validateEntry(raw);
69
+ if (!validation.success) {
70
+ skipped.push({ file: rel, reason: `schema: ${validation.errors.slice(0, 3).join("; ")}` });
71
+ continue;
72
+ }
73
+ const entry = validation.data;
74
+ const firstFile = seen.get(entry.slug);
75
+ if (firstFile) {
76
+ skipped.push({ file: rel, reason: `duplicate slug "${entry.slug}" (already loaded from ${firstFile})` });
77
+ continue;
78
+ }
79
+ seen.set(entry.slug, rel);
80
+ entries.push(entry);
81
+ }
82
+ return { dir, entries, skipped };
83
+ }
84
+ /**
85
+ * Open the database and load the bundled entries into it.
86
+ *
87
+ * - Default (DB_PATH unset): an in-memory database, rebuilt on every start, so
88
+ * no file is ever written to the user's working directory.
89
+ * - DB_PATH set explicitly: that file is opened and the bundled entries are
90
+ * upserted into it (bundled data wins for matching slugs; other rows are kept).
91
+ */
92
+ export function bootstrapKnowledgeBase(options = {}) {
93
+ const dbPath = options.dbPath ?? config.dbPath;
94
+ const entriesDir = options.entriesDir ?? resolveEntriesDir();
95
+ initDb(dbPath);
96
+ const { entries, skipped } = loadEntriesFromDir(entriesDir);
97
+ for (const s of skipped) {
98
+ logger.warn("Skipped invalid entry", s);
99
+ }
100
+ const loaded = upsertEntries(entries);
101
+ // The TF-IDF index is built lazily from the DB — force a rebuild.
102
+ resetTfIdfEngine();
103
+ const totalInDb = countEntries();
104
+ logger.info("Knowledge base loaded", {
105
+ loaded,
106
+ skipped: skipped.length,
107
+ totalInDb,
108
+ dbPath,
109
+ });
110
+ if (totalInDb === 0) {
111
+ logger.error("Knowledge base is empty — find_best_api will return no results", {
112
+ entriesDir,
113
+ });
114
+ }
115
+ return { dbPath, entriesDir, loaded, skipped, totalInDb };
116
+ }