contentbase 0.0.2 → 0.0.5

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 (130) hide show
  1. package/CLI.md +593 -0
  2. package/MCP-SERVER-SPEC.md +453 -0
  3. package/PRIMER.md +540 -0
  4. package/README.md +289 -13
  5. package/docs/README.md +110 -0
  6. package/docs/TABLE-OF-CONTENTS.md +7 -0
  7. package/docs/models.ts +38 -0
  8. package/models.ts +38 -0
  9. package/package.json +12 -3
  10. package/src/api/endpoints/actions.ts +34 -0
  11. package/src/api/endpoints/doc.ts +187 -0
  12. package/src/api/endpoints/docs-index.ts +26 -0
  13. package/src/api/endpoints/document.ts +171 -0
  14. package/src/api/endpoints/documents.ts +71 -0
  15. package/src/api/endpoints/inspect.ts +16 -0
  16. package/src/api/endpoints/models.ts +10 -0
  17. package/src/api/endpoints/query.ts +88 -0
  18. package/src/api/endpoints/root.ts +7 -0
  19. package/src/api/endpoints/search-reindex.ts +65 -0
  20. package/src/api/endpoints/search-status.ts +55 -0
  21. package/src/api/endpoints/search.ts +104 -0
  22. package/src/api/endpoints/semantic-search.ts +120 -0
  23. package/src/api/endpoints/text-search.ts +63 -0
  24. package/src/api/endpoints/validate.ts +34 -0
  25. package/src/api/helpers.ts +97 -0
  26. package/src/base-model.ts +12 -0
  27. package/src/cli/commands/action.ts +82 -44
  28. package/src/cli/commands/console.ts +124 -0
  29. package/src/cli/commands/create.ts +179 -53
  30. package/src/cli/commands/embed.ts +323 -0
  31. package/src/cli/commands/export.ts +58 -24
  32. package/src/cli/commands/extract.ts +174 -0
  33. package/src/cli/commands/help.ts +81 -0
  34. package/src/cli/commands/index.ts +17 -0
  35. package/src/cli/commands/init.ts +72 -48
  36. package/src/cli/commands/inspect.ts +56 -46
  37. package/src/cli/commands/mcp.ts +1219 -0
  38. package/src/cli/commands/search.ts +285 -0
  39. package/src/cli/commands/serve.ts +348 -0
  40. package/src/cli/commands/summary.ts +60 -0
  41. package/src/cli/commands/teach.ts +86 -0
  42. package/src/cli/commands/text-search.ts +134 -0
  43. package/src/cli/commands/validate.ts +126 -64
  44. package/src/cli/index.ts +88 -19
  45. package/src/cli/load-collection.ts +144 -17
  46. package/src/cli/registry.ts +28 -0
  47. package/src/collection.ts +361 -10
  48. package/src/define-model.ts +101 -4
  49. package/src/document.ts +47 -1
  50. package/src/extract-sections.ts +1 -1
  51. package/src/index.ts +14 -2
  52. package/src/model-instance.ts +35 -5
  53. package/src/node-shortcuts.ts +1 -1
  54. package/src/query/collection-query.ts +96 -9
  55. package/src/query/index.ts +7 -0
  56. package/src/query/query-dsl.ts +259 -0
  57. package/src/relationships/has-many.ts +39 -0
  58. package/src/relationships/index.ts +7 -2
  59. package/src/section.ts +2 -0
  60. package/src/types.ts +23 -1
  61. package/src/utils/index.ts +1 -0
  62. package/src/utils/match-pattern.ts +65 -0
  63. package/src/validator.ts +18 -1
  64. package/bun.lock +0 -473
  65. package/examples/sdlc-queries.ts +0 -161
  66. package/scripts/examples/01-collection-setup.ts +0 -46
  67. package/scripts/examples/02-querying.ts +0 -67
  68. package/scripts/examples/03-sections.ts +0 -36
  69. package/scripts/examples/04-relationships.ts +0 -54
  70. package/scripts/examples/05-document-api.ts +0 -54
  71. package/scripts/examples/06-extract-sections.ts +0 -55
  72. package/scripts/examples/07-validation.ts +0 -46
  73. package/scripts/examples/08-serialization.ts +0 -51
  74. package/scripts/examples/lib/format.ts +0 -87
  75. package/scripts/examples/lib/setup.ts +0 -21
  76. package/scripts/examples/run-all.ts +0 -43
  77. package/showcases/national-parks/models.ts +0 -74
  78. package/showcases/national-parks/parks/acadia.mdx +0 -40
  79. package/showcases/national-parks/parks/yosemite.mdx +0 -44
  80. package/showcases/national-parks/parks/zion.mdx +0 -44
  81. package/showcases/national-parks/queries.ts +0 -103
  82. package/showcases/national-parks/trails/angels-landing.mdx +0 -19
  83. package/showcases/national-parks/trails/cathedral-lakes.mdx +0 -19
  84. package/showcases/national-parks/trails/half-dome.mdx +0 -19
  85. package/showcases/national-parks/trails/jordan-pond-path.mdx +0 -19
  86. package/showcases/national-parks/trails/mist-trail.mdx +0 -19
  87. package/showcases/national-parks/trails/observation-point.mdx +0 -19
  88. package/showcases/national-parks/trails/precipice-trail.mdx +0 -19
  89. package/showcases/national-parks/trails/the-narrows.mdx +0 -19
  90. package/showcases/recipes/cuisines/chinese.mdx +0 -28
  91. package/showcases/recipes/cuisines/italian.mdx +0 -32
  92. package/showcases/recipes/cuisines/mexican.mdx +0 -28
  93. package/showcases/recipes/models.ts +0 -77
  94. package/showcases/recipes/queries.ts +0 -89
  95. package/showcases/recipes/recipes/chinese/egg-fried-rice.mdx +0 -43
  96. package/showcases/recipes/recipes/chinese/mapo-tofu.mdx +0 -47
  97. package/showcases/recipes/recipes/italian/bruschetta.mdx +0 -38
  98. package/showcases/recipes/recipes/italian/cacio-e-pepe.mdx +0 -39
  99. package/showcases/recipes/recipes/italian/tiramisu.mdx +0 -43
  100. package/showcases/recipes/recipes/mexican/chicken-tinga.mdx +0 -44
  101. package/showcases/recipes/recipes/mexican/guacamole.mdx +0 -39
  102. package/showcases/vinyl-collection/albums/bitches-brew.mdx +0 -36
  103. package/showcases/vinyl-collection/albums/i-put-a-spell-on-you.mdx +0 -35
  104. package/showcases/vinyl-collection/albums/in-rainbows.mdx +0 -35
  105. package/showcases/vinyl-collection/albums/kind-of-blue.mdx +0 -32
  106. package/showcases/vinyl-collection/albums/ok-computer.mdx +0 -37
  107. package/showcases/vinyl-collection/albums/wild-is-the-wind.mdx +0 -35
  108. package/showcases/vinyl-collection/artists/miles-davis.mdx +0 -27
  109. package/showcases/vinyl-collection/artists/nina-simone.mdx +0 -26
  110. package/showcases/vinyl-collection/artists/radiohead.mdx +0 -27
  111. package/showcases/vinyl-collection/models.ts +0 -73
  112. package/showcases/vinyl-collection/queries.ts +0 -87
  113. package/test/ast-query.test.ts +0 -128
  114. package/test/collection.test.ts +0 -99
  115. package/test/define-model.test.ts +0 -78
  116. package/test/document.test.ts +0 -225
  117. package/test/extract-sections.test.ts +0 -356
  118. package/test/fixtures/sdlc/epics/authentication.mdx +0 -42
  119. package/test/fixtures/sdlc/epics/searching-and-browsing.mdx +0 -21
  120. package/test/fixtures/sdlc/models.ts +0 -89
  121. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-register.mdx +0 -20
  122. package/test/helpers.ts +0 -21
  123. package/test/model-instance.test.ts +0 -197
  124. package/test/query.test.ts +0 -167
  125. package/test/relationships.test.ts +0 -84
  126. package/test/section.test.ts +0 -99
  127. package/test/table-of-contents.test.ts +0 -91
  128. package/test/validator.test.ts +0 -62
  129. package/tsconfig.json +0 -18
  130. package/vitest.config.ts +0 -11
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod'
2
+ import { commands } from '../registry.js'
3
+ import { loadCollection } from '../load-collection.js'
4
+
5
+ const argsSchema = z.object({
6
+ contentFolder: z.string().optional(),
7
+ })
8
+
9
+ async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
10
+ const container = context.container
11
+ const ui = container.feature('ui')
12
+
13
+ const collection = await loadCollection({
14
+ contentFolder: options.contentFolder,
15
+ })
16
+
17
+ const featureContext: Record<string, any> = {
18
+ collection,
19
+ }
20
+
21
+ // Expose all container features
22
+ for (const name of container.features.available) {
23
+ try {
24
+ featureContext[name] = container.feature(name)
25
+ } catch {}
26
+ }
27
+
28
+ // Load user console module if present
29
+ const consoleModulePath = container.paths.resolve('cnotes.console.ts')
30
+ let consoleModuleLoaded = false
31
+ let consoleModuleError: Error | null = null
32
+
33
+ if (container.fs.exists(consoleModulePath)) {
34
+ try {
35
+ const vmFeature = container.feature('vm')
36
+ const userExports = vmFeature.loadModule(consoleModulePath, { container, console, collection })
37
+ Object.assign(featureContext, userExports)
38
+ consoleModuleLoaded = true
39
+ } catch (err: any) {
40
+ consoleModuleError = err
41
+ }
42
+ }
43
+
44
+ const prompt = ui.colors.cyan('cnotes') + ui.colors.dim(' > ')
45
+
46
+ console.log()
47
+ console.log(ui.colors.dim(' Contentbase REPL — collection and container features in scope. Tab to autocomplete.'))
48
+ if (consoleModuleLoaded) {
49
+ console.log(ui.colors.dim(' Loaded cnotes.console.ts exports into scope.'))
50
+ } else if (consoleModuleError) {
51
+ console.log(ui.colors.yellow(' Warning: Failed to load cnotes.console.ts:'))
52
+ console.log(ui.colors.yellow(` ${consoleModuleError.message}`))
53
+ console.log(ui.colors.dim(' The REPL will start without your custom exports.'))
54
+ }
55
+ console.log(ui.colors.dim(' Type .exit to quit.'))
56
+ console.log()
57
+
58
+ const repl = container.feature('repl', { prompt })
59
+ await repl.start({
60
+ context: {
61
+ ...featureContext,
62
+ console,
63
+ setTimeout,
64
+ setInterval,
65
+ clearTimeout,
66
+ clearInterval,
67
+ fetch,
68
+ Bun,
69
+ },
70
+ })
71
+ }
72
+
73
+ commands.register('console', {
74
+ description: 'Start an interactive REPL with collection and container features in scope',
75
+ help: `# cnotes console
76
+
77
+ Start an interactive REPL with the loaded collection, all container features, and optional user-defined exports in scope. Useful for exploring and debugging your content.
78
+
79
+ ## Usage
80
+
81
+ \`\`\`
82
+ cnotes console [options]
83
+ \`\`\`
84
+
85
+ ## Options
86
+
87
+ | Option | Description |
88
+ |--------|-------------|
89
+ | \`--contentFolder\` | Path to content folder |
90
+
91
+ ## Scope
92
+
93
+ The REPL has these variables available:
94
+
95
+ - \`collection\` — The loaded Collection instance
96
+ - All luca container features (fs, ui, grep, etc.)
97
+ - Exports from \`cnotes.console.ts\` if present in the project root
98
+
99
+ ## Custom Console Module
100
+
101
+ Create a \`cnotes.console.ts\` file in your project root to add custom helpers to the REPL scope:
102
+
103
+ \`\`\`typescript
104
+ export const myHelper = () => "hello from console"
105
+ \`\`\`
106
+
107
+ ## Examples
108
+
109
+ \`\`\`bash
110
+ # Start the REPL
111
+ cnotes console
112
+
113
+ # Start with a specific content folder
114
+ cnotes console --contentFolder ./docs
115
+
116
+ # Once inside the REPL:
117
+ # collection.available — list all document IDs
118
+ # collection.document('posts/hello') — load a document
119
+ # .exit — quit
120
+ \`\`\`
121
+ `,
122
+ argsSchema,
123
+ handler,
124
+ })
@@ -1,59 +1,185 @@
1
- import { defineCommand } from "citty";
2
- import fs from "fs/promises";
3
- import path from "path";
4
- import { loadCollection } from "../load-collection";
5
- import { kebabCase } from "../../utils/inflect";
6
-
7
- export default defineCommand({
8
- meta: {
9
- name: "create",
10
- description: "Create a new document for a model type",
11
- },
12
- args: {
13
- model: {
14
- type: "positional",
15
- description: "Model name",
16
- required: true,
17
- },
18
- title: {
19
- type: "string",
20
- description: "Document title",
21
- required: true,
22
- },
23
- rootPath: {
24
- type: "string",
25
- description: "Root path for the collection",
26
- alias: "r",
27
- },
28
- },
29
- async run({ args }) {
30
- const collection = await loadCollection({
31
- rootPath: args.rootPath as string | undefined,
32
- });
33
-
34
- const modelName = args.model as string;
35
- const title = args.title as string;
36
- const def = collection.getModelDefinition(modelName);
37
-
38
- if (!def) {
39
- console.error(
40
- `Model "${modelName}" not found. Available: ${collection.modelDefinitions.map((d) => d.name).join(", ")}`
41
- );
42
- process.exit(1);
1
+ import { z } from 'zod'
2
+ import fs from 'fs/promises'
3
+ import path from 'path'
4
+ import matter from 'gray-matter'
5
+ import { commands } from '../registry.js'
6
+ import { loadCollection } from '../load-collection.js'
7
+ import { kebabCase } from '../../utils/inflect.js'
8
+ import { introspectMetaSchema } from '../../collection.js'
9
+
10
+ const argsSchema = z.object({
11
+ title: z.string().optional(),
12
+ contentFolder: z.string().optional(),
13
+ })
14
+
15
+ async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
16
+ const collection = await loadCollection({
17
+ contentFolder: options.contentFolder,
18
+ })
19
+
20
+ const modelName = context.container.argv._[1] as string | undefined
21
+ if (!modelName) {
22
+ console.error('Usage: cnotes create <model> --title "Document Title"')
23
+ process.exit(1)
24
+ }
25
+
26
+ const title = options.title
27
+ if (!title) {
28
+ console.error('--title is required')
29
+ process.exit(1)
30
+ }
31
+
32
+ const def =
33
+ collection.getModelDefinition(modelName) ??
34
+ collection.modelDefinitions.find(
35
+ (d) => d.name.toLowerCase() === modelName.toLowerCase()
36
+ )
37
+
38
+ if (!def) {
39
+ console.error(
40
+ `Model "${modelName}" not found. Available: ${collection.modelDefinitions.map((d) => d.name).join(', ')}`
41
+ )
42
+ process.exit(1)
43
+ }
44
+
45
+ // Parse --meta.* flags from raw argv
46
+ const metaOverrides: Record<string, unknown> = {}
47
+ const rawArgs = context.container.argv
48
+ for (const key of Object.keys(rawArgs)) {
49
+ if (key.startsWith('meta.')) {
50
+ metaOverrides[key.slice(5)] = rawArgs[key]
43
51
  }
52
+ }
53
+
54
+ // Build meta from priority layers: zod defaults < definition.defaults < template frontmatter < CLI overrides
55
+ const zodDefaults: Record<string, unknown> = {}
56
+ for (const field of introspectMetaSchema(def.meta)) {
57
+ if (field.defaultValue !== undefined) {
58
+ zodDefaults[field.name] = field.defaultValue
59
+ }
60
+ }
61
+
62
+ const definitionDefaults: Record<string, unknown> = def.defaults ?? {}
44
63
 
45
- const slug = kebabCase(title.toLowerCase());
46
- const pathId = `${def.prefix}/${slug}`;
47
- const filePath = path.resolve(
64
+ // Template lookup
65
+ const templateExtensions = ['md', 'mdx']
66
+ let templateContent: string | null = null
67
+
68
+ for (const ext of templateExtensions) {
69
+ const templatePath = path.resolve(
48
70
  collection.rootPath,
49
- `${pathId}.mdx`
50
- );
71
+ 'templates',
72
+ `${modelName.toLowerCase()}.${ext}`
73
+ )
74
+ try {
75
+ templateContent = await fs.readFile(templatePath, 'utf8')
76
+ break
77
+ } catch {
78
+ // template not found, try next extension
79
+ }
80
+ }
81
+
82
+ let content: string
83
+
84
+ if (templateContent) {
85
+ const parsed = matter(templateContent)
86
+
87
+ const mergedMeta = {
88
+ ...zodDefaults,
89
+ ...definitionDefaults,
90
+ ...parsed.data,
91
+ ...metaOverrides,
92
+ }
93
+
94
+ const body = parsed.content.replace(/^# .+$/m, `# ${title}`)
95
+ content = matter.stringify(body, mergedMeta)
96
+ } else {
97
+ const mergedMeta = {
98
+ ...zodDefaults,
99
+ ...definitionDefaults,
100
+ ...metaOverrides,
101
+ }
102
+
103
+ const lines: string[] = []
104
+ lines.push(`# ${title}`)
105
+ lines.push('')
106
+
107
+ const sections = def.sections ?? {}
108
+ for (const [, sec] of Object.entries(sections)) {
109
+ const s = sec as any
110
+ lines.push(`## ${s.heading}`)
111
+ lines.push('')
112
+ if (s.schema?.description) {
113
+ lines.push(s.schema.description)
114
+ lines.push('')
115
+ }
116
+ }
117
+
118
+ content = matter.stringify(lines.join('\n'), mergedMeta)
119
+ }
120
+
121
+ const slug = kebabCase(title.toLowerCase())
122
+ const pathId = `${def.prefix}/${slug}`
123
+ const filePath = path.resolve(collection.rootPath, `${pathId}.md`)
124
+
125
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
126
+ await fs.writeFile(filePath, content, 'utf8')
127
+
128
+ console.log(`Created ${filePath}`)
129
+ }
130
+
131
+ commands.register('create', {
132
+ description: 'Create a new document for a model type',
133
+ help: `# cnotes create
134
+
135
+ Create a new document for a registered model, with proper frontmatter defaults and section scaffolding.
136
+
137
+ ## Usage
138
+
139
+ \`\`\`
140
+ cnotes create <model> --title "Document Title" [options]
141
+ \`\`\`
142
+
143
+ ## Arguments
144
+
145
+ | Argument | Description |
146
+ |----------|-------------|
147
+ | \`model\` | Model name (e.g. \`Post\`, \`Epic\`, \`Task\`) |
148
+
149
+ ## Options
150
+
151
+ | Option | Description |
152
+ |--------|-------------|
153
+ | \`--title\` | **Required.** Title for the new document (used as H1 and slug) |
154
+ | \`--meta.*\` | Set frontmatter fields (e.g. \`--meta.status active\`) |
155
+ | \`--contentFolder\` | Path to content folder |
156
+
157
+ ## Template Lookup
158
+
159
+ If \`templates/<model>.md\` (or \`.mdx\`) exists in the content root, it will be used as the document scaffold. Template frontmatter is merged with schema defaults and CLI overrides.
160
+
161
+ ## Meta Priority
162
+
163
+ Frontmatter values are merged in this order (last wins):
164
+
165
+ 1. Zod schema defaults
166
+ 2. Model definition defaults
167
+ 3. Template frontmatter
168
+ 4. CLI \`--meta.*\` overrides
169
+
170
+ ## Examples
51
171
 
52
- const content = `---\n---\n\n# ${title}\n`;
172
+ \`\`\`bash
173
+ # Create a new post
174
+ cnotes create Post --title "My First Post"
53
175
 
54
- await fs.mkdir(path.dirname(filePath), { recursive: true });
55
- await fs.writeFile(filePath, content, "utf8");
176
+ # Create with meta overrides
177
+ cnotes create Task --title "Fix login bug" --meta.status active --meta.priority high
56
178
 
57
- console.log(`Created ${filePath}`);
58
- },
59
- });
179
+ # Create from a different content folder
180
+ cnotes create Epic --title "Auth System" --contentFolder ./docs
181
+ \`\`\`
182
+ `,
183
+ argsSchema,
184
+ handler,
185
+ })
@@ -0,0 +1,323 @@
1
+ import { z } from 'zod'
2
+ import { existsSync, readdirSync, accessSync, constants as fsConstants } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+
7
+ const argsSchema = z.object({
8
+ force: z.boolean().default(false),
9
+ provider: z.enum(['local', 'openai']).default('openai'),
10
+ status: z.boolean().default(false),
11
+ local: z.boolean().default(false),
12
+ installLocal: z.boolean().default(false),
13
+ contentFolder: z.string().optional(),
14
+ })
15
+
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
+ function isLocalInstalled(): boolean {
78
+ const modulePath = path.join(process.cwd(), 'node_modules', 'node-llama-cpp')
79
+ try {
80
+ return existsSync(modulePath)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
85
+
86
+ async function installLocal(SemanticSearch: any, container: any): Promise<void> {
87
+ const cwd = process.cwd()
88
+ const nodeModules = path.join(cwd, 'node_modules')
89
+
90
+ // Check permissions
91
+ try {
92
+ if (existsSync(nodeModules)) {
93
+ accessSync(nodeModules, fsConstants.W_OK)
94
+ }
95
+ } catch {
96
+ const version = SemanticSearch.PINNED_LLAMA_VERSION
97
+ const cmd = detectInstallCommand(cwd, version)
98
+ console.error(`Cannot write to ${nodeModules}.`)
99
+ console.error(`Run manually with elevated permissions:\n ${cmd}`)
100
+ process.exit(1)
101
+ }
102
+
103
+ console.error(`Installing node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION}...`)
104
+
105
+ // 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', {
110
+ dbPath: path.join(cwd, '.contentbase/search.sqlite'),
111
+ embeddingProvider: 'local',
112
+ })
113
+
114
+ try {
115
+ await ss.installLocalEmbeddings(cwd)
116
+ console.log(`node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION} installed successfully.`)
117
+ } catch (err: any) {
118
+ const msg = err?.message ?? String(err)
119
+ if (msg.includes('network') || msg.includes('ETIMEDOUT') || msg.includes('ENOTFOUND')) {
120
+ console.error(`Install failed (network unreachable). To use local embeddings offline, manually install node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION} into this project.`)
121
+ } else {
122
+ console.error(msg)
123
+ }
124
+ process.exit(1)
125
+ }
126
+ }
127
+
128
+ function detectInstallCommand(cwd: string, version: string): string {
129
+ const pkg = `node-llama-cpp@${version}`
130
+ if (existsSync(path.join(cwd, 'bun.lockb')) || existsSync(path.join(cwd, 'bun.lock'))) {
131
+ return `bun add --optional ${pkg}`
132
+ } else if (existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
133
+ return `pnpm add --save-optional ${pkg}`
134
+ } else if (existsSync(path.join(cwd, 'yarn.lock'))) {
135
+ return `yarn add --optional ${pkg}`
136
+ }
137
+ return `npm install --save-optional ${pkg}`
138
+ }
139
+
140
+ async function handler(options: z.infer<typeof argsSchema>, { container }: { container: any }) {
141
+ const { SemanticSearch } = await import('@soederpop/luca/agi')
142
+
143
+ // --install-local: install node-llama-cpp only, then exit
144
+ if (options.installLocal) {
145
+ if (isLocalInstalled()) {
146
+ console.log('node-llama-cpp is already installed. Skipping.')
147
+ return
148
+ }
149
+ await installLocal(SemanticSearch, container)
150
+ return
151
+ }
152
+
153
+ // Resolve effective provider: --local flag overrides --provider
154
+ const provider = options.local ? 'local' : options.provider
155
+
156
+ // --local: auto-install if needed
157
+ if (options.local && !isLocalInstalled()) {
158
+ await installLocal(SemanticSearch, container)
159
+ }
160
+
161
+ const collection = await loadCollection({
162
+ contentFolder: options.contentFolder,
163
+ })
164
+
165
+ // --status: show index health without modifying anything
166
+ if (options.status) {
167
+ if (!hasSearchIndex(collection.rootPath)) {
168
+ console.log('No search index found.')
169
+ console.log(`Documents in collection: ${collection.available.length}`)
170
+ console.log(`\nRun: cnotes embed`)
171
+ return
172
+ }
173
+
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,
180
+ embeddingProvider: provider,
181
+ })
182
+ await ss.initDb()
183
+
184
+ const stats = ss.getStats()
185
+ console.log('Search Index Status')
186
+ console.log('-------------------')
187
+ console.log(`Documents indexed: ${stats.documentCount}`)
188
+ console.log(`Chunks created: ${stats.chunkCount}`)
189
+ console.log(`Embeddings stored: ${stats.embeddingCount}`)
190
+ console.log(`Last indexed: ${stats.lastIndexedAt ?? 'never'}`)
191
+ console.log(`Provider: ${stats.provider}`)
192
+ console.log(`Model: ${stats.model}`)
193
+ console.log(`Dimensions: ${stats.dimensions}`)
194
+ console.log(`Database size: ${formatBytes(stats.dbSizeBytes)}`)
195
+ console.log(`Collection docs: ${collection.available.length}`)
196
+
197
+ // Check for staleness
198
+ const docs = collectDocumentInputs(collection)
199
+ const stale = docs.filter((d: any) => ss.needsReindex(d)).length
200
+ if (stale > 0) {
201
+ console.log(`Stale documents: ${stale}`)
202
+ } else {
203
+ console.log(`Status: up to date`)
204
+ }
205
+ return
206
+ }
207
+
208
+ // 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,
215
+ embeddingProvider: provider,
216
+ })
217
+ await ss.initDb()
218
+
219
+ const docs = collectDocumentInputs(collection)
220
+ const startTime = Date.now()
221
+
222
+ if (options.force) {
223
+ console.error('Clearing existing index...')
224
+ await ss.reindex()
225
+ }
226
+
227
+ const toIndex = options.force ? docs : docs.filter((d: any) => ss.needsReindex(d))
228
+
229
+ // Remove stale
230
+ ss.removeStale(docs.map((d: any) => d.pathId))
231
+
232
+ if (toIndex.length === 0) {
233
+ console.log('All documents are up to date. Nothing to embed.')
234
+ const stats = ss.getStats()
235
+ printStats(stats, 0, Date.now() - startTime)
236
+ return
237
+ }
238
+
239
+ console.error(`Embedding ${toIndex.length} of ${docs.length} document(s)...`)
240
+ console.error(`Provider: ${provider}`)
241
+
242
+ const batchSize = 5
243
+
244
+ for (let i = 0; i < toIndex.length; i += batchSize) {
245
+ const batch = toIndex.slice(i, i + batchSize)
246
+ await ss.indexDocuments(batch)
247
+ const done = Math.min(i + batchSize, toIndex.length)
248
+ const pct = Math.round((done / toIndex.length) * 100)
249
+ const elapsed = Date.now() - startTime
250
+ const eta = toIndex.length > done
251
+ ? Math.round((elapsed / done) * (toIndex.length - done) / 1000)
252
+ : 0
253
+ const etaStr = eta > 0 ? ` ETA: ${eta}s` : ''
254
+ console.error(` [${pct}%] ${done}/${toIndex.length} documents${etaStr}`)
255
+ }
256
+
257
+ const elapsed = Date.now() - startTime
258
+ const stats = ss.getStats()
259
+ printStats(stats, toIndex.length, elapsed)
260
+ }
261
+
262
+ function printStats(stats: any, indexed: number, elapsedMs: number) {
263
+ console.log()
264
+ console.log('Embedding complete.')
265
+ if (indexed > 0) console.log(` Documents indexed: ${indexed}`)
266
+ console.log(` Total documents: ${stats.documentCount}`)
267
+ console.log(` Total chunks: ${stats.chunkCount}`)
268
+ console.log(` Embeddings: ${stats.embeddingCount}`)
269
+ console.log(` Time elapsed: ${(elapsedMs / 1000).toFixed(1)}s`)
270
+ console.log(` Database size: ${formatBytes(stats.dbSizeBytes)}`)
271
+ }
272
+
273
+ function formatBytes(bytes: number): string {
274
+ if (bytes < 1024) return `${bytes} B`
275
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
276
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
277
+ }
278
+
279
+ commands.register('embed', {
280
+ description: 'Generate embeddings for collection documents',
281
+ help: `# cnotes embed
282
+
283
+ Generate or update vector embeddings for all documents in the collection. Required before using \`cnotes search\`.
284
+
285
+ ## Usage
286
+
287
+ \`\`\`
288
+ cnotes embed [options]
289
+ \`\`\`
290
+
291
+ ## Options
292
+
293
+ | Option | Default | Description |
294
+ |--------|---------|-------------|
295
+ | \`--force\` | \`false\` | Re-embed everything (ignore content hashes) |
296
+ | \`--provider\` | \`openai\` | Embedding provider: \`openai\` or \`local\` |
297
+ | \`--status\` | \`false\` | Show index health without embedding |
298
+ | \`--local\` | \`false\` | Use local embeddings; auto-installs node-llama-cpp if not found |
299
+ | \`--install-local\` | \`false\` | Only install node-llama-cpp (no embedding), then exit |
300
+ | \`--contentFolder\` | | Path to content folder |
301
+
302
+ ## Examples
303
+
304
+ \`\`\`bash
305
+ # Generate/update embeddings (uses OpenAI by default)
306
+ cnotes embed
307
+
308
+ # Re-embed everything from scratch
309
+ cnotes embed --force
310
+
311
+ # Use local embeddings (auto-installs node-llama-cpp if needed)
312
+ cnotes embed --local
313
+
314
+ # Only install node-llama-cpp without embedding
315
+ cnotes embed --install-local
316
+
317
+ # Show index health
318
+ cnotes embed --status
319
+ \`\`\`
320
+ `,
321
+ argsSchema,
322
+ handler,
323
+ })