contentbase 0.1.8 → 0.2.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
@@ -402,6 +402,96 @@ const result = await executeQueryDSL(collection, {
402
402
 
403
403
  ---
404
404
 
405
+ ## Semantic Search
406
+
407
+ Contentbase includes built-in semantic search that combines vector embeddings, BM25 keyword ranking, and your content models' metadata — so you can ask natural-language questions and scope results to specific models and frontmatter values in the same query.
408
+
409
+ ### Setup
410
+
411
+ Generate embeddings for your collection:
412
+
413
+ ```bash
414
+ # Uses OpenAI embeddings by default (requires OPENAI_API_KEY)
415
+ cnotes embed
416
+
417
+ # Or use local embeddings (no API key needed — auto-installs node-llama-cpp)
418
+ cnotes embed --local
419
+
420
+ # Check index health
421
+ cnotes embed --status
422
+ ```
423
+
424
+ The index is stored in `.contentbase/search.sqlite`. Documents are split into chunks at H2 section boundaries and each chunk is embedded independently, so search results can point to specific sections within a document. Only changed documents are re-embedded on subsequent runs (tracked via content hashes).
425
+
426
+ ### Searching
427
+
428
+ ```bash
429
+ # Hybrid search (default) — combines keyword + vector for best results
430
+ cnotes search "authentication patterns"
431
+
432
+ # Keyword-only (BM25 ranking) — fast, good for exact terms
433
+ cnotes search "deploymentConfig" --mode keyword
434
+
435
+ # Vector-only (semantic similarity) — understands meaning, not just words
436
+ cnotes search "how do deployments work" --mode vector
437
+ ```
438
+
439
+ ### Combining Search with Model Metadata
440
+
441
+ The real power is combining semantic understanding with your content models' structured metadata. The `--model` flag scopes results to a specific model, and `--where` filters on frontmatter fields — so your Zod schemas double as search facets:
442
+
443
+ ```bash
444
+ # Find approved plans related to infrastructure
445
+ cnotes search "infrastructure" --model Plan --where "status=approved"
446
+
447
+ # Search only within epics that are in progress
448
+ cnotes search "user onboarding" --model Epic --where "status=in-progress"
449
+
450
+ # Combine model filtering with result limits
451
+ cnotes search "auth" --model Story -n 5
452
+ ```
453
+
454
+ This means the same schema you use for validation and querying also powers search filtering. A model with `status`, `priority`, or `category` in its meta schema automatically becomes filterable in search — no extra configuration.
455
+
456
+ ### Search Modes
457
+
458
+ | Mode | Algorithm | Best for |
459
+ |------|-----------|----------|
460
+ | `hybrid` (default) | BM25 + vector cosine similarity | General-purpose queries |
461
+ | `keyword` | BM25 full-text ranking | Exact terms, code identifiers, names |
462
+ | `vector` | Embedding cosine similarity | Conceptual queries, "how does X work" |
463
+
464
+ ### CLI Reference
465
+
466
+ ```bash
467
+ cnotes search <query> [options]
468
+ --mode hybrid|keyword|vector Search mode (default: hybrid)
469
+ --model <name> Filter by content model
470
+ --where <filter> Metadata filter, e.g. "status=approved"
471
+ -n <number> Max results (default: 10)
472
+ --json Output as JSON
473
+ --full Include full document content in output
474
+ --bootstrap Build index if missing, then search
475
+
476
+ cnotes embed [options]
477
+ --force Re-embed everything (ignore content hashes)
478
+ --provider openai|local Embedding provider (default: openai)
479
+ --status Show index health without embedding
480
+ --local Use local embeddings (auto-installs if needed)
481
+ --install-local Only install node-llama-cpp, then exit
482
+ ```
483
+
484
+ ### REST API
485
+
486
+ When running `cnotes serve`, search is also available over HTTP:
487
+
488
+ ```
489
+ GET /api/search?q=<query>&mode=hybrid&model=Epic&limit=10
490
+ POST /api/search { "query": "...", "mode": "hybrid", "model": "Epic", "where": { "status": "approved" } }
491
+ ```
492
+
493
+ ---
494
+
405
495
  ## Validation
406
496
 
407
497
  Every model instance can be validated against its Zod schemas:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contentbase",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "repository": "https://github.com/soederpop/contentbase",
5
5
  "website": "https://contentbase.soederpop.com",
6
6
  "type": "module",
@@ -193,22 +193,29 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
193
193
  return
194
194
  }
195
195
 
196
- const header = `| # | Score | Document | Match |`
197
- const separator = `|---|-------|----------|-------|`
198
- const rows = results.map((r: any, i: number) => {
199
- const score = `${Math.round(r.score * 100)}%`
200
- const doc = r.title || r.pathId
201
- let match = ''
196
+ const colors = ui.colors
197
+ const cols = process.stdout.columns || 80
198
+ const pad = ' '
199
+ const maxSnippet = cols - pad.length - 2
200
+
201
+ for (let i = 0; i < results.length; i++) {
202
+ const r = results[i]
203
+ const title = r.title || r.pathId
204
+ console.log(`${colors.dim(`${i + 1}.`)} ${colors.bold(title)}`)
205
+ let relPath = r.pathId
206
+ try { relPath = path.relative(process.cwd(), collection.document(r.pathId).path) } catch {}
207
+ console.log(`${pad}${colors.cyan(relPath)}`)
202
208
  if (r.snippet) {
203
- const snippet = r.snippet.replace(/\n/g, ' ').replace(/\|/g, '\\|').substring(0, 80)
204
- const section = r.matchedSection || ''
205
- match = section ? `**${section}**: ${snippet}` : snippet
209
+ let snippet = r.snippet
210
+ .replace(/>>>/g, '').replace(/<<</g, '')
211
+ .replace(/\n/g, ' ').replace(/\s+/g, ' ')
212
+ .replace(/[`*_~\[\]]/g, '').trim()
213
+ if (r.matchedSection) snippet = `${r.matchedSection} — ${snippet}`
214
+ if (snippet.length > maxSnippet) snippet = snippet.substring(0, maxSnippet - 1) + '…'
215
+ console.log(`${pad}${colors.dim(snippet)}`)
206
216
  }
207
- return `| ${i + 1} | ${score} | ${doc} | ${match} |`
208
- })
209
-
210
- const table = [header, separator, ...rows].join('\n')
211
- ui.markdown(table)
217
+ if (i < results.length - 1) console.log()
218
+ }
212
219
 
213
220
  if (options.full) {
214
221
  for (const r of results) {
package/src/collection.ts CHANGED
@@ -191,9 +191,17 @@ export class Collection {
191
191
  }
192
192
 
193
193
  get available(): string[] {
194
- return Array.from(this.#items.keys()).filter(
195
- (id) => !this.#isExcludedByModel(id)
196
- );
194
+ return Array.from(this.#items.keys())
195
+ .filter((id) => !this.#isExcludedByModel(id))
196
+ .sort();
197
+ }
198
+
199
+ /**
200
+ * Match available pathIds against one or more glob patterns.
201
+ */
202
+ matchPaths(patterns: string | string[]): string[] {
203
+ const isMatch = picomatch(patterns);
204
+ return this.available.filter((id) => isMatch(id));
197
205
  }
198
206
 
199
207
  /**
@@ -135,6 +135,8 @@ export class HasManyRelationship<
135
135
  // e.g. Project hasMany Plans → looks for meta.project on Plan documents.
136
136
  const fk = this.#definition.foreignKey || this.#inferForeignKey();
137
137
  const slug = this.#document.slug;
138
+ const parentPrefix = this.#document.id.split("/")[0];
139
+ const idSegment = this.#document.id.slice(parentPrefix.length + 1);
138
140
  const prefix = targetDef.prefix;
139
141
  const results: InferModelInstance<TTarget>[] = [];
140
142
 
@@ -147,7 +149,7 @@ export class HasManyRelationship<
147
149
  const patternMeta = matchPatterns(targetDef.pattern, pathId);
148
150
  if (patternMeta) fkValue = patternMeta[fk];
149
151
  }
150
- if (fkValue === slug) {
152
+ if (fkValue === slug || fkValue === idSegment) {
151
153
  results.push(this.#factory(doc, targetDef, this.#collection));
152
154
  }
153
155
  }