contentbase 0.1.6 → 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.6",
3
+ "version": "0.2.0",
4
4
  "repository": "https://github.com/soederpop/contentbase",
5
5
  "website": "https://contentbase.soederpop.com",
6
6
  "type": "module",
@@ -50,6 +50,11 @@
50
50
  "vitest": "^1.6.0"
51
51
  },
52
52
  "luca": {
53
- "aliases": ["contentbase", "content base", "content", "the orm"]
53
+ "aliases": [
54
+ "contentbase",
55
+ "content base",
56
+ "content",
57
+ "the orm"
58
+ ]
54
59
  }
55
60
  }
@@ -38,6 +38,7 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
38
38
 
39
39
  commands.register('action', {
40
40
  description: 'Run a named action on the collection',
41
+ usage: '<name>',
41
42
  help: `# cnotes action
42
43
 
43
44
  Run a named action registered on the collection. Actions are custom functions defined in your collection's entry point.
@@ -2,6 +2,7 @@ import { z } from 'zod'
2
2
  import fs from 'fs/promises'
3
3
  import path from 'path'
4
4
  import matter from 'gray-matter'
5
+ import { execSync } from 'child_process'
5
6
  import { commands } from '../registry.js'
6
7
  import { loadCollection } from '../load-collection.js'
7
8
  import { kebabCase } from '../../utils/inflect.js'
@@ -10,6 +11,7 @@ import { introspectMetaSchema } from '../../collection.js'
10
11
  const argsSchema = z.object({
11
12
  title: z.string().optional(),
12
13
  contentFolder: z.string().optional(),
14
+ openEditor: z.boolean().optional(),
13
15
  })
14
16
 
15
17
  async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
@@ -125,10 +127,37 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
125
127
  await fs.writeFile(filePath, content, 'utf8')
126
128
 
127
129
  console.log(`Created ${filePath}`)
130
+
131
+ if (options.openEditor) {
132
+ const editor = process.env.EDITOR
133
+ if (editor) {
134
+ execSync(`${editor} ${filePath}`, { stdio: 'inherit' })
135
+ } else {
136
+ const candidates = ['cursor', 'code', 'vim']
137
+ let found = false
138
+ for (const cmd of candidates) {
139
+ try {
140
+ execSync(`which ${cmd}`, { stdio: 'ignore' })
141
+ execSync(`${cmd} ${filePath}`, { stdio: 'inherit' })
142
+ found = true
143
+ break
144
+ } catch {
145
+ // not available, try next
146
+ }
147
+ }
148
+ if (!found) {
149
+ console.error(
150
+ 'No editor found. Tried: cursor, code, vim. Set $EDITOR to your preferred editor.'
151
+ )
152
+ process.exit(1)
153
+ }
154
+ }
155
+ }
128
156
  }
129
157
 
130
158
  commands.register('create', {
131
159
  description: 'Create a new document for a model type',
160
+ usage: '<model>',
132
161
  help: `# cnotes create
133
162
 
134
163
  Create a new document for a registered model, with proper frontmatter defaults and section scaffolding.
@@ -151,6 +180,7 @@ cnotes create <model> --title "Document Title" [options]
151
180
  |--------|-------------|
152
181
  | \`--title\` | **Required.** Title for the new document (used as H1 and slug) |
153
182
  | \`--meta.*\` | Set frontmatter fields (e.g. \`--meta.status active\`) |
183
+ | \`--open-editor\` | Open the new file in your editor ($EDITOR, or tries cursor/code/vim) |
154
184
  | \`--contentFolder\` | Path to content folder |
155
185
 
156
186
  ## Template Lookup
@@ -123,6 +123,7 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
123
123
 
124
124
  commands.register('extract', {
125
125
  description: 'Extract specific sections from documents',
126
+ usage: '<glob>',
126
127
  help: `# cnotes extract
127
128
 
128
129
  Extract specific sections from one or more documents. Supports glob patterns for matching multiple documents and outputs clean markdown with normalized headings.
@@ -22,7 +22,8 @@ async function handler(_options: any, context: { container: any }) {
22
22
 
23
23
  for (const name of commands.available) {
24
24
  const def = commands.get(name)!
25
- lines.push(`| \`${name}\` | ${def.description} |`)
25
+ const label = def.usage ? `${name} ${def.usage}` : name
26
+ lines.push(`| \`${label}\` | ${def.description} |`)
26
27
  }
27
28
 
28
29
  lines.push(
@@ -60,6 +60,7 @@ collection.register(Post);
60
60
 
61
61
  commands.register('init', {
62
62
  description: 'Initialize a new contentbase project',
63
+ usage: '[name]',
63
64
  help: `# cnotes init
64
65
 
65
66
  Scaffold a new contentbase project with a sample model and document.
@@ -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) {
@@ -223,6 +230,7 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
223
230
 
224
231
  commands.register('search', {
225
232
  description: 'Semantic search across collection documents',
233
+ usage: '<query>',
226
234
  help: `# cnotes search
227
235
 
228
236
  Search documents in the collection using keyword, semantic, or hybrid search modes. Requires a search index — run \`cnotes embed\` first.
@@ -86,6 +86,7 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
86
86
 
87
87
  commands.register('text-search', {
88
88
  description: 'Search file contents with pattern matching',
89
+ usage: '<pattern>',
89
90
  help: `# cnotes text-search
90
91
 
91
92
  Search file contents within the collection using ripgrep. Returns matching files by default, or line-level detail with \`--expanded\`.
@@ -85,6 +85,7 @@ async function handler(options: z.infer<typeof argsSchema>, context: { container
85
85
 
86
86
  commands.register('validate', {
87
87
  description: 'Validate documents against their model schemas',
88
+ usage: '[model]',
88
89
  help: `# cnotes validate
89
90
 
90
91
  Validate documents against their model schemas. Check frontmatter types, required fields, and optionally fill in missing defaults.
@@ -2,6 +2,7 @@ import type { z } from 'zod'
2
2
 
3
3
  export interface CommandDefinition {
4
4
  description: string
5
+ usage?: string
5
6
  help?: string
6
7
  argsSchema?: z.ZodType<any>
7
8
  handler: (options: any, context: { container: any }) => Promise<void>
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
  }