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 +90 -0
- package/package.json +7 -2
- package/src/cli/commands/action.ts +1 -0
- package/src/cli/commands/create.ts +30 -0
- package/src/cli/commands/extract.ts +1 -0
- package/src/cli/commands/help.ts +2 -1
- package/src/cli/commands/init.ts +1 -0
- package/src/cli/commands/search.ts +22 -14
- package/src/cli/commands/text-search.ts +1 -0
- package/src/cli/commands/validate.ts +1 -0
- package/src/cli/registry.ts +1 -0
- package/src/collection.ts +11 -3
- package/src/relationships/has-many.ts +3 -1
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.
|
|
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": [
|
|
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.
|
package/src/cli/commands/help.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
+
const label = def.usage ? `${name} ${def.usage}` : name
|
|
26
|
+
lines.push(`| \`${label}\` | ${def.description} |`)
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
lines.push(
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -193,22 +193,29 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
|
|
|
193
193
|
return
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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.
|
package/src/cli/registry.ts
CHANGED
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())
|
|
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
|
}
|