contentbase 0.1.8 → 0.4.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.
Files changed (40) hide show
  1. package/CNAME +1 -0
  2. package/LAUNCH-PLAN.md +419 -0
  3. package/README.md +90 -0
  4. package/index.html +1636 -0
  5. package/package.json +5 -4
  6. package/src/adapters/node-fs.ts +80 -0
  7. package/src/cli/commands/api/endpoints/doc.ts +2 -2
  8. package/src/cli/commands/api/endpoints/document.ts +2 -2
  9. package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
  10. package/src/cli/commands/api/endpoints/search-status.ts +3 -20
  11. package/src/cli/commands/api/endpoints/search.ts +3 -33
  12. package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
  13. package/src/cli/commands/embed.ts +8 -81
  14. package/src/cli/commands/mcp.ts +5 -1128
  15. package/src/cli/commands/search.ts +25 -90
  16. package/src/cli/commands/serve.ts +7 -32
  17. package/src/cli/index.ts +1 -1
  18. package/src/cli/load-collection.ts +2 -2
  19. package/src/collection.ts +42 -33
  20. package/src/document.ts +18 -0
  21. package/src/extract-sections.ts +3 -0
  22. package/src/index.ts +10 -0
  23. package/src/mcp/helpers.ts +7 -0
  24. package/src/mcp/model-info.ts +102 -0
  25. package/src/mcp/prompts.ts +199 -0
  26. package/src/mcp/readme.ts +153 -0
  27. package/src/mcp/resources.ts +89 -0
  28. package/src/mcp/server.ts +103 -0
  29. package/src/mcp/tools/mutation.ts +176 -0
  30. package/src/mcp/tools/query.ts +141 -0
  31. package/src/mcp/tools/search.ts +180 -0
  32. package/src/parse.ts +5 -0
  33. package/src/query/query-dsl.ts +1 -1
  34. package/src/relationships/has-many.ts +4 -2
  35. package/src/search/document-inputs.ts +85 -0
  36. package/src/search/luca-semantic-search.ts +75 -0
  37. package/src/types.ts +54 -0
  38. package/src/utils/index.ts +2 -0
  39. package/src/utils/match-pattern.ts +2 -2
  40. package/src/utils/strip-markdown.ts +59 -0
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "contentbase",
3
- "version": "0.1.8",
3
+ "version": "0.4.0",
4
4
  "repository": "https://github.com/soederpop/contentbase",
5
5
  "website": "https://contentbase.soederpop.com",
6
6
  "type": "module",
