contentbase 0.0.1 → 0.0.4
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/.mcp.json +9 -0
- package/CLI.md +593 -0
- package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +406 -13
- package/bun.lock +45 -6
- package/dist/cnotes +0 -0
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +14 -4
- package/public/web-demo/index.html +813 -0
- package/scripts/examples/01-collection-setup.ts +46 -0
- package/scripts/examples/02-querying.ts +67 -0
- package/scripts/examples/03-sections.ts +36 -0
- package/scripts/examples/04-relationships.ts +54 -0
- package/scripts/examples/05-document-api.ts +54 -0
- package/scripts/examples/06-extract-sections.ts +55 -0
- package/scripts/examples/07-validation.ts +46 -0
- package/scripts/examples/08-serialization.ts +51 -0
- package/scripts/examples/lib/format.ts +87 -0
- package/scripts/examples/lib/setup.ts +21 -0
- package/scripts/examples/run-all.ts +43 -0
- package/showcases/node_modules/.cache/.repl_history +3 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
- package/src/__tests__/semantic-search.integration.test.ts +284 -0
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +455 -6
- package/src/define-model.ts +104 -7
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +222 -0
- package/src/index.ts +20 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +24 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/test/collection.test.ts +118 -2
- package/test/extract-sections.test.ts +356 -0
- package/test/fixtures/sdlc/MODELS.md +106 -0
- package/test/fixtures/sdlc/SKILL.md +404 -0
- package/test/fixtures/sdlc/index.ts +9 -0
- package/test/fixtures/sdlc/models.ts +8 -6
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
- package/test/fixtures/sdlc/templates/epic.md +23 -0
- package/test/fixtures/sdlc/templates/story.md +19 -0
- package/test/pattern.test.ts +191 -0
- package/test/query-dsl.test.ts +431 -0
- package/test/query.test.ts +29 -0
- package/test/relationships.test.ts +130 -0
- package/test/section.test.ts +65 -4
- package/test/table-of-contents.test.ts +135 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import matter from 'gray-matter'
|
|
3
|
+
import { resolveModelDef } from '../helpers.js'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/documents'
|
|
6
|
+
export const description = 'List or create documents'
|
|
7
|
+
export const tags = ['documents']
|
|
8
|
+
|
|
9
|
+
export const getSchema = z.object({
|
|
10
|
+
model: z.string().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export async function get(params: any, ctx: any) {
|
|
14
|
+
const collection = ctx.container._contentbaseCollection
|
|
15
|
+
|
|
16
|
+
let ids = collection.available as string[]
|
|
17
|
+
|
|
18
|
+
if (params.model) {
|
|
19
|
+
const def = resolveModelDef(collection, params.model)
|
|
20
|
+
if (!def) {
|
|
21
|
+
ctx.response.status(400)
|
|
22
|
+
return { error: `Unknown model: ${params.model}` }
|
|
23
|
+
}
|
|
24
|
+
const prefix = (def as any).prefix + '/'
|
|
25
|
+
ids = ids.filter((id: string) => id.startsWith(prefix))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return ids.map((id: string) => {
|
|
29
|
+
const doc = collection.document(id)
|
|
30
|
+
return { id, title: doc.title, meta: doc.meta, size: doc.size, createdAt: doc.createdAt, updatedAt: doc.updatedAt }
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const postSchema = z.object({
|
|
35
|
+
pathId: z.string(),
|
|
36
|
+
title: z.string(),
|
|
37
|
+
meta: z.record(z.string(), z.any()).optional(),
|
|
38
|
+
model: z.string().optional(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export async function post(params: any, ctx: any) {
|
|
42
|
+
if (ctx.container._contentbaseReadOnly) {
|
|
43
|
+
ctx.response.status(403)
|
|
44
|
+
return { error: 'Server is running in read-only mode' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const collection = ctx.container._contentbaseCollection
|
|
48
|
+
|
|
49
|
+
if (collection.available.includes(params.pathId)) {
|
|
50
|
+
ctx.response.status(409)
|
|
51
|
+
return { error: `Document already exists: ${params.pathId}` }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const def = params.model
|
|
55
|
+
? resolveModelDef(collection, params.model)
|
|
56
|
+
: collection.findModelDefinition(params.pathId)
|
|
57
|
+
|
|
58
|
+
const metaData = { ...((def as any)?.defaults || {}), ...(params.meta || {}) }
|
|
59
|
+
|
|
60
|
+
const sectionHeadings = def
|
|
61
|
+
? Object.values((def as any).sections || {}).map((s: any) => `## ${s.heading}\n\n`)
|
|
62
|
+
: []
|
|
63
|
+
|
|
64
|
+
const body = [`# ${params.title}`, '', ...sectionHeadings].join('\n')
|
|
65
|
+
const content = matter.stringify(body, metaData)
|
|
66
|
+
|
|
67
|
+
await collection.saveItem(params.pathId, { content })
|
|
68
|
+
|
|
69
|
+
ctx.response.status(201)
|
|
70
|
+
return { created: params.pathId, model: def ? (def as any).name : null, meta: metaData }
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { buildSchemaJSON } from '../helpers.js'
|
|
2
|
+
|
|
3
|
+
export const path = '/api/inspect'
|
|
4
|
+
export const description = 'Collection overview — models, document count, actions'
|
|
5
|
+
export const tags = ['collection']
|
|
6
|
+
|
|
7
|
+
export async function get(_params: any, ctx: any) {
|
|
8
|
+
const collection = ctx.container._contentbaseCollection
|
|
9
|
+
const schema = buildSchemaJSON(collection)
|
|
10
|
+
return {
|
|
11
|
+
rootPath: collection.rootPath,
|
|
12
|
+
documentCount: collection.available.length,
|
|
13
|
+
models: Object.values(schema),
|
|
14
|
+
actions: collection.availableActions,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { buildSchemaJSON } from '../helpers.js'
|
|
2
|
+
|
|
3
|
+
export const path = '/api/models'
|
|
4
|
+
export const description = 'All model definitions with schemas, sections, and relationships'
|
|
5
|
+
export const tags = ['collection']
|
|
6
|
+
|
|
7
|
+
export async function get(_params: any, ctx: any) {
|
|
8
|
+
const collection = ctx.container._contentbaseCollection
|
|
9
|
+
return buildSchemaJSON(collection)
|
|
10
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { resolveModelDef } from '../helpers.js'
|
|
3
|
+
import { queryDSLSchema, executeQueryDSL } from '../../query/query-dsl.js'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/query'
|
|
6
|
+
export const description = 'Query model instances with filtering'
|
|
7
|
+
export const tags = ['query']
|
|
8
|
+
|
|
9
|
+
export const getSchema = z.object({
|
|
10
|
+
model: z.string(),
|
|
11
|
+
where: z.string().optional(),
|
|
12
|
+
select: z.string().optional(),
|
|
13
|
+
related: z.string().optional(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const postSchema = queryDSLSchema
|
|
17
|
+
|
|
18
|
+
export async function get(params: any, ctx: any) {
|
|
19
|
+
const collection = ctx.container._contentbaseCollection
|
|
20
|
+
|
|
21
|
+
const def = resolveModelDef(collection, params.model)
|
|
22
|
+
if (!def) {
|
|
23
|
+
ctx.response.status(400)
|
|
24
|
+
return { error: `Unknown model: ${params.model}` }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let q = collection.query(def)
|
|
28
|
+
|
|
29
|
+
if (params.where) {
|
|
30
|
+
let conditions: any[]
|
|
31
|
+
try {
|
|
32
|
+
conditions = JSON.parse(params.where)
|
|
33
|
+
} catch {
|
|
34
|
+
ctx.response.status(400)
|
|
35
|
+
return { error: 'Invalid JSON in where parameter' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const condition of conditions) {
|
|
39
|
+
const { path: fieldPath, operator = 'eq', value } = condition
|
|
40
|
+
switch (operator) {
|
|
41
|
+
case 'eq': q = q.where(fieldPath, value); break
|
|
42
|
+
case 'in': q = q.whereIn(fieldPath, value); break
|
|
43
|
+
case 'notIn': q = q.whereNotIn(fieldPath, value); break
|
|
44
|
+
case 'gt': q = q.whereGt(fieldPath, value); break
|
|
45
|
+
case 'lt': q = q.whereLt(fieldPath, value); break
|
|
46
|
+
case 'gte': q = q.whereGte(fieldPath, value); break
|
|
47
|
+
case 'lte': q = q.whereLte(fieldPath, value); break
|
|
48
|
+
case 'contains': q = q.whereContains(fieldPath, value); break
|
|
49
|
+
case 'startsWith': q = q.whereStartsWith(fieldPath, value); break
|
|
50
|
+
case 'endsWith': q = q.whereEndsWith(fieldPath, value); break
|
|
51
|
+
case 'regex': q = q.whereRegex(fieldPath, value); break
|
|
52
|
+
case 'exists': q = q.whereExists(fieldPath); break
|
|
53
|
+
case 'notExists': q = q.whereNotExists(fieldPath); break
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const results = await q.fetchAll()
|
|
59
|
+
const selectFields = params.select ? params.select.split(',').map((s: string) => s.trim()) : null
|
|
60
|
+
const relatedFields = params.related ? params.related.split(',').map((s: string) => s.trim()) : undefined
|
|
61
|
+
|
|
62
|
+
return results.map((instance: any) => {
|
|
63
|
+
const json = instance.toJSON({ related: relatedFields })
|
|
64
|
+
if (selectFields && selectFields.length > 0) {
|
|
65
|
+
const filtered: Record<string, any> = {}
|
|
66
|
+
for (const key of selectFields) {
|
|
67
|
+
if (key in json) filtered[key] = json[key]
|
|
68
|
+
else if (key.startsWith('meta.') && json.meta) {
|
|
69
|
+
filtered[key] = json.meta[key.slice(5)]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return filtered
|
|
73
|
+
}
|
|
74
|
+
return json
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function post(params: any, ctx: any) {
|
|
79
|
+
const collection = ctx.container._contentbaseCollection
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const dsl = queryDSLSchema.parse(params)
|
|
83
|
+
return await executeQueryDSL(collection, dsl)
|
|
84
|
+
} catch (error: any) {
|
|
85
|
+
ctx.response.status(400)
|
|
86
|
+
return { error: error.message }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import pathModule from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const path = '/api/search/reindex'
|
|
5
|
+
export const description = 'Trigger search index rebuild'
|
|
6
|
+
export const tags = ['mutation']
|
|
7
|
+
|
|
8
|
+
export const postSchema = z.object({
|
|
9
|
+
pathIds: z.array(z.string()).optional(),
|
|
10
|
+
force: z.boolean().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export async function post(params: any, ctx: any) {
|
|
14
|
+
const collection = ctx.container._contentbaseCollection
|
|
15
|
+
const rootPath = collection.rootPath
|
|
16
|
+
|
|
17
|
+
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
18
|
+
if (!ctx.container.features.available.includes('semanticSearch')) {
|
|
19
|
+
SemanticSearch.attach(ctx.container)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dbPath = pathModule.join(rootPath, '.contentbase/search.sqlite')
|
|
23
|
+
const ss = ctx.container.feature('semanticSearch', { dbPath })
|
|
24
|
+
await ss.initDb()
|
|
25
|
+
|
|
26
|
+
if (params.pathIds) {
|
|
27
|
+
await ss.reindex(params.pathIds)
|
|
28
|
+
} else if (params.force) {
|
|
29
|
+
await ss.reindex()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Collect and re-index documents
|
|
33
|
+
const docs: any[] = []
|
|
34
|
+
const targetIds = params.pathIds || collection.available
|
|
35
|
+
|
|
36
|
+
for (const pathId of targetIds) {
|
|
37
|
+
if (!collection.available.includes(pathId)) continue
|
|
38
|
+
const doc = collection.document(pathId)
|
|
39
|
+
const modelDef = collection.findModelDefinition(pathId)
|
|
40
|
+
|
|
41
|
+
docs.push({
|
|
42
|
+
pathId,
|
|
43
|
+
model: modelDef?.name ?? undefined,
|
|
44
|
+
title: doc.title,
|
|
45
|
+
slug: doc.slug,
|
|
46
|
+
meta: doc.meta,
|
|
47
|
+
content: doc.content,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toIndex = params.force ? docs : docs.filter((d: any) => ss.needsReindex(d))
|
|
52
|
+
|
|
53
|
+
if (toIndex.length > 0) {
|
|
54
|
+
await ss.indexDocuments(toIndex)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ss.removeStale(collection.available)
|
|
58
|
+
|
|
59
|
+
const stats = ss.getStats()
|
|
60
|
+
return {
|
|
61
|
+
reindexed: toIndex.length,
|
|
62
|
+
total: docs.length,
|
|
63
|
+
...stats,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import pathModule from 'node:path'
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/search/status'
|
|
6
|
+
export const description = 'Search index health and statistics'
|
|
7
|
+
export const tags = ['query']
|
|
8
|
+
|
|
9
|
+
export const getSchema = z.object({})
|
|
10
|
+
|
|
11
|
+
export async function get(_params: any, ctx: any) {
|
|
12
|
+
const collection = ctx.container._contentbaseCollection
|
|
13
|
+
const rootPath = collection.rootPath
|
|
14
|
+
const dbDir = pathModule.join(rootPath, '.contentbase')
|
|
15
|
+
|
|
16
|
+
const hasIndex = existsSync(dbDir) && (() => {
|
|
17
|
+
try {
|
|
18
|
+
const files = readdirSync(dbDir) as string[]
|
|
19
|
+
return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
})()
|
|
24
|
+
|
|
25
|
+
if (!hasIndex) {
|
|
26
|
+
return {
|
|
27
|
+
exists: false,
|
|
28
|
+
documentCount: 0,
|
|
29
|
+
chunkCount: 0,
|
|
30
|
+
embeddingCount: 0,
|
|
31
|
+
lastIndexedAt: null,
|
|
32
|
+
provider: null,
|
|
33
|
+
model: null,
|
|
34
|
+
dimensions: 0,
|
|
35
|
+
dbSizeBytes: 0,
|
|
36
|
+
collectionDocumentCount: collection.available.length,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
41
|
+
if (!ctx.container.features.available.includes('semanticSearch')) {
|
|
42
|
+
SemanticSearch.attach(ctx.container)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dbPath = pathModule.join(rootPath, '.contentbase/search.sqlite')
|
|
46
|
+
const ss = ctx.container.feature('semanticSearch', { dbPath })
|
|
47
|
+
await ss.initDb()
|
|
48
|
+
|
|
49
|
+
const stats = ss.getStats()
|
|
50
|
+
return {
|
|
51
|
+
exists: true,
|
|
52
|
+
...stats,
|
|
53
|
+
collectionDocumentCount: collection.available.length,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import pathModule from 'node:path'
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/search'
|
|
6
|
+
export const description = 'Search across collection documents using keyword, semantic, or hybrid modes'
|
|
7
|
+
export const tags = ['query']
|
|
8
|
+
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function hasSearchIndex(rootPath: string): boolean {
|
|
12
|
+
const dbDir = pathModule.join(rootPath, '.contentbase')
|
|
13
|
+
if (!existsSync(dbDir)) return false
|
|
14
|
+
try {
|
|
15
|
+
const files = readdirSync(dbDir) as string[]
|
|
16
|
+
return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _semanticSearch: any = null
|
|
23
|
+
|
|
24
|
+
async function getSemanticSearch(container: any, rootPath: string) {
|
|
25
|
+
if (_semanticSearch?.state?.get('dbReady')) return _semanticSearch
|
|
26
|
+
|
|
27
|
+
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
28
|
+
if (!container.features.available.includes('semanticSearch')) {
|
|
29
|
+
SemanticSearch.attach(container)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dbPath = pathModule.join(rootPath, '.contentbase/search.sqlite')
|
|
33
|
+
_semanticSearch = container.feature('semanticSearch', { dbPath })
|
|
34
|
+
await _semanticSearch.initDb()
|
|
35
|
+
return _semanticSearch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function doSearch(ss: any, query: string, mode: string, options: any) {
|
|
39
|
+
switch (mode) {
|
|
40
|
+
case 'keyword':
|
|
41
|
+
return ss.search(query, options)
|
|
42
|
+
case 'vector':
|
|
43
|
+
return ss.vectorSearch(query, options)
|
|
44
|
+
case 'hybrid':
|
|
45
|
+
default:
|
|
46
|
+
return ss.hybridSearch(query, options)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── GET /api/search ──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const getSchema = z.object({
|
|
53
|
+
q: z.string(),
|
|
54
|
+
mode: z.string().optional(),
|
|
55
|
+
model: z.string().optional(),
|
|
56
|
+
limit: z.string().optional(),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
export async function get(params: any, ctx: any) {
|
|
60
|
+
const collection = ctx.container._contentbaseCollection
|
|
61
|
+
const rootPath = collection.rootPath
|
|
62
|
+
|
|
63
|
+
if (!hasSearchIndex(rootPath)) {
|
|
64
|
+
ctx.response.status(400)
|
|
65
|
+
return { error: 'No search index found. Run: cnotes embed' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ss = await getSemanticSearch(ctx.container, rootPath)
|
|
69
|
+
const mode = params.mode || 'hybrid'
|
|
70
|
+
const limit = params.limit ? parseInt(params.limit, 10) : 10
|
|
71
|
+
const searchOptions = { limit, model: params.model }
|
|
72
|
+
|
|
73
|
+
return doSearch(ss, params.q, mode, searchOptions)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── POST /api/search ─────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export const postSchema = z.object({
|
|
79
|
+
query: z.string(),
|
|
80
|
+
mode: z.enum(['hybrid', 'keyword', 'vector']).optional(),
|
|
81
|
+
model: z.string().optional(),
|
|
82
|
+
limit: z.number().optional(),
|
|
83
|
+
where: z.record(z.string(), z.any()).optional(),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export async function post(params: any, ctx: any) {
|
|
87
|
+
const collection = ctx.container._contentbaseCollection
|
|
88
|
+
const rootPath = collection.rootPath
|
|
89
|
+
|
|
90
|
+
if (!hasSearchIndex(rootPath)) {
|
|
91
|
+
ctx.response.status(400)
|
|
92
|
+
return { error: 'No search index found. Run: cnotes embed' }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ss = await getSemanticSearch(ctx.container, rootPath)
|
|
96
|
+
const mode = params.mode || 'hybrid'
|
|
97
|
+
const searchOptions = {
|
|
98
|
+
limit: params.limit || 10,
|
|
99
|
+
model: params.model,
|
|
100
|
+
where: params.where,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return doSearch(ss, params.query, mode, searchOptions)
|
|
104
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import pathModule from 'node:path'
|
|
3
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/semantic-search'
|
|
6
|
+
export const description = 'Semantic search across collection documents'
|
|
7
|
+
export const tags = ['query']
|
|
8
|
+
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function hasSearchIndex(rootPath: string): boolean {
|
|
12
|
+
const dbDir = pathModule.join(rootPath, '.contentbase')
|
|
13
|
+
if (!existsSync(dbDir)) return false
|
|
14
|
+
try {
|
|
15
|
+
const files = readdirSync(dbDir) as string[]
|
|
16
|
+
return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _semanticSearch: any = null
|
|
23
|
+
|
|
24
|
+
async function getSemanticSearch(container: any, rootPath: string) {
|
|
25
|
+
if (_semanticSearch?.state?.get('dbReady')) return _semanticSearch
|
|
26
|
+
|
|
27
|
+
const { SemanticSearch } = await import('@soederpop/luca/agi')
|
|
28
|
+
if (!container.features.available.includes('semanticSearch')) {
|
|
29
|
+
SemanticSearch.attach(container)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dbPath = pathModule.join(rootPath, '.contentbase/search.sqlite')
|
|
33
|
+
_semanticSearch = container.feature('semanticSearch', { dbPath })
|
|
34
|
+
await _semanticSearch.initDb()
|
|
35
|
+
return _semanticSearch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── GET /api/semantic-search ─────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export const getSchema = z.object({
|
|
41
|
+
q: z.string(),
|
|
42
|
+
mode: z.string().optional(),
|
|
43
|
+
model: z.string().optional(),
|
|
44
|
+
limit: z.string().optional(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export async function get(params: any, ctx: any) {
|
|
48
|
+
const collection = ctx.container._contentbaseCollection
|
|
49
|
+
const rootPath = collection.rootPath
|
|
50
|
+
|
|
51
|
+
if (!hasSearchIndex(rootPath)) {
|
|
52
|
+
ctx.response.status(400)
|
|
53
|
+
return { error: 'No search index found. Run: cnotes embed' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ss = await getSemanticSearch(ctx.container, rootPath)
|
|
57
|
+
const mode = params.mode || 'hybrid'
|
|
58
|
+
const limit = params.limit ? parseInt(params.limit, 10) : 10
|
|
59
|
+
const searchOptions = { limit, model: params.model }
|
|
60
|
+
|
|
61
|
+
let results: any[]
|
|
62
|
+
switch (mode) {
|
|
63
|
+
case 'keyword':
|
|
64
|
+
results = await ss.search(params.q, searchOptions)
|
|
65
|
+
break
|
|
66
|
+
case 'vector':
|
|
67
|
+
results = await ss.vectorSearch(params.q, searchOptions)
|
|
68
|
+
break
|
|
69
|
+
case 'hybrid':
|
|
70
|
+
default:
|
|
71
|
+
results = await ss.hybridSearch(params.q, searchOptions)
|
|
72
|
+
break
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── POST /api/semantic-search ────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export const postSchema = z.object({
|
|
81
|
+
query: z.string(),
|
|
82
|
+
mode: z.enum(['hybrid', 'keyword', 'vector']).optional(),
|
|
83
|
+
model: z.string().optional(),
|
|
84
|
+
limit: z.number().optional(),
|
|
85
|
+
where: z.record(z.string(), z.any()).optional(),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
export async function post(params: any, ctx: any) {
|
|
89
|
+
const collection = ctx.container._contentbaseCollection
|
|
90
|
+
const rootPath = collection.rootPath
|
|
91
|
+
|
|
92
|
+
if (!hasSearchIndex(rootPath)) {
|
|
93
|
+
ctx.response.status(400)
|
|
94
|
+
return { error: 'No search index found. Run: cnotes embed' }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ss = await getSemanticSearch(ctx.container, rootPath)
|
|
98
|
+
const mode = params.mode || 'hybrid'
|
|
99
|
+
const searchOptions = {
|
|
100
|
+
limit: params.limit || 10,
|
|
101
|
+
model: params.model,
|
|
102
|
+
where: params.where,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let results: any[]
|
|
106
|
+
switch (mode) {
|
|
107
|
+
case 'keyword':
|
|
108
|
+
results = await ss.search(params.query, searchOptions)
|
|
109
|
+
break
|
|
110
|
+
case 'vector':
|
|
111
|
+
results = await ss.vectorSearch(params.query, searchOptions)
|
|
112
|
+
break
|
|
113
|
+
case 'hybrid':
|
|
114
|
+
default:
|
|
115
|
+
results = await ss.hybridSearch(params.query, searchOptions)
|
|
116
|
+
break
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return results
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const path = '/api/text-search'
|
|
4
|
+
export const description = 'Search file contents with pattern matching'
|
|
5
|
+
export const tags = ['query']
|
|
6
|
+
|
|
7
|
+
export const getSchema = z.object({
|
|
8
|
+
pattern: z.string(),
|
|
9
|
+
expanded: z.string().optional(),
|
|
10
|
+
include: z.string().optional(),
|
|
11
|
+
exclude: z.string().optional(),
|
|
12
|
+
ignoreCase: z.string().optional(),
|
|
13
|
+
maxResults: z.string().optional(),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export async function get(params: any, ctx: any) {
|
|
17
|
+
const collection = ctx.container._contentbaseCollection
|
|
18
|
+
const grep = ctx.container.feature('grep')
|
|
19
|
+
const searchPath = collection.rootPath
|
|
20
|
+
|
|
21
|
+
const expanded = params.expanded === 'true'
|
|
22
|
+
const ignoreCase = params.ignoreCase === 'true'
|
|
23
|
+
const maxResults = params.maxResults ? parseInt(params.maxResults, 10) : undefined
|
|
24
|
+
|
|
25
|
+
if (!expanded) {
|
|
26
|
+
const files = await grep.filesContaining(params.pattern, {
|
|
27
|
+
path: searchPath,
|
|
28
|
+
ignoreCase,
|
|
29
|
+
maxResults,
|
|
30
|
+
include: params.include,
|
|
31
|
+
exclude: params.exclude,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return { files, count: files.length }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const results = await grep.search({
|
|
38
|
+
pattern: params.pattern,
|
|
39
|
+
path: searchPath,
|
|
40
|
+
ignoreCase,
|
|
41
|
+
maxResults,
|
|
42
|
+
include: params.include,
|
|
43
|
+
exclude: params.exclude,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// Group by file
|
|
47
|
+
const grouped = new Map<string, Array<{ line: number; column?: number; content: string }>>()
|
|
48
|
+
for (const match of results) {
|
|
49
|
+
if (!grouped.has(match.file)) grouped.set(match.file, [])
|
|
50
|
+
grouped.get(match.file)!.push({
|
|
51
|
+
line: match.line,
|
|
52
|
+
column: match.column,
|
|
53
|
+
content: match.content,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const files = Array.from(grouped.entries()).map(([file, matches]) => ({
|
|
58
|
+
file,
|
|
59
|
+
matches,
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
return { files, count: files.length }
|
|
63
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { validateDocument } from '../../index.js'
|
|
3
|
+
import { resolveModelDef } from '../helpers.js'
|
|
4
|
+
|
|
5
|
+
export const path = '/api/validate'
|
|
6
|
+
export const description = 'Validate a document against its model schema'
|
|
7
|
+
export const tags = ['validation']
|
|
8
|
+
|
|
9
|
+
export const getSchema = z.object({
|
|
10
|
+
pathId: z.string(),
|
|
11
|
+
model: z.string().optional(),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
export async function get(params: any, ctx: any) {
|
|
15
|
+
const collection = ctx.container._contentbaseCollection
|
|
16
|
+
|
|
17
|
+
if (!collection.available.includes(params.pathId)) {
|
|
18
|
+
ctx.response.status(404)
|
|
19
|
+
return { error: `Document not found: ${params.pathId}` }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const doc = collection.document(params.pathId)
|
|
23
|
+
|
|
24
|
+
const def = params.model
|
|
25
|
+
? resolveModelDef(collection, params.model)
|
|
26
|
+
: collection.findModelDefinition(params.pathId)
|
|
27
|
+
|
|
28
|
+
if (!def) {
|
|
29
|
+
ctx.response.status(400)
|
|
30
|
+
return { error: `No model definition found for ${params.pathId}` }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return validateDocument(doc, def)
|
|
34
|
+
}
|