contentbase 0.0.2 → 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.
Files changed (85) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +289 -13
  7. package/bun.lock +43 -4
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +12 -3
  14. package/public/web-demo/index.html +813 -0
  15. package/showcases/node_modules/.cache/.repl_history +3 -0
  16. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  17. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  18. package/src/api/endpoints/actions.ts +34 -0
  19. package/src/api/endpoints/doc.ts +187 -0
  20. package/src/api/endpoints/docs-index.ts +26 -0
  21. package/src/api/endpoints/document.ts +171 -0
  22. package/src/api/endpoints/documents.ts +71 -0
  23. package/src/api/endpoints/inspect.ts +16 -0
  24. package/src/api/endpoints/models.ts +10 -0
  25. package/src/api/endpoints/query.ts +88 -0
  26. package/src/api/endpoints/root.ts +7 -0
  27. package/src/api/endpoints/search-reindex.ts +65 -0
  28. package/src/api/endpoints/search-status.ts +55 -0
  29. package/src/api/endpoints/search.ts +104 -0
  30. package/src/api/endpoints/semantic-search.ts +120 -0
  31. package/src/api/endpoints/text-search.ts +63 -0
  32. package/src/api/endpoints/validate.ts +34 -0
  33. package/src/api/helpers.ts +97 -0
  34. package/src/base-model.ts +12 -0
  35. package/src/cli/commands/action.ts +82 -44
  36. package/src/cli/commands/console.ts +124 -0
  37. package/src/cli/commands/create.ts +179 -53
  38. package/src/cli/commands/embed.ts +323 -0
  39. package/src/cli/commands/export.ts +58 -24
  40. package/src/cli/commands/extract.ts +174 -0
  41. package/src/cli/commands/help.ts +81 -0
  42. package/src/cli/commands/index.ts +17 -0
  43. package/src/cli/commands/init.ts +72 -48
  44. package/src/cli/commands/inspect.ts +56 -46
  45. package/src/cli/commands/mcp.ts +1219 -0
  46. package/src/cli/commands/search.ts +285 -0
  47. package/src/cli/commands/serve.ts +348 -0
  48. package/src/cli/commands/summary.ts +60 -0
  49. package/src/cli/commands/teach.ts +86 -0
  50. package/src/cli/commands/text-search.ts +134 -0
  51. package/src/cli/commands/validate.ts +126 -64
  52. package/src/cli/index.ts +88 -19
  53. package/src/cli/load-collection.ts +144 -17
  54. package/src/cli/registry.ts +28 -0
  55. package/src/collection.ts +361 -10
  56. package/src/define-model.ts +101 -4
  57. package/src/document.ts +47 -1
  58. package/src/extract-sections.ts +1 -1
  59. package/src/index.ts +14 -2
  60. package/src/model-instance.ts +35 -5
  61. package/src/node-shortcuts.ts +1 -1
  62. package/src/query/collection-query.ts +96 -9
  63. package/src/query/index.ts +7 -0
  64. package/src/query/query-dsl.ts +259 -0
  65. package/src/relationships/has-many.ts +39 -0
  66. package/src/relationships/index.ts +7 -2
  67. package/src/section.ts +2 -0
  68. package/src/types.ts +23 -1
  69. package/src/utils/index.ts +1 -0
  70. package/src/utils/match-pattern.ts +65 -0
  71. package/src/validator.ts +18 -1
  72. package/test/collection.test.ts +118 -2
  73. package/test/fixtures/sdlc/MODELS.md +106 -0
  74. package/test/fixtures/sdlc/SKILL.md +404 -0
  75. package/test/fixtures/sdlc/index.ts +9 -0
  76. package/test/fixtures/sdlc/models.ts +8 -6
  77. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  78. package/test/fixtures/sdlc/templates/epic.md +23 -0
  79. package/test/fixtures/sdlc/templates/story.md +19 -0
  80. package/test/pattern.test.ts +191 -0
  81. package/test/query-dsl.test.ts +431 -0
  82. package/test/query.test.ts +29 -0
  83. package/test/relationships.test.ts +130 -0
  84. package/test/section.test.ts +61 -0
  85. package/test/table-of-contents.test.ts +49 -5