7
- "main": "./src/index.ts",
8
- "types": "./src/index.ts",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
9
  "bin": {
10
10
  "cnotes": "./src/cli/index.ts",
11
11
  "contentbase": "./src/cli/index.ts"
@@ -13,6 +13,7 @@
13
13
  "scripts": {
14
14
  "test": "vitest run",
15
15
  "test:watch": "vitest",
16
+ "build": "find dist -name '*.d.ts' -o -name '*.d.ts.map' -o -name '*.js' -o -name '*.js.map' | xargs rm -f 2>/dev/null; tsc",
16
17
  "typecheck": "tsc --noEmit",
17
18
  "examples": "bun run scripts/examples/run-all.ts",
18
19
  "compile": "bun build ./src/cli/index.ts --compile --outfile dist/cnotes --external @node-llama-cpp/*",
@@ -20,7 +21,7 @@
20
21
  "release": "./scripts/release.sh"
21
22
  },
22
23
  "dependencies": {
23
- "@soederpop/luca": ">=0.0.16",
24
+ "luca": ">= 3.0.2",
24
25
  "gray-matter": "^4.0.3",
25
26
  "js-yaml": "^4.1.0",
26
27
  "mdast-util-mdxjs-esm": "^2.0.1",
@@ -0,0 +1,80 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import type { StorageAdapter, FileEntry, FileStat } from "../types";
4
+
5
+ /**
6
+ * Default StorageAdapter that reads and writes from the local file system
7
+ * using Node's fs/promises module. This is used automatically when no
8
+ * adapter is passed to the Collection constructor.
9
+ */
10
+ export class NodeStorageAdapter implements StorageAdapter {
11
+ async listFiles(rootPath: string, match: RegExp): Promise<FileEntry[]> {
12
+ const results: FileEntry[] = [];
13
+
14
+ const scan = async (dirPath: string) => {
15
+ let entries: string[];
16
+ try {
17
+ entries = await fs.readdir(dirPath);
18
+ } catch {
19
+ return;
20
+ }
21
+
22
+ for (const entry of entries) {
23
+ if (entry.startsWith(".")) continue;
24
+
25
+ const filePath = path.join(dirPath, entry);
26
+ const stat = await fs.stat(filePath);
27
+
28
+ if (stat.isDirectory()) {
29
+ await scan(filePath);
30
+ } else if (stat.isFile() && match.test(filePath)) {
31
+ results.push({
32
+ key: filePath,
33
+ stat: {
34
+ createdAt: stat.ctime,
35
+ updatedAt: stat.mtime,
36
+ size: stat.size,
37
+ },
38
+ });
39
+ }
40
+ }
41
+ };
42
+
43
+ await scan(rootPath);
44
+ return results;
45
+ }
46
+
47
+ async readFile(key: string): Promise<string> {
48
+ return fs.readFile(key, "utf8");
49
+ }
50
+
51
+ async stat(key: string): Promise<FileStat> {
52
+ const s = await fs.stat(key);
53
+ return { createdAt: s.ctime, updatedAt: s.mtime, size: s.size };
54
+ }
55
+
56
+ async writeFile(key: string, content: string): Promise<void> {
57
+ await fs.mkdir(path.parse(key).dir, { recursive: true });
58
+ await fs.writeFile(key, content, "utf8");
59
+ }
60
+
61
+ async deleteFile(key: string): Promise<void> {
62
+ try {
63
+ await fs.rm(key);
64
+ } catch {
65
+ // Silently ignore missing files — matches existing behavior
66
+ }
67
+ }
68
+
69
+ join(root: string, ...parts: string[]): string {
70
+ return path.resolve(root, ...parts);
71
+ }
72
+
73
+ relative(root: string, key: string): string {
74
+ return path.relative(root, key);
75
+ }
76
+
77
+ extname(key: string): string {
78
+ return path.extname(key);
79
+ }
80
+ }
@@ -173,8 +173,8 @@ ${html}
173
173
  if ('fetchAll' in rel) {
174
174
  (result.relationships as any)[key] = rel.fetchAll().map((i: any) => ({ id: i.id, title: i.title }))
175
175
  } else if ('fetch' in rel) {
176
- const parent = rel.fetch()
177
- (result.relationships as any)[key] = parent ? { id: parent.id, title: parent.title } : null
176
+ const parentInstance = rel.fetch();
177
+ (result.relationships as any)[key] = parentInstance ? { id: parentInstance.id, title: parentInstance.title } : null
178
178
  }
179
179
  } catch {}
180
180
  }
@@ -58,8 +58,8 @@ export async function get(params: any, ctx: any) {
58
58
  if ('fetchAll' in rel) {
59
59
  (result.relationships as any)[key] = rel.fetchAll().map((i: any) => ({ id: i.id, title: i.title }))
60
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
61
+ const parentInstance = rel.fetch();
62
+ (result.relationships as any)[key] = parentInstance ? { id: parentInstance.id, title: parentInstance.title } : null
63
63
  }
64
64
  } catch {}
65
65
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod'
2
- import pathModule from 'node:path'
2
+ import { collectDocumentInputs } from '../../../../search/document-inputs.js'
3
+ import { getInitializedSemanticSearch } from '../../../../search/luca-semantic-search.js'
3
4
 
4
5
  export const path = '/api/search/reindex'
5
6
  export const description = 'Trigger search index rebuild'
@@ -14,14 +15,7 @@ export async function post(params: any, ctx: any) {
14
15
  const collection = ctx.container._contentbaseCollection
15
16
  const rootPath = collection.rootPath
16
17
 
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()
18
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
25
19
 
26
20
  if (params.pathIds) {
27
21
  await ss.reindex(params.pathIds)
@@ -30,23 +24,10 @@ export async function post(params: any, ctx: any) {
30
24
  }
31
25
 
32
26
  // Collect and re-index documents
33
- const docs: any[] = []
34
27
  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
- }
28
+ const targetSet = new Set(targetIds)
29
+ const docs = collectDocumentInputs(collection)
30
+ .filter((doc) => targetSet.has(doc.pathId))
50
31
 
51
32
  const toIndex = params.force ? docs : docs.filter((d: any) => ss.needsReindex(d))
52
33
 
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod'
2
- import pathModule from 'node:path'
3
- import { existsSync, readdirSync } from 'node:fs'
2
+ import { getInitializedSemanticSearch, hasSearchIndex } from '../../../../search/luca-semantic-search.js'
4
3
 
5
4
  export const path = '/api/search/status'
6
5
  export const description = 'Search index health and statistics'
@@ -11,16 +10,7 @@ export const getSchema = z.object({})
11
10
  export async function get(_params: any, ctx: any) {
12
11
  const collection = ctx.container._contentbaseCollection
13
12
  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
- })()
13
+ const hasIndex = hasSearchIndex(rootPath)
24
14
 
