compend 0.0.1 → 1.1.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 +48 -0
- package/README.md +381 -2
- package/config.js +121 -0
- package/dashboard/api-handler.js +77 -0
- package/dashboard/public/app.js +338 -0
- package/dashboard/public/index.html +66 -0
- package/dashboard/public/logo.svg +1 -0
- package/dashboard/public/style.css +497 -0
- package/dashboard.js +203 -0
- package/db.js +569 -0
- package/embedding.js +81 -0
- package/index.js +179 -0
- package/package.json +19 -3
package/db.js
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { load } from 'sqlite-vec';
|
|
3
|
+
import { createEmbedding } from './embedding.js';
|
|
4
|
+
import { getDbPath, getConfig, resolveTypeSchema, getIndexPaths } from './config.js';
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
6
|
+
import { join, relative, resolve as resolvePath, sep } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
let db;
|
|
10
|
+
|
|
11
|
+
export function initDb() {
|
|
12
|
+
if (db) return db;
|
|
13
|
+
|
|
14
|
+
const path = getDbPath();
|
|
15
|
+
db = new Database(path);
|
|
16
|
+
db.pragma('journal_mode = WAL');
|
|
17
|
+
db.pragma('busy_timeout = 5000');
|
|
18
|
+
load(db);
|
|
19
|
+
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS concepts (
|
|
22
|
+
id INTEGER PRIMARY KEY,
|
|
23
|
+
slug TEXT NOT NULL UNIQUE,
|
|
24
|
+
type TEXT NOT NULL,
|
|
25
|
+
title TEXT,
|
|
26
|
+
description TEXT,
|
|
27
|
+
tags TEXT,
|
|
28
|
+
status TEXT DEFAULT 'stable',
|
|
29
|
+
frontmatter TEXT NOT NULL DEFAULT '{}',
|
|
30
|
+
body TEXT NOT NULL,
|
|
31
|
+
file_path TEXT,
|
|
32
|
+
file_hash TEXT,
|
|
33
|
+
source TEXT DEFAULT 'local',
|
|
34
|
+
timestamp TEXT,
|
|
35
|
+
resource TEXT,
|
|
36
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
37
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
38
|
+
last_synced_at INTEGER
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_concepts_type ON concepts(type);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_concepts_slug ON concepts(slug);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_concepts_status ON concepts(status);
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_concepts_source ON concepts(source);
|
|
45
|
+
|
|
46
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS concepts_fts USING fts5(
|
|
47
|
+
title, description, tags, body,
|
|
48
|
+
content=concepts,
|
|
49
|
+
content_rowid=id,
|
|
50
|
+
tokenize='unicode61'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS concepts_vec USING vec0(
|
|
54
|
+
embedding float[256]
|
|
55
|
+
);
|
|
56
|
+
`);
|
|
57
|
+
|
|
58
|
+
// Migrations
|
|
59
|
+
try { db.exec('ALTER TABLE concepts ADD COLUMN timestamp TEXT'); } catch {}
|
|
60
|
+
try { db.exec('ALTER TABLE concepts ADD COLUMN resource TEXT'); } catch {}
|
|
61
|
+
|
|
62
|
+
const needsAutoIndex = db.prepare('SELECT COUNT(*) as c FROM concepts').get().c === 0;
|
|
63
|
+
if (needsAutoIndex) {
|
|
64
|
+
autoIndex();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return db;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function autoIndex() {
|
|
71
|
+
const paths = getIndexPaths();
|
|
72
|
+
if (paths.length === 0) return;
|
|
73
|
+
try {
|
|
74
|
+
indexConcepts(paths);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.warn('Compend: auto-index failed:', e.message);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseFrontmatter(content) {
|
|
81
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
82
|
+
if (!match) return { frontmatter: {}, body: content };
|
|
83
|
+
try {
|
|
84
|
+
const frontmatter = {};
|
|
85
|
+
const lines = match[1].split('\n');
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
const colonIdx = line.indexOf(':');
|
|
88
|
+
if (colonIdx === -1) continue;
|
|
89
|
+
const key = line.slice(0, colonIdx).trim();
|
|
90
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
91
|
+
if (!key) continue;
|
|
92
|
+
let parsed = value;
|
|
93
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
94
|
+
parsed = value.slice(1, -1);
|
|
95
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
96
|
+
try { parsed = JSON.parse(value); } catch {
|
|
97
|
+
parsed = value.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
frontmatter[key] = parsed;
|
|
101
|
+
}
|
|
102
|
+
return { frontmatter, body: content.slice(match[0].length) };
|
|
103
|
+
} catch {
|
|
104
|
+
return { frontmatter: {}, body: content };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let _instructionPaths = null;
|
|
109
|
+
function getInstructionPaths() {
|
|
110
|
+
if (_instructionPaths) return _instructionPaths;
|
|
111
|
+
_instructionPaths = new Set();
|
|
112
|
+
try {
|
|
113
|
+
const paths = getIndexPaths();
|
|
114
|
+
for (const p of paths) {
|
|
115
|
+
if (p.endsWith('.md') && !p.includes('/skills/') && !p.includes('/editorial-skills/')) {
|
|
116
|
+
_instructionPaths.add(p);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {}
|
|
120
|
+
return _instructionPaths;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function inferType(pathStr, frontmatter) {
|
|
124
|
+
if (frontmatter.type) return frontmatter.type;
|
|
125
|
+
const lower = pathStr.toLowerCase();
|
|
126
|
+
if (lower.includes('/references/')) return 'reference';
|
|
127
|
+
if (lower.includes('/example/')) return 'reference';
|
|
128
|
+
if (lower.endsWith('skill.md')) return 'skill';
|
|
129
|
+
if (lower.includes('/skills/')) return 'skill';
|
|
130
|
+
if (getInstructionPaths().has(pathStr)) return 'instruction';
|
|
131
|
+
if (lower.includes('instruction')) return 'instruction';
|
|
132
|
+
if (lower.includes('agent')) return 'agent';
|
|
133
|
+
if (lower.includes('prompt')) return 'prompt';
|
|
134
|
+
if (lower.includes('workflow')) return 'workflow';
|
|
135
|
+
return 'reference';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function deriveSlug(filePath, scanPaths) {
|
|
139
|
+
for (const scanPath of scanPaths) {
|
|
140
|
+
if (filePath.startsWith(scanPath + sep) || filePath.startsWith(scanPath)) {
|
|
141
|
+
let rel = relative(scanPath, filePath);
|
|
142
|
+
if (!rel || rel === '.' || rel === '') {
|
|
143
|
+
rel = filePath.split(sep).pop();
|
|
144
|
+
}
|
|
145
|
+
const hasDir = rel.includes(sep) || rel.includes('/');
|
|
146
|
+
rel = rel.replace(/\.md$/i, '');
|
|
147
|
+
rel = rel.replace(/\/SKILL$/i, '');
|
|
148
|
+
if (!hasDir) {
|
|
149
|
+
const normed = scanPath.replace(/\/+$/, '');
|
|
150
|
+
const segments = normed.split(sep).filter(Boolean);
|
|
151
|
+
let prefix = (segments[segments.length - 1] || '').replace(/\.md$/i, '');
|
|
152
|
+
if (prefix === 'skills' && segments.length > 1) {
|
|
153
|
+
prefix = segments[segments.length - 2] + '/' + prefix;
|
|
154
|
+
}
|
|
155
|
+
if (prefix && rel !== prefix) rel = prefix + '/' + rel;
|
|
156
|
+
}
|
|
157
|
+
return rel || filePath.split(sep).pop().replace(/\.md$/i, '');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const parts = filePath.split(sep);
|
|
161
|
+
const name = parts[parts.length - 1].replace(/\.md$/i, '');
|
|
162
|
+
return name;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function indexSingleFile(filePath, scanPaths) {
|
|
166
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
167
|
+
const hash = createHash('sha256').update(content).digest('hex');
|
|
168
|
+
const slug = deriveSlug(filePath, scanPaths);
|
|
169
|
+
|
|
170
|
+
const existing = db.prepare('SELECT id, file_hash FROM concepts WHERE file_path = ?').get(filePath);
|
|
171
|
+
if (existing && existing.file_hash === hash) {
|
|
172
|
+
return { slug, action: 'skipped' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
176
|
+
const type = inferType(filePath, frontmatter);
|
|
177
|
+
const schema = resolveTypeSchema(type);
|
|
178
|
+
let status = frontmatter.status || (schema ? schema.defaults.status : 'stable');
|
|
179
|
+
if (schema && schema.statuses && schema.statuses.length > 0 && !schema.statuses.includes(status)) {
|
|
180
|
+
status = schema.defaults.status;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const title = frontmatter.title || frontmatter.name || '';
|
|
184
|
+
const description = frontmatter.description || '';
|
|
185
|
+
const tags = JSON.stringify(frontmatter.tags || []);
|
|
186
|
+
const frontmatterJson = JSON.stringify(frontmatter);
|
|
187
|
+
const timestamp = frontmatter.timestamp || null;
|
|
188
|
+
const resource = frontmatter.resource || null;
|
|
189
|
+
|
|
190
|
+
const now = Math.floor(Date.now() / 1000);
|
|
191
|
+
const embedding = createEmbedding(body);
|
|
192
|
+
|
|
193
|
+
if (existing) {
|
|
194
|
+
db.prepare(`
|
|
195
|
+
UPDATE concepts SET type=?, title=?, description=?, tags=?, status=?, frontmatter=?, body=?,
|
|
196
|
+
file_hash=?, timestamp=?, resource=?, updated_at=?
|
|
197
|
+
WHERE id=?
|
|
198
|
+
`).run(type, title, description, tags, status, frontmatterJson, body, hash, timestamp, resource, now, existing.id);
|
|
199
|
+
|
|
200
|
+
db.prepare('DELETE FROM concepts_fts WHERE rowid = ?').run(existing.id);
|
|
201
|
+
db.prepare('INSERT INTO concepts_fts (rowid, title, description, tags, body) VALUES (?, ?, ?, ?, ?)')
|
|
202
|
+
.run(existing.id, title, description, tags, body);
|
|
203
|
+
|
|
204
|
+
db.prepare('DELETE FROM concepts_vec WHERE rowid = ?').run(existing.id);
|
|
205
|
+
db.prepare('INSERT INTO concepts_vec (rowid, embedding) VALUES (CAST(? AS INTEGER), ?)')
|
|
206
|
+
.run(existing.id, Buffer.from(embedding.buffer));
|
|
207
|
+
|
|
208
|
+
return { slug, action: 'updated' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const result = db.prepare(`
|
|
212
|
+
INSERT INTO concepts (slug, type, title, description, tags, status, frontmatter, body, file_path, file_hash, source, timestamp, resource, created_at, updated_at)
|
|
213
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', ?, ?, ?, ?)
|
|
214
|
+
`).run(slug, type, title, description, tags, status, frontmatterJson, body, filePath, hash, timestamp, resource, now, now);
|
|
215
|
+
|
|
216
|
+
const id = result.lastInsertRowid;
|
|
217
|
+
db.prepare('INSERT INTO concepts_fts (rowid, title, description, tags, body) VALUES (?, ?, ?, ?, ?)')
|
|
218
|
+
.run(id, title, description, tags, body);
|
|
219
|
+
db.prepare('INSERT INTO concepts_vec (rowid, embedding) VALUES (CAST(? AS INTEGER), ?)')
|
|
220
|
+
.run(id, Buffer.from(embedding.buffer));
|
|
221
|
+
|
|
222
|
+
return { slug, action: 'added' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function indexFile(absolutePath) {
|
|
226
|
+
initDb();
|
|
227
|
+
const added = [], updated = [], removed = [];
|
|
228
|
+
|
|
229
|
+
if (!existsSync(absolutePath)) {
|
|
230
|
+
const existing = db.prepare("SELECT id, slug FROM concepts WHERE file_path = ? AND source = 'local'").get(absolutePath);
|
|
231
|
+
if (existing) {
|
|
232
|
+
db.prepare('DELETE FROM concepts WHERE id = ?').run(existing.id);
|
|
233
|
+
db.prepare('DELETE FROM concepts_fts WHERE rowid = ?').run(existing.id);
|
|
234
|
+
db.prepare('DELETE FROM concepts_vec WHERE rowid = ?').run(existing.id);
|
|
235
|
+
removed.push(existing.slug);
|
|
236
|
+
}
|
|
237
|
+
return { added, updated, removed, total: db.prepare('SELECT COUNT(*) as c FROM concepts').get().c };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const st = statSync(absolutePath);
|
|
241
|
+
|
|
242
|
+
if (st.isDirectory()) {
|
|
243
|
+
const files = [];
|
|
244
|
+
walkDir(absolutePath, files);
|
|
245
|
+
const scanPaths = [absolutePath];
|
|
246
|
+
|
|
247
|
+
for (const filePath of files) {
|
|
248
|
+
const result = indexSingleFile(filePath, scanPaths);
|
|
249
|
+
if (result.action === 'added') added.push(result.slug);
|
|
250
|
+
if (result.action === 'updated') updated.push(result.slug);
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
const paths = getIndexPaths();
|
|
254
|
+
const result = indexSingleFile(absolutePath, paths);
|
|
255
|
+
if (result.action === 'added') added.push(result.slug);
|
|
256
|
+
if (result.action === 'updated') updated.push(result.slug);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { added, updated, removed, total: db.prepare('SELECT COUNT(*) as c FROM concepts').get().c };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function deindexConcepts({ slug, path } = {}) {
|
|
263
|
+
initDb();
|
|
264
|
+
const removed = [];
|
|
265
|
+
|
|
266
|
+
if (slug) {
|
|
267
|
+
const concept = db.prepare('SELECT id, slug FROM concepts WHERE slug = ?').get(slug);
|
|
268
|
+
if (concept) {
|
|
269
|
+
db.prepare('DELETE FROM concepts WHERE id = ?').run(concept.id);
|
|
270
|
+
db.prepare('DELETE FROM concepts_fts WHERE rowid = ?').run(concept.id);
|
|
271
|
+
db.prepare('DELETE FROM concepts_vec WHERE rowid = ?').run(concept.id);
|
|
272
|
+
removed.push(concept.slug);
|
|
273
|
+
}
|
|
274
|
+
} else if (path) {
|
|
275
|
+
const rows = db.prepare("SELECT id, slug FROM concepts WHERE file_path LIKE ? AND source = 'local'").all(path + '%');
|
|
276
|
+
for (const r of rows) {
|
|
277
|
+
db.prepare('DELETE FROM concepts WHERE id = ?').run(r.id);
|
|
278
|
+
db.prepare('DELETE FROM concepts_fts WHERE rowid = ?').run(r.id);
|
|
279
|
+
db.prepare('DELETE FROM concepts_vec WHERE rowid = ?').run(r.id);
|
|
280
|
+
removed.push(r.slug);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { removed, total: db.prepare('SELECT COUNT(*) as c FROM concepts').get().c };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const SKIP_FILES = new Set(['readme.md', 'changelog.md', 'contributing.md', 'license.md']);
|
|
288
|
+
|
|
289
|
+
function walkDir(dir, files = []) {
|
|
290
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
const full = join(dir, entry.name);
|
|
293
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
294
|
+
walkDir(full, files);
|
|
295
|
+
} else if (entry.isFile() && entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name.toLowerCase())) {
|
|
296
|
+
files.push(full);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return files;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function indexConcepts(scanPaths) {
|
|
303
|
+
initDb();
|
|
304
|
+
const paths = scanPaths || getIndexPaths();
|
|
305
|
+
const added = [], updated = [];
|
|
306
|
+
const currentFiles = new Set();
|
|
307
|
+
const allPaths = [];
|
|
308
|
+
|
|
309
|
+
for (const p of paths) {
|
|
310
|
+
if (!existsSync(p)) continue;
|
|
311
|
+
const st = statSync(p);
|
|
312
|
+
if (st.isDirectory()) {
|
|
313
|
+
walkDir(p, allPaths);
|
|
314
|
+
} else if (st.isFile() && p.endsWith('.md')) {
|
|
315
|
+
allPaths.push(p);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const filePath of allPaths) {
|
|
320
|
+
const result = indexSingleFile(filePath, paths);
|
|
321
|
+
currentFiles.add(filePath);
|
|
322
|
+
if (result.action === 'added') added.push(result.slug);
|
|
323
|
+
if (result.action === 'updated') updated.push(result.slug);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const removed = [];
|
|
327
|
+
const localConcepts = db.prepare('SELECT id, slug, file_path FROM concepts WHERE source = \'local\'').all();
|
|
328
|
+
for (const c of localConcepts) {
|
|
329
|
+
if (!currentFiles.has(c.file_path)) {
|
|
330
|
+
db.prepare('DELETE FROM concepts WHERE id = ?').run(c.id);
|
|
331
|
+
db.prepare('DELETE FROM concepts_fts WHERE rowid = ?').run(c.id);
|
|
332
|
+
db.prepare('DELETE FROM concepts_vec WHERE rowid = ?').run(c.id);
|
|
333
|
+
removed.push(c.slug);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { added, updated, removed, total: db.prepare('SELECT COUNT(*) as c FROM concepts').get().c };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function searchHybrid({ query, type, tags, limit, alpha } = {}) {
|
|
341
|
+
initDb();
|
|
342
|
+
const cfg = getConfig();
|
|
343
|
+
const searchLimit = limit || cfg.search.limit;
|
|
344
|
+
const alphaValue = alpha !== undefined ? alpha : cfg.search.alpha;
|
|
345
|
+
|
|
346
|
+
let vectorResults = [];
|
|
347
|
+
if (alphaValue > 0) {
|
|
348
|
+
const queryEmbedding = createEmbedding(query);
|
|
349
|
+
let vecSql = `SELECT c.id, vec_distance_cosine(v.embedding, ?) AS distance
|
|
350
|
+
FROM concepts c
|
|
351
|
+
JOIN concepts_vec v ON v.rowid = c.id
|
|
352
|
+
WHERE v.embedding MATCH ?`;
|
|
353
|
+
const vecParams = [Buffer.from(queryEmbedding.buffer), Buffer.from(queryEmbedding.buffer)];
|
|
354
|
+
|
|
355
|
+
if (type) { vecSql += ' AND c.type = ?'; vecParams.push(type); }
|
|
356
|
+
|
|
357
|
+
vecSql += ' ORDER BY distance LIMIT ?';
|
|
358
|
+
vecParams.push(Math.max(searchLimit * 3, 50));
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
vectorResults = db.prepare(vecSql).all(...vecParams);
|
|
362
|
+
} catch {}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let ftsResults = [];
|
|
366
|
+
const escapedQuery = query.replace(/"/g, '').replace(/'/g, '');
|
|
367
|
+
if (escapedQuery.trim()) {
|
|
368
|
+
let ftsSql = `SELECT c.id, c.slug, c.type, c.title, c.description, c.tags, c.status, c.source,
|
|
369
|
+
bm25(concepts_fts, 0) AS rank
|
|
370
|
+
FROM concepts c
|
|
371
|
+
JOIN concepts_fts f ON f.rowid = c.id
|
|
372
|
+
WHERE concepts_fts MATCH ?`;
|
|
373
|
+
const ftsParams = ['"' + escapedQuery.replace(/\s+/g, '" OR "') + '"'];
|
|
374
|
+
|
|
375
|
+
if (type) { ftsSql += ' AND c.type = ?'; ftsParams.push(type); }
|
|
376
|
+
ftsSql += ' ORDER BY rank LIMIT ?';
|
|
377
|
+
ftsParams.push(Math.max(searchLimit * 3, 50));
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
ftsResults = db.prepare(ftsSql).all(...ftsParams);
|
|
381
|
+
} catch {}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (ftsResults.length === 0 && vectorResults.length === 0) return [];
|
|
385
|
+
|
|
386
|
+
const scores = new Map();
|
|
387
|
+
|
|
388
|
+
if (ftsResults.length > 0) {
|
|
389
|
+
const maxRank = Math.max(...ftsResults.map(r => Math.abs(r.rank)), 1);
|
|
390
|
+
for (const r of ftsResults) {
|
|
391
|
+
const normalized = 1 - (Math.abs(r.rank) / maxRank);
|
|
392
|
+
scores.set(r.id, { id: r.id, slug: r.slug, type: r.type, title: r.title,
|
|
393
|
+
description: r.description, tags: r.tags, status: r.status, source: r.source, score: normalized });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (vectorResults.length > 0) {
|
|
398
|
+
const maxDist = Math.max(...vectorResults.map(r => r.distance), 1);
|
|
399
|
+
for (const r of vectorResults) {
|
|
400
|
+
const normalized = 1 - (r.distance / maxDist);
|
|
401
|
+
if (scores.has(r.id)) {
|
|
402
|
+
scores.get(r.id).score = (1 - alphaValue) * scores.get(r.id).score + alphaValue * normalized;
|
|
403
|
+
} else {
|
|
404
|
+
const concept = db.prepare('SELECT c.id, c.slug, c.type, c.title, c.description, c.tags, c.status, c.source FROM concepts c WHERE c.id = ?').get(r.id);
|
|
405
|
+
if (concept) {
|
|
406
|
+
scores.set(r.id, { ...concept, score: alphaValue * normalized });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (tags && tags.length > 0) {
|
|
413
|
+
for (const [id, entry] of scores) {
|
|
414
|
+
const conceptTags = typeof entry.tags === 'string' ? JSON.parse(entry.tags || '[]') : (entry.tags || []);
|
|
415
|
+
const hasAll = tags.every(t => conceptTags.includes(t));
|
|
416
|
+
if (!hasAll) scores.delete(id);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const sorted = Array.from(scores.values()).sort((a, b) => b.score - a.score).slice(0, searchLimit);
|
|
421
|
+
|
|
422
|
+
return sorted.map(r => {
|
|
423
|
+
let tagsParsed = typeof r.tags === 'string' ? JSON.parse(r.tags || '[]') : (r.tags || []);
|
|
424
|
+
let snippet = '';
|
|
425
|
+
if (r.description) {
|
|
426
|
+
snippet = r.description;
|
|
427
|
+
} else {
|
|
428
|
+
const raw = db.prepare('SELECT body FROM concepts WHERE id = ?').get(r.id);
|
|
429
|
+
if (raw) snippet = raw.body.slice(0, 200).replace(/\n/g, ' ');
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
id: r.id,
|
|
433
|
+
slug: r.slug,
|
|
434
|
+
type: r.type,
|
|
435
|
+
title: r.title,
|
|
436
|
+
description: r.description,
|
|
437
|
+
tags: tagsParsed,
|
|
438
|
+
status: r.status,
|
|
439
|
+
source: r.source,
|
|
440
|
+
score: Math.round(r.score * 100) / 100,
|
|
441
|
+
snippet
|
|
442
|
+
};
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function getConcept(slug) {
|
|
447
|
+
initDb();
|
|
448
|
+
const concept = db.prepare('SELECT * FROM concepts WHERE slug = ?').get(slug);
|
|
449
|
+
if (!concept) return null;
|
|
450
|
+
|
|
451
|
+
let frontmatter = {};
|
|
452
|
+
try { frontmatter = JSON.parse(concept.frontmatter || '{}'); } catch {}
|
|
453
|
+
|
|
454
|
+
const references = db.prepare('SELECT slug, title, type FROM concepts WHERE slug LIKE ? ESCAPE \'\\\' AND source = ?')
|
|
455
|
+
.all(slug.replace(/_/g, '\\_').replace(/%/g, '\\%') + '/%', concept.source)
|
|
456
|
+
.map(r => ({ slug: r.slug, title: r.title, type: r.type }));
|
|
457
|
+
|
|
458
|
+
let dependencies = [];
|
|
459
|
+
if (frontmatter.dependencies && Array.isArray(frontmatter.dependencies)) {
|
|
460
|
+
for (const depSlug of frontmatter.dependencies) {
|
|
461
|
+
const dep = db.prepare('SELECT title FROM concepts WHERE slug = ?').get(depSlug);
|
|
462
|
+
dependencies.push({ slug: depSlug, title: dep ? dep.title : null });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
id: concept.id,
|
|
468
|
+
slug: concept.slug,
|
|
469
|
+
type: concept.type,
|
|
470
|
+
title: concept.title,
|
|
471
|
+
description: concept.description,
|
|
472
|
+
tags: typeof concept.tags === 'string' ? JSON.parse(concept.tags || '[]') : (concept.tags || []),
|
|
473
|
+
status: concept.status,
|
|
474
|
+
source: concept.source,
|
|
475
|
+
timestamp: concept.timestamp,
|
|
476
|
+
resource: concept.resource,
|
|
477
|
+
frontmatter,
|
|
478
|
+
body: concept.body,
|
|
479
|
+
references,
|
|
480
|
+
dependencies
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function listConcepts({ type, tags, status, limit, offset } = {}) {
|
|
485
|
+
initDb();
|
|
486
|
+
const cfg = getConfig();
|
|
487
|
+
const listLimit = limit || cfg.dashboard.paginationLimit;
|
|
488
|
+
const listOffset = offset || 0;
|
|
489
|
+
|
|
490
|
+
const conditions = [];
|
|
491
|
+
const params = [];
|
|
492
|
+
|
|
493
|
+
if (type) { conditions.push('type = ?'); params.push(type); }
|
|
494
|
+
if (status) { conditions.push('status = ?'); params.push(status); }
|
|
495
|
+
|
|
496
|
+
let whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
|
497
|
+
|
|
498
|
+
if (tags && tags.length > 0) {
|
|
499
|
+
const tagConditions = tags.map(() => 'tags LIKE ?').join(' AND ');
|
|
500
|
+
whereClause += (conditions.length > 0 ? ' AND ' : 'WHERE ') + tagConditions;
|
|
501
|
+
for (const t of tags) params.push('%"' + t + '"%');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const total = db.prepare('SELECT COUNT(*) as c FROM concepts ' + whereClause).get(...params).c;
|
|
505
|
+
const rows = db.prepare('SELECT id, slug, type, title, description, tags, status, source, timestamp, resource, created_at, updated_at FROM concepts ' + whereClause + ' ORDER BY updated_at DESC LIMIT ? OFFSET ?')
|
|
506
|
+
.all(...params, listLimit, listOffset)
|
|
507
|
+
.map(r => ({
|
|
508
|
+
id: r.id,
|
|
509
|
+
slug: r.slug,
|
|
510
|
+
type: r.type,
|
|
511
|
+
title: r.title,
|
|
512
|
+
description: r.description,
|
|
513
|
+
tags: typeof r.tags === 'string' ? JSON.parse(r.tags || '[]') : (r.tags || []),
|
|
514
|
+
status: r.status,
|
|
515
|
+
source: r.source,
|
|
516
|
+
timestamp: r.timestamp
|
|
517
|
+
}));
|
|
518
|
+
|
|
519
|
+
return { concepts: rows, total, limit: listLimit, offset: listOffset };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function getTags(type) {
|
|
523
|
+
initDb();
|
|
524
|
+
let sql = 'SELECT DISTINCT tags FROM concepts';
|
|
525
|
+
const params = [];
|
|
526
|
+
if (type) { sql += ' WHERE type = ?'; params.push(type); }
|
|
527
|
+
const rows = db.prepare(sql).all(...params);
|
|
528
|
+
const counts = new Map();
|
|
529
|
+
for (const r of rows) {
|
|
530
|
+
const list = typeof r.tags === 'string' ? JSON.parse(r.tags || '[]') : (r.tags || []);
|
|
531
|
+
for (const t of list) {
|
|
532
|
+
counts.set(t, (counts.get(t) || 0) + 1);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return Array.from(counts.entries()).map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function getChanges(since) {
|
|
539
|
+
initDb();
|
|
540
|
+
const ts = typeof since === 'number' ? since : Math.floor(new Date(since).getTime() / 1000);
|
|
541
|
+
const rows = db.prepare('SELECT id, slug, type, title, status FROM concepts WHERE updated_at > ? ORDER BY updated_at DESC').all(ts);
|
|
542
|
+
return rows;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function getDeps(slug) {
|
|
546
|
+
initDb();
|
|
547
|
+
const concept = db.prepare('SELECT frontmatter FROM concepts WHERE slug = ?').get(slug);
|
|
548
|
+
if (!concept) return { dependencies: [], dependents: [] };
|
|
549
|
+
|
|
550
|
+
let frontmatter = {};
|
|
551
|
+
try { frontmatter = JSON.parse(concept.frontmatter || '{}'); } catch {}
|
|
552
|
+
|
|
553
|
+
const dependencies = (frontmatter.dependencies || []).map(depSlug => {
|
|
554
|
+
const dep = db.prepare('SELECT title FROM concepts WHERE slug = ?').get(depSlug);
|
|
555
|
+
return { slug: depSlug, title: dep ? dep.title : null };
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const dependents = db.prepare('SELECT slug, title FROM concepts WHERE frontmatter LIKE ?')
|
|
559
|
+
.all('%' + slug + '%')
|
|
560
|
+
.filter(r => {
|
|
561
|
+
try {
|
|
562
|
+
const fm = JSON.parse(r.frontmatter || '{}');
|
|
563
|
+
return fm.dependencies && fm.dependencies.includes(slug);
|
|
564
|
+
} catch { return false; }
|
|
565
|
+
})
|
|
566
|
+
.map(r => ({ slug: r.slug, title: r.title }));
|
|
567
|
+
|
|
568
|
+
return { dependencies, dependents };
|
|
569
|
+
}
|
package/embedding.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const DIM = 256;
|
|
2
|
+
|
|
3
|
+
function murmurHash3(key, seed = 0) {
|
|
4
|
+
let h1 = seed >>> 0;
|
|
5
|
+
const remainder = key.length & 3;
|
|
6
|
+
const bytes = key.length - remainder;
|
|
7
|
+
const c1 = 0xcc9e2d51;
|
|
8
|
+
const c2 = 0x1b873593;
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < bytes; i += 4) {
|
|
11
|
+
let k1 = (key.charCodeAt(i) & 0xff)
|
|
12
|
+
| ((key.charCodeAt(i + 1) & 0xff) << 8)
|
|
13
|
+
| ((key.charCodeAt(i + 2) & 0xff) << 16)
|
|
14
|
+
| ((key.charCodeAt(i + 3) & 0xff) << 24);
|
|
15
|
+
|
|
16
|
+
k1 = Math.imul(k1, c1);
|
|
17
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
18
|
+
k1 = Math.imul(k1, c2);
|
|
19
|
+
|
|
20
|
+
h1 ^= k1;
|
|
21
|
+
h1 = (h1 << 13) | (h1 >>> 19);
|
|
22
|
+
h1 = Math.imul(h1, 5) + 0xe6546b64;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (remainder > 0) {
|
|
26
|
+
let k1 = 0;
|
|
27
|
+
for (let j = remainder - 1; j >= 0; j--) {
|
|
28
|
+
k1 = (k1 << 8) | (key.charCodeAt(bytes + j) & 0xff);
|
|
29
|
+
}
|
|
30
|
+
k1 = Math.imul(k1, c1);
|
|
31
|
+
k1 = (k1 << 15) | (k1 >>> 17);
|
|
32
|
+
k1 = Math.imul(k1, c2);
|
|
33
|
+
h1 ^= k1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
h1 ^= key.length;
|
|
37
|
+
h1 ^= h1 >>> 16;
|
|
38
|
+
h1 = Math.imul(h1, 0x85ebca6b);
|
|
39
|
+
h1 ^= h1 >>> 13;
|
|
40
|
+
h1 = Math.imul(h1, 0xc2b2ae35);
|
|
41
|
+
h1 ^= h1 >>> 16;
|
|
42
|
+
|
|
43
|
+
return h1 >>> 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function hashVector(str) {
|
|
47
|
+
const hash = murmurHash3(str);
|
|
48
|
+
const idx = hash % DIM;
|
|
49
|
+
const sign = (hash & 1) ? 1 : -1;
|
|
50
|
+
return { idx, sign };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createEmbedding(text) {
|
|
54
|
+
const vector = new Float32Array(DIM);
|
|
55
|
+
|
|
56
|
+
const tokens = text.toLowerCase().split(/[^\w']+/).filter(t => t.length > 0);
|
|
57
|
+
|
|
58
|
+
for (const token of tokens) {
|
|
59
|
+
const { idx, sign } = hashVector(token);
|
|
60
|
+
vector[idx] += sign;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
64
|
+
const bigram = tokens[i] + '\x00' + tokens[i + 1];
|
|
65
|
+
const { idx, sign } = hashVector(bigram);
|
|
66
|
+
vector[idx] += sign;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let sumSq = 0;
|
|
70
|
+
for (let i = 0; i < DIM; i++) {
|
|
71
|
+
sumSq += vector[i] * vector[i];
|
|
72
|
+
}
|
|
73
|
+
const norm = Math.sqrt(sumSq);
|
|
74
|
+
if (norm > 0) {
|
|
75
|
+
for (let i = 0; i < DIM; i++) {
|
|
76
|
+
vector[i] /= norm;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return vector;
|
|
81
|
+
}
|