@@ -0,0 +1,171 @@
1
+ import { z } from 'zod'
2
+ import matter from 'gray-matter'
3
+
4
+ export const path = '/api/documents/:pathId(.*)'
5
+ export const description = 'CRUD operations on a single document'
6
+ export const tags = ['documents']
7
+
8
+ export async function get(params: any, ctx: any) {
9
+ const collection = ctx.container._contentbaseCollection
10
+ const pathId = params.pathId
11
+
12
+ if (!collection.available.includes(pathId)) {
13
+ ctx.response.status(404)
14
+ return { error: `Document not found: ${pathId}` }
15
+ }
16
+
17
+ const doc = collection.document(pathId)
18
+ const modelDef = collection.findModelDefinition(pathId)
19
+
20
+ const result: Record<string, unknown> = {
21
+ id: doc.id,
22
+ title: doc.title,
23
+ meta: doc.meta,
24
+ content: doc.content,
25
+ outline: doc.toOutline(),
26
+ model: modelDef?.name || null,
27
+ }
28
+
29
+ if (modelDef) {
30
+ const instance = collection.getModel(pathId, modelDef)
31
+ const sectionKeys = modelDef.sections ? Object.keys(modelDef.sections) : []
32
+ const computedKeys = modelDef.computed ? Object.keys(modelDef.computed) : []
33
+ const relationshipKeys = modelDef.relationships ? Object.keys(modelDef.relationships) : []
34
+
35
+ if (sectionKeys.length) {
36
+ result.sections = {}
37
+ for (const key of sectionKeys) {
38
+ try {
39
+ (result.sections as any)[key] = instance.sections[key]
40
+ } catch {}
41
+ }
42
+ }
43
+
44
+ if (computedKeys.length) {
45
+ result.computed = {}
46
+ for (const key of computedKeys) {
47
+ try {
48
+ (result.computed as any)[key] = instance.computed[key]
49
+ } catch {}
50
+ }
51
+ }
52
+
53
+ if (relationshipKeys.length) {
54
+ result.relationships = {}
55
+ for (const key of relationshipKeys) {
56
+ try {
57
+ const rel = (instance.relationships as any)[key]
58
+ if ('fetchAll' in rel) {
59
+ (result.relationships as any)[key] = rel.fetchAll().map((i: any) => ({ id: i.id, title: i.title }))
60
+ } else if ('fetch' in rel) {
61
+ const parent = rel.fetch()
62
+ (result.relationships as any)[key] = parent ? { id: parent.id, title: parent.title } : null
63
+ }
64
+ } catch {}
65
+ }
66
+ }
67
+ }
68
+
69
+ return result
70
+ }
71
+
72
+ export const putSchema = z.object({
73
+ meta: z.record(z.string(), z.any()).optional(),
74
+ content: z.string().optional(),
75
+ })
76
+
77
+ export async function put(params: any, ctx: any) {
78
+ if (ctx.container._contentbaseReadOnly) {
79
+ ctx.response.status(403)
80
+ return { error: 'Server is running in read-only mode' }
81
+ }
82
+
83
+ const collection = ctx.container._contentbaseCollection
84
+ const pathId = ctx.params.pathId
85
+
86
+ if (!collection.available.includes(pathId)) {
87
+ ctx.response.status(404)
88
+ return { error: `Document not found: ${pathId}` }
89
+ }
90
+
91
+ const doc = collection.document(pathId)
92
+ const currentMeta = { ...doc.meta }
93
+ const newMeta = params.meta ? { ...currentMeta, ...params.meta } : currentMeta
94
+ const newContent = params.content ?? doc.content
95
+
96
+ const fullContent = matter.stringify(newContent, newMeta)
97
+ await collection.saveItem(pathId, { content: fullContent })
98
+
99
+ return { updated: pathId, meta: newMeta }
100
+ }
101
+
102
+ export const patchSchema = z.object({
103
+ heading: z.string(),
104
+ action: z.enum(['replace', 'append', 'remove']),
105
+ content: z.string().optional(),
106
+ })
107
+
108
+ export async function patch(params: any, ctx: any) {
109
+ if (ctx.container._contentbaseReadOnly) {
110
+ ctx.response.status(403)
111
+ return { error: 'Server is running in read-only mode' }
112
+ }
113
+
114
+ const collection = ctx.container._contentbaseCollection
115
+ const pathId = ctx.params.pathId
116
+
117
+ if (!collection.available.includes(pathId)) {
118
+ ctx.response.status(404)
119
+ return { error: `Document not found: ${pathId}` }
120
+ }
121
+
122
+ let doc = collection.document(pathId)
123
+
124
+ switch (params.action) {
125
+ case 'replace': {
126
+ if (!params.content) {
127
+ ctx.response.status(400)
128
+ return { error: 'Content is required for replace action' }
129
+ }
130
+ doc = doc.replaceSectionContent(params.heading, params.content)
131
+ break
132
+ }
133
+ case 'append': {
134
+ if (!params.content) {
135
+ ctx.response.status(400)
136
+ return { error: 'Content is required for append action' }
137
+ }
138
+ doc = doc.appendToSection(params.heading, params.content)
139
+ break
140
+ }
141
+ case 'remove': {
142
+ doc = doc.removeSection(params.heading)
143
+ break
144
+ }
145
+ }
146
+
147
+ const fullContent = matter.stringify(doc.content, doc.meta)
148
+ await collection.saveItem(pathId, { content: fullContent })
149
+
150
+ return { updated: pathId, action: params.action, heading: params.heading }
151
+ }
152
+
153
+ async function del(_params: any, ctx: any) {
154
+ if (ctx.container._contentbaseReadOnly) {
155
+ ctx.response.status(403)
156
+ return { error: 'Server is running in read-only mode' }
157
+ }
158
+
159
+ const collection = ctx.container._contentbaseCollection
160
+ const pathId = ctx.params.pathId
161
+
162
+ if (!collection.available.includes(pathId)) {
163
+ ctx.response.status(404)
164
+ return { error: `Document not found: ${pathId}` }
165
+ }
166
+
167
+ await collection.deleteItem(pathId)
168
+ return { deleted: pathId }
169
+ }
170
+
171
+ export { del as delete }
@@ -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,7 @@
1
+ export const path = '/'
2
+ export const description = 'Redirect to docs table of contents when no static index.html exists'
3
+ export const tags = ['docs']
4
+
5
+ export async function get(_params: any, ctx: any) {
6
+ ctx.response.redirect('/docs/')
7
+ }
@@ -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
+ }