25
15
  if (!hasIndex) {
26
16
  return {
@@ -37,14 +27,7 @@ export async function get(_params: any, ctx: any) {
37
27
  }
38
28
  }
39
29
 
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()
30
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
48
31
 
49
32
  const stats = ss.getStats()
50
33
  return {
@@ -1,40 +1,10 @@
1
1
  import { z } from 'zod'
2
- import pathModule from 'node:path'
3
- import { existsSync, readdirSync } from 'node:fs'
2
+ import { getInitializedSemanticSearch, hasSearchIndex } from '../../../../search/luca-semantic-search.js'
4
3
 
5
4
  export const path = '/api/search'
6
5
  export const description = 'Search across collection documents using keyword, semantic, or hybrid modes'
7
6
  export const tags = ['query']
8
7
 
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
8
  async function doSearch(ss: any, query: string, mode: string, options: any) {
39
9
  switch (mode) {
40
10
  case 'keyword':
@@ -65,7 +35,7 @@ export async function get(params: any, ctx: any) {
65
35
  return { error: 'No search index found. Run: cnotes embed' }
66
36
  }
67
37
 
68
- const ss = await getSemanticSearch(ctx.container, rootPath)
38
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
69
39
  const mode = params.mode || 'hybrid'
70
40
  const limit = params.limit ? parseInt(params.limit, 10) : 10
71
41
  const searchOptions = { limit, model: params.model }
@@ -92,7 +62,7 @@ export async function post(params: any, ctx: any) {
92
62
  return { error: 'No search index found. Run: cnotes embed' }
93
63
  }
94
64
 
95
- const ss = await getSemanticSearch(ctx.container, rootPath)
65
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
96
66
  const mode = params.mode || 'hybrid'
97
67
  const searchOptions = {
98
68
  limit: params.limit || 10,
@@ -1,40 +1,10 @@
1
1
  import { z } from 'zod'
2
- import pathModule from 'node:path'
3
- import { existsSync, readdirSync } from 'node:fs'
2
+ import { getInitializedSemanticSearch, hasSearchIndex } from '../../../../search/luca-semantic-search.js'
4
3
 
5
4
  export const path = '/api/semantic-search'
6
5
  export const description = 'Semantic search across collection documents'
7
6
  export const tags = ['query']
8
7
 
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
8
  // ── GET /api/semantic-search ─────────────────────────────────────────
39
9
 
40
10
  export const getSchema = z.object({
@@ -53,7 +23,7 @@ export async function get(params: any, ctx: any) {
53
23
  return { error: 'No search index found. Run: cnotes embed' }
54
24
  }
55
25
 
56
- const ss = await getSemanticSearch(ctx.container, rootPath)
26
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
57
27
  const mode = params.mode || 'hybrid'
58
28
  const limit = params.limit ? parseInt(params.limit, 10) : 10
59
29
  const searchOptions = { limit, model: params.model }
@@ -94,7 +64,7 @@ export async function post(params: any, ctx: any) {
94
64
  return { error: 'No search index found. Run: cnotes embed' }
95
65
  }
96
66
 
97
- const ss = await getSemanticSearch(ctx.container, rootPath)
67
+ const ss = await getInitializedSemanticSearch(ctx.container, rootPath)
98
68
  const mode = params.mode || 'hybrid'
99
69
  const searchOptions = {
100
70
  limit: params.limit || 10,
@@ -1,8 +1,10 @@
1
1
  import { z } from 'zod'
2
- import { existsSync, readdirSync, accessSync, constants as fsConstants } from 'node:fs'
2
+ import { existsSync, accessSync, constants as fsConstants } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { commands } from '../registry.js'
5
5
  import { loadCollection } from '../load-collection.js'
6
+ import { collectDocumentInputs } from '../../search/document-inputs.js'
7
+ import { createSemanticSearch, ensureSemanticSearchAttached, getInitializedSemanticSearch, hasSearchIndex, loadSemanticSearchClass } from '../../search/luca-semantic-search.js'
6
8
 
7
9
  const argsSchema = z.object({
8
10
  force: z.boolean().default(false),
@@ -13,67 +15,6 @@ const argsSchema = z.object({
13
15
  contentFolder: z.string().optional(),
14
16
  })
15
17
 
16
- function hasSearchIndex(rootPath: string): boolean {
17
- const dbDir = path.join(rootPath, '.contentbase')
18
- if (!existsSync(dbDir)) return false
19
- try {
20
- const files = readdirSync(dbDir) as string[]
21
- return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
22
- } catch {
23
- return false
24
- }
25
- }
26
-
27
- function collectDocumentInputs(collection: any) {
28
- const inputs: any[] = []
29
- for (const pathId of collection.available) {
30
- const doc = collection.document(pathId)
31
- const modelDef = collection.findModelDefinition(pathId)
32
-
33
- const sections: any[] = []
34
- const lines = (doc.content as string).split('\n')
35
- let currentHeading: string | null = null
36
- let currentContent: string[] = []
37
-
38
- for (const line of lines) {
39
- const h2Match = line.match(/^## (.+)/)
40
- if (h2Match) {
41
- if (currentHeading) {
42
- sections.push({
43
- heading: currentHeading,
44
- headingPath: currentHeading,
45
- content: currentContent.join('\n').trim(),
46
- level: 2,
47
- })
48
- }
49
- currentHeading = h2Match[1].trim()
50
- currentContent = []
51
- } else if (currentHeading) {
52
- currentContent.push(line)
53
- }
54
- }
55
- if (currentHeading) {
56
- sections.push({
57
- heading: currentHeading,
58
- headingPath: currentHeading,
59
- content: currentContent.join('\n').trim(),
60
- level: 2,
61
- })
62
- }
63
-
64
- inputs.push({
65
- pathId,
66
- model: modelDef?.name ?? undefined,
67
- title: doc.title,
68
- slug: doc.slug,
69
- meta: doc.meta,
70
- content: doc.content,
71
- sections: sections.length > 0 ? sections : undefined,
72
- })
73
- }
74
- return inputs
75
- }
76
-
77
18
  function isLocalInstalled(): boolean {
78
19
  const modulePath = path.join(process.cwd(), 'node_modules', 'node-llama-cpp')
79
20
  try {
@@ -103,10 +44,8 @@ async function installLocal(SemanticSearch: any, container: any): Promise<void>
103
44
  console.error(`Installing node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION}...`)
104
45
 
105
46
  // Create a temporary instance to use the install method
106
- if (!container.features.available.includes('semanticSearch')) {
107
- SemanticSearch.attach(container)
108
- }
109
- const ss = container.feature('semanticSearch', {
47
+ await ensureSemanticSearchAttached(container, SemanticSearch)
48
+ const ss = await createSemanticSearch(container, cwd, {
110
49
  dbPath: path.join(cwd, '.contentbase/search.sqlite'),
111
50
  embeddingProvider: 'local',
112
51
  })
@@ -138,7 +77,7 @@ function detectInstallCommand(cwd: string, version: string): string {
138
77
  }
139
78
 
140
79
  async function handler(options: z.infer<typeof argsSchema>, { container }: { container: any }) {
141
- const { SemanticSearch } = await import('@soederpop/luca/agi')
80
+ const SemanticSearch = await loadSemanticSearchClass()
142
81
 
143
82
  // --install-local: install node-llama-cpp only, then exit
144
83
  if (options.installLocal) {
@@ -171,15 +110,9 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
171
110
  return
172
111
  }
173
112
 
174
- if (!container.features.available.includes('semanticSearch')) {
175
- SemanticSearch.attach(container)
176
- }
177
- const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
178
- const ss = container.feature('semanticSearch', {
179
- dbPath,
113
+ const ss = await getInitializedSemanticSearch(container, collection.rootPath, {
180
114
  embeddingProvider: provider,
181
115
  })
182
- await ss.initDb()
183
116
 
184
117
  const stats = ss.getStats()
185
118
  console.log('Search Index Status')
@@ -206,15 +139,9 @@ async function handler(options: z.infer<typeof argsSchema>, { container }: { con
206
139
  }
207
140
 
208
141
  // Embed / re-embed
209
- if (!container.features.available.includes('semanticSearch')) {
210
- SemanticSearch.attach(container)
211
- }
212
- const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
213
- const ss = container.feature('semanticSearch', {
214
- dbPath,
142
+ const ss = await getInitializedSemanticSearch(container, collection.rootPath, {
215
143
  embeddingProvider: provider,
216
144
  })
217
- await ss.initDb()
218
145
 
219
146
  const docs = collectDocumentInputs(collection)
220
147
  const startTime = Date.now()