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.
- 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 +289 -13
- package/bun.lock +43 -4
- 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 +12 -3
- package/public/web-demo/index.html +813 -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 +361 -10
- package/src/define-model.ts +101 -4
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +1 -1
- package/src/index.ts +14 -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 +23 -1
- 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/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 +61 -0
- package/test/table-of-contents.test.ts +49 -5
|
@@ -1,59 +1,185 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import fs from
|
|
3
|
-
import path from
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
\`\`\`bash
|
|
173
|
+
# Create a new post
|
|
174
|
+
cnotes create Post --title "My First Post"
|
|
53
175
|
|
|
54
|
-
|
|
55
|
-
|
|
176
|
+
# Create with meta overrides
|
|
177
|
+
cnotes create Task --title "Fix login bug" --meta.status active --meta.priority high
|
|
56
178
|
|
|
57
|
-
|
|
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
|
+
})
|
|
@@ -1,24 +1,58 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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>) {
|
|
10
|
+
const collection = await loadCollection({
|
|
11
|
+
contentFolder: options.contentFolder,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const data = await collection.export()
|
|
15
|
+
console.log(JSON.stringify(data, null, 2))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
commands.register('export', {
|
|
19
|
+
description: 'Export collection as JSON',
|
|
20
|
+
help: `# cnotes export
|
|
21
|
+
|
|
22
|
+
Export the entire collection as a JSON object. Each document includes its path ID, title, frontmatter, content, and model info.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
\`\`\`
|
|
27
|
+
cnotes export [options]
|
|
28
|
+
\`\`\`
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Option | Description |
|
|
33
|
+
|--------|-------------|
|
|
34
|
+
| \`--contentFolder\` | Path to content folder |
|
|
35
|
+
|
|
36
|
+
## Output
|
|
37
|
+
|
|
38
|
+
Writes JSON to stdout. Pipe to a file or another tool.
|
|
39
|
+
|
|
40
|
+
## Examples
|
|
41
|
+
|
|
42
|
+
\`\`\`bash
|
|
43
|
+
# Export to stdout
|
|
44
|
+
cnotes export
|
|
45
|
+
|
|
46
|
+
# Save to a file
|
|
47
|
+
cnotes export > backup.json
|
|
48
|
+
|
|
49
|
+
# Export a specific content folder
|
|
50
|
+
cnotes export --contentFolder ./docs > docs.json
|
|
51
|
+
|
|
52
|
+
# Pipe to jq for filtering
|
|
53
|
+
cnotes export | jq '.[] | select(.meta.status == "published")'
|
|
54
|
+
\`\`\`
|
|
55
|
+
`,
|
|
56
|
+
argsSchema,
|
|
57
|
+
handler,
|
|
58
|
+
})
|