compend 0.0.1 → 1.0.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/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Changelog
2
+
3
+ All notable changes to Compend will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.0] - 2026-06-27
9
+
10
+ ### Added
11
+ - Initial release
12
+ - 5 MCP tools: `compend_index`, `compend_deindex`, `compend_search`, `compend_get`, `compend_list`
13
+ - `compend_index` mirrors filesystem into index — accepts file path, directory path, or no args (all configured paths)
14
+ - `compend_deindex` removes concepts by slug or path prefix (files never touched)
15
+ - `compend_search` with hybrid FTS5 + vec0 weighted scoring (MurmurHash3 256-dim, alpha 0-1)
16
+ - `compend_get` returns full concept: frontmatter JSON, markdown body, children (references), and dependencies
17
+ - `compend_list` browse with type, tag, and status filters + pagination
18
+ - Dashboard on port 3457: read-only, dark/light theme, SSE real-time updates, type/tag/status filters, rendered markdown viewer
19
+ - CLI: `compend` (start dashboard), `compend stop`, `compend restart`
20
+ - 7 extensible concept types: skill, agent, instruction, prompt, workflow, reference, knowledge
21
+ - Type schemas in config.js with per-type status validation (matching Hemisphere's kind schema pattern)
22
+ - OKF frontmatter parsing (YAML + markdown body) with automatic type inference
23
+ - Auto-index on first run from opencode.json `skills.paths` + `instructions`
24
+ - Parent/child concept resolution via slug hierarchy (file path convention)
25
+ - Dependency resolution from OKF frontmatter `dependencies` field
26
+ - `~/.compend/config.json` config with env var overrides (`COMPEND_PORT`, `COMPEND_DB_PATH`)
27
+ - Index paths from opencode.json auto-discovery + `~/.compend/config.json` overrides
28
+
29
+ ## [0.0.1] - 2026-06-27
30
+
31
+ ### Added
32
+ - Placeholder release to secure npm namespace
33
+ - Logo SVG (48x48 book icon, currentColor)
34
+ - GPL-3.0-only license
package/README.md CHANGED
@@ -2,8 +2,383 @@
2
2
 
3
3
  # Compend
4
4
 
5
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen)](https://nodejs.org)
6
+
7
+ **Author:** [Hector Jarquin](https://hectorjarquin.com)
8
+
5
9
  A reference engine for AI agents. Index your Markdown skills, prompts, and knowledge bases — and serve them as just-in-time grounding with hybrid search and dynamic context. No retraining. No keys. No Python.
6
10
 
7
- ## Coming soon
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ npm install -g compend
15
+ ```
16
+
17
+ Once installed, run the dashboard:
18
+
19
+ ```bash
20
+ compend
21
+ # → http://localhost:3457
22
+ ```
23
+
24
+ ### CLI commands
25
+
26
+ ```bash
27
+ compend # Start the dashboard
28
+ compend stop # Stop a running instance
29
+ compend restart # Stop then restart
30
+ ```
31
+
32
+ ### MCP server
33
+
34
+ Add to `opencode.json`:
35
+
36
+ ```json
37
+ {
38
+ "mcp": {
39
+ "compend": {
40
+ "type": "local",
41
+ "command": ["node", "/usr/lib/node_modules/compend/index.js"],
42
+ "enabled": true
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Find your exact path with `npm root -g` — append `/compend/index.js`. For nvm users the path is typically `~/.nvm/versions/node/vX/lib/node_modules/compend/index.js`.
49
+
50
+ ### Dashboard
51
+
52
+ Browse, search and read knowledge concepts visually at `http://localhost:3457`.
53
+
54
+ Port configurable via `~/.compend/config.json` or `COMPEND_PORT`.
55
+
56
+ **Features:** dark/light theme toggle with SVG icon, WCAG 2.1 AA accessibility (keyboard navigation, ARIA labels, focus-visible outlines), real-time SSE updates (no polling), toast notifications, skeleton loading, search with debounce, type, tag, and status filters, expandable detail rows with rendered markdown body.
57
+
58
+ ## Installation
59
+
60
+ ### Prerequisites
61
+
62
+ - Node.js 18+
63
+ - C++ build tools (`build-essential` on Debian/Ubuntu, Xcode CLI tools on macOS) — required to compile `better-sqlite3`
64
+
65
+ ### From npm (recommended)
66
+
67
+ ```bash
68
+ npm install -g compend
69
+ ```
70
+
71
+ The `compend` CLI is now available globally. The MCP server runs via your AI client — see [Configuration](#configuration).
72
+
73
+ ### From git
74
+
75
+ ```bash
76
+ git clone https://github.com/hectorjarquin/compend.git ~/compend
77
+ cd ~/compend
78
+ npm install
79
+ npm link # creates global compend command
80
+ ```
81
+
82
+ ### Configuration
83
+
84
+ ```json
85
+ {
86
+ "mcp": {
87
+ "compend": {
88
+ "type": "local",
89
+ "command": ["node", "/usr/lib/node_modules/compend/index.js"],
90
+ "enabled": true
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ Find your exact path with `npm root -g` — append `/compend/index.js`.
97
+
98
+ Concepts are discovered automatically from your opencode.json `skills.paths` and `instructions` directories. Add custom project knowledge bundles via `~/.compend/config.json`.
99
+
100
+ ## Updating
101
+
102
+ ### npm
103
+
104
+ ```bash
105
+ npm install -g compend@latest
106
+ ```
107
+
108
+ Then restart your MCP client to pick up the new tools, and run `compend restart` to refresh the dashboard.
109
+
110
+ Database migrations run automatically on first launch — no manual steps required.
111
+
112
+ ### From git
113
+
114
+ ```bash
115
+ cd ~/compend
116
+ git pull
117
+ npm install
118
+ npm link
119
+ compend restart
120
+ ```
121
+
122
+ ## MCP Tools (Agent-Facing)
123
+
124
+ ### `compend_index`
125
+
126
+ Mirror the filesystem source of truth into the index. No args scans all configured paths. Pass `{ path }` to index a single `.md` file or a directory (recursively). Uses SHA-256 hash diffing — unchanged files are skipped. Files missing from disk are removed from the index.
127
+
128
+ | Parameter | Type | Required | Default | Description |
129
+ |-----------|------|----------|---------|-------------|
130
+ | `path` | string | no | — | Absolute path to a `.md` file or directory to index. Omit to scan all configured paths. |
131
+
132
+ Returns `{ added: [...slugs], updated: [...slugs], removed: [...slugs], total: N }`.
133
+
134
+ ### `compend_deindex`
135
+
136
+ Remove concepts from the index. Pass `{ slug }` to remove one concept, or `{ path }` to remove all concepts under a directory path. Files on disk are never touched — deindex is index-only. If the file still exists, the next `compend_index` will re-index it.
137
+
138
+ | Parameter | Type | Required | Default | Description |
139
+ |-----------|------|----------|---------|-------------|
140
+ | `slug` | string | no | — | Concept slug to remove |
141
+ | `path` | string | no | — | Directory or file path — all concepts whose `file_path` starts with this are removed |
142
+
143
+ Returns `{ removed: [...slugs], total: N }`.
144
+
145
+ ### `compend_search`
146
+
147
+ Hybrid FTS + vector search across indexed concepts. Returns metadata with snippet and relevance score.
148
+
149
+ | Parameter | Type | Required | Default | Description |
150
+ |-----------|------|----------|---------|-------------|
151
+ | `query` | string | yes | — | Search query text |
152
+ | `type` | string | no | — | Filter by concept type (skill, agent, instruction, etc.) |
153
+ | `tags` | string[] | no | — | Filter by tags (AND match) |
154
+ | `limit` | number | no | `10` | Max results |
155
+ | `alpha` | number | no | `0.3` | Vector weight. `0` = FTS-only, `1` = vector-only |
156
+
157
+ Returns concepts sorted by relevance (score 0–1) with `id`, `slug`, `type`, `title`, `description`, `tags`, `status`, `source`, `score`, and `snippet`.
158
+
159
+ ### `compend_get`
160
+
161
+ Retrieve a full concept by slug. Includes frontmatter JSON, markdown body, child references (concepts whose slug starts with `{slug}/`), and dependencies (from the OKF `dependencies` frontmatter field).
162
+
163
+ | Parameter | Type | Required | Default | Description |
164
+ |-----------|------|----------|---------|-------------|
165
+ | `slug` | string | yes | — | Concept slug (e.g. `"wp-image-to-blocks"`) |
166
+
167
+ Returns `{ id, slug, type, title, description, tags, status, frontmatter, body, references: [...], dependencies: [...] }`.
168
+
169
+ ### `compend_list`
170
+
171
+ List concepts with optional filters. Returns compact metadata — no body.
172
+
173
+ | Parameter | Type | Required | Default | Description |
174
+ |-----------|------|----------|---------|-------------|
175
+ | `type` | string | no | — | Filter by concept type |
176
+ | `tags` | string[] | no | — | Filter by tags (AND match) |
177
+ | `status` | string | no | — | Filter by status (stable, draft, deprecated) |
178
+ | `limit` | number | no | `50` | Max results |
179
+ | `offset` | number | no | `0` | Pagination offset |
180
+
181
+ Returns `{ concepts: [...], total, limit, offset }`.
182
+
183
+ ## HTTP API (Dashboard-Facing)
184
+
185
+ The dashboard exposes REST endpoints:
186
+
187
+ | Method | Path | Description |
188
+ |--------|------|-------------|
189
+ | `GET` | `/api/stats` | Get total concept count and counts by type |
190
+ | `GET` | `/api/tags?type=` | List tags with concept counts, optional type filter |
191
+ | `GET` | `/api/concepts?type=&status=&tags=&search=&limit=&offset=` | Paginated concept list. `search=` triggers FTS+vector. |
192
+ | `GET` | `/api/concepts/{slug}` | Full concept including frontmatter, body, references, and dependencies |
193
+ | `POST` | `/api/notify` | SSE event relay from MCP server (internal — called by `notifyDash()`) |
194
+ | `GET` | `/api/events` | SSE (Server-Sent Events) stream for real-time dashboard updates |
195
+
196
+ ## Concept Types
197
+
198
+ Seven types are built into the default schema, extensible via `~/.compend/config.json`:
199
+
200
+ | Type | Statuses | Default | Description |
201
+ |------|----------|---------|-------------|
202
+ | `skill` | `stable`, `draft`, `deprecated` | `stable` | Agent skill definition (SKILL.md) |
203
+ | `agent` | `stable`, `draft`, `deprecated` | `stable` | Agent/subagent definition |
204
+ | `instruction` | `stable`, `draft`, `deprecated` | `stable` | Instruction files injected into system prompt |
205
+ | `prompt` | `stable`, `draft`, `deprecated` | `stable` | Prompt templates |
206
+ | `workflow` | `stable`, `draft`, `deprecated` | `stable` | Multi-step pipeline definitions |
207
+ | `reference` | — (no validation) | — | Reference documents, examples, templates |
208
+ | `knowledge` | — (no validation) | — | Project domain knowledge (OKF bundles) |
209
+
210
+ Type is inferred from OKF frontmatter, file path, or directory location. Unknown types pass through without validation — existing data is never affected. Custom types can be added via `~/.compend/config.json`:
211
+
212
+ ```json
213
+ {
214
+ "schemas": {
215
+ "default": {
216
+ "types": {
217
+ "template": { "statuses": ["draft","published","retired"], "defaults": { "status": "draft" } }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ ```
223
+
224
+ ## Configuration
225
+
226
+ ### Config File (`~/.compend/config.json`)
227
+
228
+ Create an optional JSON config file to customize operational settings. All keys are optional — missing keys use the code defaults.
229
+
230
+ **Priority chain** (highest wins): code defaults < config file < environment variables.
231
+
232
+ ```json
233
+ {
234
+ "port": 3457,
235
+ "dbPath": "~/.compend/concepts.db",
236
+ "search": {
237
+ "limit": 10,
238
+ "alpha": 0.3
239
+ },
240
+ "dashboard": {
241
+ "paginationLimit": 50,
242
+ "maxLimit": 200
243
+ },
244
+ "schemas": {
245
+ "default": {
246
+ "types": {}
247
+ }
248
+ },
249
+ "index": {
250
+ "paths": []
251
+ }
252
+ }
253
+ ```
254
+
255
+ | Key | Type | Default | Description |
256
+ |-----|------|---------|-------------|
257
+ | `port` | number | `3457` | Dashboard HTTP server port |
258
+ | `dbPath` | string | `~/.compend/concepts.db` | SQLite database file path (supports `~`) |
259
+ | `search.limit` | number | `10` | Default search result count |
260
+ | `search.alpha` | number | `0.3` | Vector weight in hybrid search (0–1) |
261
+ | `dashboard.paginationLimit` | number | `50` | Default page size for dashboard API |
262
+ | `dashboard.maxLimit` | number | `200` | Hard cap on API page size |
263
+ | `schemas.default.types` | object | built-in set | Type definitions with `statuses` and `defaults` |
264
+ | `index.paths` | string[] | `[]` | Additional paths to scan for `.md` files (appended to opencode auto-discovered paths) |
265
+
266
+ ### Environment Variables
267
+
268
+ | Variable | Equivalent Config Key | Default |
269
+ |----------|----------------------|---------|
270
+ | `COMPEND_PORT` | `port` | `3457` |
271
+ | `COMPEND_DB_PATH` | `dbPath` | `~/.compend/concepts.db` |
272
+
273
+ ### Path Discovery
274
+
275
+ Compend auto-discovers filesystem paths from your opencode.json:
276
+
277
+ 1. `skills.paths` — skill directories (scanned recursively for `*.md`)
278
+ 2. `instructions` — instruction files (indexed directly)
279
+
280
+ Additional paths can be added via `index.paths` in `~/.compend/config.json`. This is where you add project OKF knowledge bundles:
281
+
282
+ ```json
283
+ {
284
+ "index": {
285
+ "paths": ["/home/user/projects/my-project/knowledge"]
286
+ }
287
+ }
288
+ ```
289
+
290
+ ## Architecture
291
+
292
+ ### Embedding
293
+
294
+ MurmurHash3 (32-bit x86) applied to word unigrams and bigrams, accumulated into a 256-bin `Float32Array`, then L2-normalized. Runs in ~2µs per text with no external dependencies. Collisions are inherent to feature hashing and don't materially affect retrieval quality at this dimension count.
295
+
296
+ ### Hybrid Search
297
+
298
+ Three indexing layers work together:
299
+
300
+ 1. **FTS5** — SQLite full-text search with BM25 ranking (`unicode61` tokenizer)
301
+ 2. **Vector** — `sqlite-vec` virtual table with 256-dim float vectors, cosine distance
302
+ 3. **Merge** — Weighted combination: `score = alpha * vec + (1 - alpha) * fts`. Both sides are normalized to 0–1 before merging.
303
+
304
+ ### Database
305
+
306
+ - WAL journal mode for concurrent reads, `busy_timeout=5000ms` for write-contention safety across processes
307
+ - `concepts` table (slug, type, title, description, tags, status, frontmatter, body, file_path, file_hash, source, created_at, updated_at, last_synced_at)
308
+ - `concepts_fts` — FTS5 external content table on title, description, tags, body
309
+ - `concepts_vec` — `vec0` table with `float[256]`
310
+
311
+ ### Index vs Source of Truth
312
+
313
+ Compend does not own content. It mirrors a source of truth — the local filesystem or a remote CMS (`compend_sync` in v2). There is no CRUD lifecycle (no create/edit/delete of concepts from the index). The source is always authoritative. The index is a mirror — always rebuildable from source.
314
+
315
+ `compend_index` uses SHA-256 hash diffing: unchanged files are skipped, changed files are updated, missing files are removed from the index. `compend_deindex` removes concepts without touching files — the next index run restores them.
316
+
317
+ ### SSE Real-Time Updates
318
+
319
+ The dashboard uses Server-Sent Events for live updates — no polling. Events flow through a three-hop chain:
320
+
321
+ MCP server (index.js) → `notifyDash()` → dashboard `/api/notify` → `broadcast()` → SSE clients → `app.js` listeners
322
+
323
+ | Event | Source | Frontend Behavior |
324
+ |---|---|---|
325
+ | `index_complete` | `compend_index`, `compend_deindex` | Toast with count summary + reload list |
326
+
327
+ If the SSE connection drops, a 30-second fallback poll resumes.
328
+
329
+ ## Project Structure
330
+
331
+ ```
332
+ compend/
333
+ ├── index.js MCP stdio server (5 tool handlers)
334
+ ├── dashboard.js HTTP dashboard + SSE broadcast server
335
+ ├── dashboard/
336
+ │ ├── api-handler.js Dashboard REST API routes
337
+ │ └── public/
338
+ │ ├── index.html Dashboard HTML + ARIA structure
339
+ │ ├── style.css Full theme (dark/light), toast, skeleton
340
+ │ ├── app.js Frontend SSE client, keyboard nav, WCAG 2.1 AA
341
+ │ └── logo.svg Book icon (48x48, currentColor)
342
+ ├── db.js SQLite init, CRUD, hybrid FTS+vec search
343
+ ├── embedding.js MurmurHash3 → 256-dim float vector
344
+ ├── config.js Config loader (defaults ← config.json ← env vars)
345
+ ├── package.json
346
+ ├── CHANGELOG.md
347
+ └── README.md
348
+ ```
349
+
350
+ ## Development
351
+
352
+ ### Scripts
353
+
354
+ For local development (after `git clone`):
355
+
356
+ ```bash
357
+ npm start # Start dashboard server (same as compend)
358
+ npm run stop # Stop running instance (same as compend stop)
359
+ npm run restart # Stop then start
360
+ ```
361
+
362
+ For installed users, the global `compend` CLI handles these — see [Quick Start](#quick-start).
363
+
364
+ ### Testing the MCP server
365
+
366
+ Test with the MCP Inspector:
367
+
368
+ ```bash
369
+ npx @modelcontextprotocol/inspector node index.js
370
+ ```
371
+
372
+ ## Roadmap
373
+
374
+ - **Remote sync** — `compend_sync` for REST-based reconciliation with distributed teams
375
+ - **Import / export** — portable OKF bundle archives across Compend instances
376
+ - **Auth / access control** — API keys for multi-client deployments
377
+
378
+ ## Contributing
379
+
380
+ Open an issue or PR at [github.com/hectorjarquin/compend](https://github.com/hectorjarquin/compend).
381
+
382
+ ## License
8
383
 
9
- Compend is an MCP server that indexes OKF-formatted knowledge documents skills, agents, instructions, prompts, workflows, and references — with FTS + vector search and REST-based reconciliation for distributed teams.
384
+ GNU General Public License v3.0 or latersee [LICENSE](LICENSE) for details.
package/config.js ADDED
@@ -0,0 +1,147 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, dirname } from 'node:path';
3
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
4
+
5
+ const DEFAULTS = {
6
+ port: 3457,
7
+ dbPath: join(homedir(), '.compend', 'concepts.db'),
8
+ search: {
9
+ limit: 10,
10
+ alpha: 0.3
11
+ },
12
+ dashboard: {
13
+ paginationLimit: 50,
14
+ maxLimit: 200
15
+ },
16
+ schemas: {
17
+ default: {
18
+ types: {
19
+ skill: { statuses: ['stable','draft','deprecated'], defaults: { status: 'stable' } },
20
+ agent: { statuses: ['stable','draft','deprecated'], defaults: { status: 'stable' } },
21
+ instruction: { statuses: ['stable','draft','deprecated'], defaults: { status: 'stable' } },
22
+ prompt: { statuses: ['stable','draft','deprecated'], defaults: { status: 'stable' } },
23
+ workflow: { statuses: ['stable','draft','deprecated'], defaults: { status: 'stable' } },
24
+ reference: { statuses: [], defaults: {} },
25
+ knowledge: { statuses: [], defaults: {} }
26
+ }
27
+ }
28
+ }
29
+ };
30
+
31
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
32
+
33
+ function deepMerge(target, source) {
34
+ for (const key of Object.keys(source)) {
35
+ if (!Object.hasOwn(target, key)) {
36
+ target[key] = JSON.parse(JSON.stringify(source[key]));
37
+ continue;
38
+ }
39
+ const tv = target[key];
40
+ const sv = source[key];
41
+ if (isObject(tv) && isObject(sv)) {
42
+ deepMerge(tv, sv);
43
+ } else {
44
+ target[key] = sv;
45
+ }
46
+ }
47
+ return target;
48
+ }
49
+
50
+ function readConfigFile() {
51
+ try {
52
+ const filePath = join(homedir(), '.compend', 'config.json');
53
+ if (!existsSync(filePath)) return {};
54
+ const raw = readFileSync(filePath, 'utf-8').trim();
55
+ if (!raw) return {};
56
+ return JSON.parse(raw);
57
+ } catch (e) {
58
+ console.warn('Compend: config.json parse error — using defaults:', e.message);
59
+ return {};
60
+ }
61
+ }
62
+
63
+ function resolveTilde(p) {
64
+ if (typeof p === 'string' && p.startsWith('~')) {
65
+ return join(homedir(), p.slice(1));
66
+ }
67
+ return p;
68
+ }
69
+
70
+ function applyEnvOverrides(cfg) {
71
+ if (process.env.COMPEND_PORT && process.env.COMPEND_PORT.trim()) {
72
+ cfg.port = parseInt(process.env.COMPEND_PORT.trim(), 10) || cfg.port;
73
+ }
74
+ if (process.env.COMPEND_DB_PATH && process.env.COMPEND_DB_PATH.trim()) {
75
+ cfg.dbPath = resolveTilde(process.env.COMPEND_DB_PATH.trim());
76
+ }
77
+ }
78
+
79
+ let _cfg = null;
80
+
81
+ export function getConfig() {
82
+ if (_cfg) return _cfg;
83
+ const defaults = JSON.parse(JSON.stringify(DEFAULTS));
84
+ _cfg = deepMerge(defaults, readConfigFile());
85
+ applyEnvOverrides(_cfg);
86
+ _cfg.dbPath = resolveTilde(_cfg.dbPath);
87
+ return _cfg;
88
+ }
89
+
90
+ export function getDbPath() {
91
+ const path = getConfig().dbPath;
92
+ const dir = dirname(path);
93
+ if (!existsSync(dir)) {
94
+ mkdirSync(dir, { recursive: true });
95
+ }
96
+ return path;
97
+ }
98
+
99
+ export function resolveTypeSchema(type) {
100
+ const schemas = getConfig().schemas || {};
101
+ const projectSchema = schemas.default || {};
102
+ const types = projectSchema.types || {};
103
+ return types[type] || null;
104
+ }
105
+
106
+ export function getIndexPaths() {
107
+ const paths = [];
108
+
109
+ try {
110
+ const opencodePaths = [
111
+ join(homedir(), '.config', 'opencode', 'opencode.json'),
112
+ ];
113
+ for (const p of opencodePaths) {
114
+ if (existsSync(p)) {
115
+ const raw = readFileSync(p, 'utf-8').trim();
116
+ if (raw) {
117
+ const cfg = JSON.parse(raw);
118
+ if (cfg.skills && Array.isArray(cfg.skills.paths)) {
119
+ for (const sp of cfg.skills.paths) {
120
+ paths.push(resolveTilde(sp));
121
+ }
122
+ }
123
+ if (cfg.instructions && Array.isArray(cfg.instructions)) {
124
+ for (const ip of cfg.instructions) {
125
+ paths.push(resolveTilde(ip));
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ } catch (e) {
132
+ console.warn('Compend: could not read opencode.json:', e.message);
133
+ }
134
+
135
+ try {
136
+ const compendCfg = readConfigFile();
137
+ if (compendCfg.index && Array.isArray(compendCfg.index.paths)) {
138
+ for (const p of compendCfg.index.paths) {
139
+ paths.push(resolveTilde(p));
140
+ }
141
+ }
142
+ } catch (e) {
143
+ console.warn('Compend: could not read compend config paths:', e.message);
144
+ }
145
+
146
+ return paths;
147
+ }
@@ -0,0 +1,77 @@
1
+ import { searchHybrid, getConcept, listConcepts, getTags, getChanges, getDeps } from '../db.js';
2
+ import { getConfig } from '../config.js';
3
+
4
+ export function json(data, status = 200) {
5
+ return { status, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) };
6
+ }
7
+
8
+ export function err(msg, status = 500) {
9
+ return json({ error: msg }, status);
10
+ }
11
+
12
+ export function createApiHandler(db) {
13
+ return function handleApi(path, method, params) {
14
+ if (path === '/api/stats' && method === 'GET') {
15
+ const total = db.prepare('SELECT COUNT(*) as c FROM concepts').get().c;
16
+ const types = db.prepare('SELECT type, COUNT(*) as c FROM concepts GROUP BY type ORDER BY c DESC').all().map(r => ({ type: r.type, count: r.c }));
17
+ return json({ total, types });
18
+ }
19
+
20
+ if (path === '/api/tags' && method === 'GET') {
21
+ const type = params.get('type') || '';
22
+ const results = getTags(type || undefined);
23
+ return json({ tags: results });
24
+ }
25
+
26
+ if (path === '/api/concepts' && method === 'GET') {
27
+ const type = params.get('type') || '';
28
+ const status = params.get('status') || '';
29
+ const search = params.get('search') || '';
30
+ const tagsRaw = params.get('tags') || '';
31
+ const maxLimit = getConfig().dashboard.maxLimit;
32
+ const limit = Math.min(parseInt(params.get('limit') || String(getConfig().dashboard.paginationLimit), 10), maxLimit);
33
+ const offset = Math.max(parseInt(params.get('offset') || '0', 10), 0);
34
+
35
+ if (search.trim()) {
36
+ try {
37
+ const rows = searchHybrid({ query: search, type: type || undefined, limit, alpha: getConfig().search.alpha });
38
+ return json({ concepts: rows, total: rows.length, limit, offset });
39
+ } catch (e) {
40
+ return err('Search error', 400);
41
+ }
42
+ }
43
+
44
+ const result = listConcepts({
45
+ type: type || undefined,
46
+ status: status || undefined,
47
+ tags: tagsRaw ? tagsRaw.split(',').filter(Boolean) : undefined,
48
+ limit,
49
+ offset
50
+ });
51
+ return json(result);
52
+ }
53
+
54
+ const conceptMatch = path.match(/^\/api\/concepts\/(.+)$/);
55
+ if (conceptMatch && method === 'GET') {
56
+ const slug = decodeURIComponent(conceptMatch[1]);
57
+ const concept = getConcept(slug);
58
+ if (!concept) return err('Concept not found', 404);
59
+ return json(concept);
60
+ }
61
+
62
+ if (path === '/api/changes' && method === 'GET') {
63
+ const since = params.get('since') || '0';
64
+ const results = getChanges(parseInt(since, 10) || 0);
65
+ return json({ changes: results });
66
+ }
67
+
68
+ const depsMatch = path.match(/^\/api\/deps\/(.+)$/);
69
+ if (depsMatch && method === 'GET') {
70
+ const slug = decodeURIComponent(depsMatch[1]);
71
+ const results = getDeps(slug);
72
+ return json(results);
73
+ }
74
+
75
+ return null;
76
+ };
77
+ }