contentbase 0.2.0 → 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.
- package/CNAME +1 -0
- package/LAUNCH-PLAN.md +419 -0
- package/index.html +1636 -0
- package/package.json +5 -4
- package/src/adapters/node-fs.ts +80 -0
- package/src/cli/commands/api/endpoints/doc.ts +2 -2
- package/src/cli/commands/api/endpoints/document.ts +2 -2
- package/src/cli/commands/api/endpoints/search-reindex.ts +6 -25
- package/src/cli/commands/api/endpoints/search-status.ts +3 -20
- package/src/cli/commands/api/endpoints/search.ts +3 -33
- package/src/cli/commands/api/endpoints/semantic-search.ts +3 -33
- package/src/cli/commands/embed.ts +8 -81
- package/src/cli/commands/mcp.ts +5 -1128
- package/src/cli/commands/search.ts +4 -76
- package/src/cli/commands/serve.ts +7 -32
- package/src/cli/index.ts +1 -1
- package/src/cli/load-collection.ts +2 -2
- package/src/collection.ts +31 -30
- package/src/document.ts +18 -0
- package/src/extract-sections.ts +3 -0
- package/src/index.ts +10 -0
- package/src/mcp/helpers.ts +7 -0
- package/src/mcp/model-info.ts +102 -0
- package/src/mcp/prompts.ts +199 -0
- package/src/mcp/readme.ts +153 -0
- package/src/mcp/resources.ts +89 -0
- package/src/mcp/server.ts +103 -0
- package/src/mcp/tools/mutation.ts +176 -0
- package/src/mcp/tools/query.ts +141 -0
- package/src/mcp/tools/search.ts +180 -0
- package/src/parse.ts +5 -0
- package/src/query/query-dsl.ts +1 -1
- package/src/relationships/has-many.ts +2 -2
- package/src/search/document-inputs.ts +85 -0
- package/src/search/luca-semantic-search.ts +75 -0
- package/src/types.ts +54 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/match-pattern.ts +2 -2
- package/src/utils/strip-markdown.ts +59 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import matter from 'gray-matter'
|
|
3
|
+
import { errorResult, textResult } from '../helpers.js'
|
|
4
|
+
import { resolveModelDef } from '../../cli/commands/api/helpers.js'
|
|
5
|
+
import { validateDocument } from '../../index.js'
|
|
6
|
+
|
|
7
|
+
export function registerMutationTools(mcpServer: any, collection: any) {
|
|
8
|
+
const modelDefs = collection.modelDefinitions as any[]
|
|
9
|
+
|
|
10
|
+
mcpServer.tool('validate', {
|
|
11
|
+
description: 'Validate a document against its model schema. Returns validation result with any errors. **ALWAYS call after create/update operations** to confirm the document conforms to its model.',
|
|
12
|
+
schema: z.object({
|
|
13
|
+
pathId: z.string().describe('Document path ID'),
|
|
14
|
+
model: z.string().optional().describe('Model name (auto-detected if omitted)'),
|
|
15
|
+
}),
|
|
16
|
+
handler: (args: { pathId: string; model?: string }) => {
|
|
17
|
+
const doc = collection.document(args.pathId)
|
|
18
|
+
if (!doc) return errorResult(`Document not found: ${args.pathId}`)
|
|
19
|
+
|
|
20
|
+
const def = args.model
|
|
21
|
+
? resolveModelDef(collection, args.model)
|
|
22
|
+
: collection.findModelDefinition(args.pathId)
|
|
23
|
+
|
|
24
|
+
if (!def) {
|
|
25
|
+
return errorResult(`No model definition found for ${args.pathId}. Specify one with the model parameter.`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = validateDocument(doc, def)
|
|
29
|
+
return textResult(JSON.stringify(result, null, 2))
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
mcpServer.tool('create_document', {
|
|
34
|
+
description: '**ALWAYS use this instead of writing markdown files directly.** Creates a new document with proper scaffolding from a model definition — generates correct frontmatter defaults and section headings. Call `validate` after creation.',
|
|
35
|
+
schema: z.object({
|
|
36
|
+
pathId: z.string().describe('Path ID for the new document (e.g. "epics/my-new-epic")'),
|
|
37
|
+
title: z.string().describe('Document title (used as the H1 heading)'),
|
|
38
|
+
meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to set'),
|
|
39
|
+
model: z.string().optional().describe('Model name (auto-detected from pathId prefix if omitted)'),
|
|
40
|
+
}),
|
|
41
|
+
handler: async (args: { pathId: string; title: string; meta?: Record<string, any>; model?: string }) => {
|
|
42
|
+
if (collection.available.includes(args.pathId)) {
|
|
43
|
+
return errorResult(`Document already exists: ${args.pathId}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const def = args.model
|
|
47
|
+
? resolveModelDef(collection, args.model)
|
|
48
|
+
: collection.findModelDefinition(args.pathId)
|
|
49
|
+
|
|
50
|
+
const metaData = { ...((def as any)?.defaults || {}), ...(args.meta || {}) }
|
|
51
|
+
|
|
52
|
+
const sectionHeadings = def
|
|
53
|
+
? Object.values((def as any).sections || {}).map((s: any) => `## ${s.heading}\n\n`)
|
|
54
|
+
: []
|
|
55
|
+
|
|
56
|
+
const body = [
|
|
57
|
+
`# ${args.title}`,
|
|
58
|
+
'',
|
|
59
|
+
...sectionHeadings,
|
|
60
|
+
].join('\n')
|
|
61
|
+
|
|
62
|
+
const content = matter.stringify(body, metaData)
|
|
63
|
+
|
|
64
|
+
await collection.saveItem(args.pathId, { content })
|
|
65
|
+
await collection.load({ refresh: true })
|
|
66
|
+
|
|
67
|
+
return textResult(JSON.stringify({
|
|
68
|
+
created: args.pathId,
|
|
69
|
+
model: def ? (def as any).name : null,
|
|
70
|
+
meta: metaData,
|
|
71
|
+
}, null, 2))
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
mcpServer.tool('update_document', {
|
|
76
|
+
description: 'Update a document\'s frontmatter and/or replace its entire content body. Use for frontmatter changes. For section-level edits, prefer `update_section` instead. Call `validate` after.',
|
|
77
|
+
schema: z.object({
|
|
78
|
+
pathId: z.string().describe('Document path ID'),
|
|
79
|
+
meta: z.record(z.string(), z.any()).optional().describe('Frontmatter fields to merge (existing fields are preserved unless overridden)'),
|
|
80
|
+
content: z.string().optional().describe('New markdown content body (replaces everything after frontmatter)'),
|
|
81
|
+
}),
|
|
82
|
+
handler: async (args: { pathId: string; meta?: Record<string, any>; content?: string }) => {
|
|
83
|
+
const doc = collection.document(args.pathId)
|
|
84
|
+
if (!doc) return errorResult(`Document not found: ${args.pathId}`)
|
|
85
|
+
|
|
86
|
+
const currentMeta = { ...doc.meta }
|
|
87
|
+
const newMeta = args.meta ? { ...currentMeta, ...args.meta } : currentMeta
|
|
88
|
+
const newContent = args.content ?? doc.content
|
|
89
|
+
|
|
90
|
+
const fullContent = matter.stringify(newContent, newMeta)
|
|
91
|
+
await collection.saveItem(args.pathId, { content: fullContent })
|
|
92
|
+
await collection.load({ refresh: true })
|
|
93
|
+
|
|
94
|
+
return textResult(JSON.stringify({
|
|
95
|
+
updated: args.pathId,
|
|
96
|
+
meta: newMeta,
|
|
97
|
+
}, null, 2))
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
mcpServer.tool('update_section', {
|
|
102
|
+
description: 'Preferred way to edit document content. Surgically edit a specific section — replace, append, or remove. Target a section by its heading name. Call `validate` after.',
|
|
103
|
+
schema: z.object({
|
|
104
|
+
pathId: z.string().describe('Document path ID'),
|
|
105
|
+
heading: z.string().describe('Section heading text to target (e.g. "Overview", "Requirements")'),
|
|
106
|
+
action: z.enum(['replace', 'append', 'remove']).describe('What to do with the section'),
|
|
107
|
+
content: z.string().optional().describe('New content (required for replace/append, ignored for remove)'),
|
|
108
|
+
}),
|
|
109
|
+
handler: async (args: { pathId: string; heading: string; action: 'replace' | 'append' | 'remove'; content?: string }) => {
|
|
110
|
+
let doc = collection.document(args.pathId)
|
|
111
|
+
if (!doc) return errorResult(`Document not found: ${args.pathId}`)
|
|
112
|
+
|
|
113
|
+
switch (args.action) {
|
|
114
|
+
case 'replace': {
|
|
115
|
+
if (!args.content) return errorResult('Content is required for replace action')
|
|
116
|
+
doc = doc.replaceSectionContent(args.heading, args.content)
|
|
117
|
+
break
|
|
118
|
+
}
|
|
119
|
+
case 'append': {
|
|
120
|
+
if (!args.content) return errorResult('Content is required for append action')
|
|
121
|
+
doc = doc.appendToSection(args.heading, args.content)
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
case 'remove': {
|
|
125
|
+
doc = doc.removeSection(args.heading)
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fullContent = matter.stringify(doc.content, doc.meta)
|
|
131
|
+
await collection.saveItem(args.pathId, { content: fullContent })
|
|
132
|
+
await collection.load({ refresh: true })
|
|
133
|
+
|
|
134
|
+
return textResult(JSON.stringify({
|
|
135
|
+
updated: args.pathId,
|
|
136
|
+
action: args.action,
|
|
137
|
+
heading: args.heading,
|
|
138
|
+
}, null, 2))
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
mcpServer.tool('delete_document', {
|
|
143
|
+
description: 'Delete a document from the collection permanently. Cannot be undone except through version control.',
|
|
144
|
+
schema: z.object({
|
|
145
|
+
pathId: z.string().describe('Document path ID to delete'),
|
|
146
|
+
}),
|
|
147
|
+
handler: async (args: { pathId: string }) => {
|
|
148
|
+
if (!collection.available.includes(args.pathId)) {
|
|
149
|
+
return errorResult(`Document not found: ${args.pathId}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await collection.deleteItem(args.pathId)
|
|
153
|
+
await collection.load({ refresh: true })
|
|
154
|
+
return textResult(JSON.stringify({ deleted: args.pathId }, null, 2))
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
mcpServer.tool('run_action', {
|
|
159
|
+
description: 'Execute a registered collection action by name.',
|
|
160
|
+
schema: z.object({
|
|
161
|
+
name: z.string().describe('Action name'),
|
|
162
|
+
args: z.array(z.any()).optional().describe('Arguments to pass to the action'),
|
|
163
|
+
}),
|
|
164
|
+
handler: async (toolArgs: { name: string; args?: any[] }) => {
|
|
165
|
+
if (!collection.availableActions.includes(toolArgs.name)) {
|
|
166
|
+
return errorResult(
|
|
167
|
+
`Unknown action: ${toolArgs.name}. Available: ${collection.availableActions.join(', ') || '(none)'}`,
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = await collection.runAction(toolArgs.name, ...(toolArgs.args || []))
|
|
172
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
173
|
+
return textResult(text)
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { errorResult, textResult } from '../helpers.js'
|
|
3
|
+
import { generateReadMe } from '../readme.js'
|
|
4
|
+
import { generateModelInfo } from '../model-info.js'
|
|
5
|
+
import { resolveModelDef, buildSchemaJSON } from '../../cli/commands/api/helpers.js'
|
|
6
|
+
import { queryDSLSchema, executeQueryDSL } from '../../query/query-dsl.js'
|
|
7
|
+
|
|
8
|
+
export function registerQueryTools(mcpServer: any, collection: any) {
|
|
9
|
+
const modelDefs = collection.modelDefinitions as any[]
|
|
10
|
+
|
|
11
|
+
// -- read_me: entry-point guidance for AI agents --
|
|
12
|
+
const readMeContent = generateReadMe(collection, modelDefs)
|
|
13
|
+
|
|
14
|
+
mcpServer.tool('read_me', {
|
|
15
|
+
description: [
|
|
16
|
+
'Returns the content collection guide. Call this BEFORE working with any documents.',
|
|
17
|
+
'Contains model definitions, available tools, query syntax, and recommended workflow.',
|
|
18
|
+
'Call this at the start of every session to understand the collection structure.',
|
|
19
|
+
].join('\n'),
|
|
20
|
+
schema: z.object({}),
|
|
21
|
+
handler: () => textResult(readMeContent),
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
mcpServer.tool('inspect', {
|
|
25
|
+
description: 'Overview of the collection — registered models, document count, available actions. Call `read_me` first if this is your first interaction.',
|
|
26
|
+
schema: z.object({}),
|
|
27
|
+
handler: () => {
|
|
28
|
+
const schema = buildSchemaJSON(collection)
|
|
29
|
+
const overview = {
|
|
30
|
+
rootPath: collection.rootPath,
|
|
31
|
+
documentCount: collection.available.length,
|
|
32
|
+
models: Object.values(schema),
|
|
33
|
+
actions: collection.availableActions,
|
|
34
|
+
}
|
|
35
|
+
return textResult(JSON.stringify(overview, null, 2))
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
mcpServer.tool('get_model_info', {
|
|
40
|
+
description: 'Get detailed information about a single model — fields, sections, relationships, example document. Use when you need to understand a model before creating or editing its documents.',
|
|
41
|
+
schema: z.object({
|
|
42
|
+
model: z.string().describe('Model name or prefix'),
|
|
43
|
+
}),
|
|
44
|
+
handler: (args: { model: string }) => {
|
|
45
|
+
const def = resolveModelDef(collection, args.model)
|
|
46
|
+
if (!def) {
|
|
47
|
+
return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
|
|
48
|
+
}
|
|
49
|
+
return textResult(generateModelInfo(collection, def))
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
mcpServer.tool('list_documents', {
|
|
54
|
+
description: 'List all document path IDs in the collection, optionally filtered by model name or prefix. The prefix before the slash indicates the model.',
|
|
55
|
+
schema: z.object({
|
|
56
|
+
model: z.string().optional().describe('Filter by model name or prefix'),
|
|
57
|
+
}),
|
|
58
|
+
handler: (args: { model?: string }) => {
|
|
59
|
+
let ids = collection.available
|
|
60
|
+
|
|
61
|
+
if (args.model) {
|
|
62
|
+
const def = resolveModelDef(collection, args.model)
|
|
63
|
+
if (def) {
|
|
64
|
+
const prefix = (def as any).prefix + '/'
|
|
65
|
+
ids = ids.filter((id: string) => id.startsWith(prefix))
|
|
66
|
+
} else {
|
|
67
|
+
return errorResult(`Unknown model: ${args.model}. Available: ${modelDefs.map((d: any) => d.name).join(', ')}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return textResult(JSON.stringify(ids, null, 2))
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
mcpServer.tool('query', {
|
|
76
|
+
description: [
|
|
77
|
+
'Query typed model instances with MongoDB-style filtering. See `read_me` output for full syntax reference.',
|
|
78
|
+
'Where clause: keys are dot-notation paths, values are literals (implies $eq),',
|
|
79
|
+
'arrays (implies $in), or operator objects like { "$gt": 5 }.',
|
|
80
|
+
'Operators: $eq, $neq, $in, $notIn, $gt, $lt, $gte, $lte,',
|
|
81
|
+
'$contains, $startsWith, $endsWith, $regex, $exists.',
|
|
82
|
+
'Supports sort, limit, offset, select, and method (fetchAll/first/last/count).',
|
|
83
|
+
].join(' '),
|
|
84
|
+
schema: z.object({
|
|
85
|
+
model: z.string().describe('Model name to query'),
|
|
86
|
+
where: z.any().optional().describe(
|
|
87
|
+
'MongoDB-style where clause. Keys are field paths, values are literals (implicit $eq), arrays (implicit $in), or operator objects like { "$gt": 5 }. Also accepts legacy array format for backward compat.',
|
|
88
|
+
),
|
|
89
|
+
sort: z.record(z.string(), z.enum(['asc', 'desc'])).optional().describe(
|
|
90
|
+
'Sort specification, e.g. { "meta.priority": "desc" }',
|
|
91
|
+
),
|
|
92
|
+
select: z.array(z.string()).optional().describe('Fields to include in output (default: all)'),
|
|
93
|
+
related: z.array(z.string()).optional().describe('Relationship names to include in results (e.g. ["plans", "goal"])'),
|
|
94
|
+
scopes: z.array(z.string()).optional().describe('Named scopes to apply before filtering'),
|
|
95
|
+
limit: z.number().optional().describe('Maximum results to return'),
|
|
96
|
+
offset: z.number().optional().describe('Number of results to skip'),
|
|
97
|
+
method: z.enum(['fetchAll', 'first', 'last', 'count']).optional().describe(
|
|
98
|
+
'Terminal operation (default: fetchAll)',
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
handler: async (args: any) => {
|
|
102
|
+
try {
|
|
103
|
+
// Backward compat: convert legacy array-style where to MongoDB-style
|
|
104
|
+
let whereClause = args.where
|
|
105
|
+
if (Array.isArray(whereClause)) {
|
|
106
|
+
const converted: Record<string, unknown> = {}
|
|
107
|
+
for (const cond of whereClause) {
|
|
108
|
+
const op = cond.operator || 'eq'
|
|
109
|
+
if (op === 'eq') {
|
|
110
|
+
converted[cond.path] = cond.value
|
|
111
|
+
} else if (op === 'notExists') {
|
|
112
|
+
converted[cond.path] = { $exists: false }
|
|
113
|
+
} else if (op === 'exists') {
|
|
114
|
+
converted[cond.path] = { $exists: true }
|
|
115
|
+
} else {
|
|
116
|
+
converted[cond.path] = { [`$${op}`]: cond.value }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
whereClause = converted
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const dsl = queryDSLSchema.parse({
|
|
123
|
+
model: args.model,
|
|
124
|
+
where: whereClause,
|
|
125
|
+
sort: args.sort,
|
|
126
|
+
select: args.select,
|
|
127
|
+
related: args.related,
|
|
128
|
+
scopes: args.scopes,
|
|
129
|
+
limit: args.limit,
|
|
130
|
+
offset: args.offset,
|
|
131
|
+
method: args.method,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const result = await executeQueryDSL(collection, dsl)
|
|
135
|
+
return textResult(JSON.stringify(result, null, 2))
|
|
136
|
+
} catch (error: any) {
|
|
137
|
+
return errorResult(error.message)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { errorResult, textResult } from '../helpers.js'
|
|
3
|
+
import { resolveModelDef } from '../../cli/commands/api/helpers.js'
|
|
4
|
+
import { getInitializedSemanticSearch, hasSearchIndex as searchIndexExists } from '../../search/luca-semantic-search.js'
|
|
5
|
+
|
|
6
|
+
export function registerSearchTools(mcpServer: any, collection: any, container: any) {
|
|
7
|
+
let _semanticSearch: any = null
|
|
8
|
+
|
|
9
|
+
function hasSearchIndex(): boolean {
|
|
10
|
+
return searchIndexExists(collection.rootPath)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function getSemanticSearch() {
|
|
14
|
+
if (_semanticSearch?.state?.get('dbReady')) return _semanticSearch
|
|
15
|
+
|
|
16
|
+
_semanticSearch = await getInitializedSemanticSearch(container, collection.rootPath)
|
|
17
|
+
return _semanticSearch
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
mcpServer.tool('search_content', {
|
|
21
|
+
description: 'Full-text regex search across all document content. Returns matching document IDs with context. Searches document body text, not metadata — for metadata filtering, use `query` instead.',
|
|
22
|
+
schema: z.object({
|
|
23
|
+
pattern: z.string().describe('Regex pattern to search for'),
|
|
24
|
+
model: z.string().optional().describe('Limit search to a specific model'),
|
|
25
|
+
caseSensitive: z.boolean().default(false).describe('Case-sensitive matching'),
|
|
26
|
+
}),
|
|
27
|
+
handler: (args: { pattern: string; model?: string; caseSensitive: boolean }) => {
|
|
28
|
+
const flags = args.caseSensitive ? 'g' : 'gi'
|
|
29
|
+
let regex: RegExp
|
|
30
|
+
try {
|
|
31
|
+
regex = new RegExp(args.pattern, flags)
|
|
32
|
+
} catch (e: any) {
|
|
33
|
+
return errorResult(`Invalid regex: ${e.message}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let ids = collection.available
|
|
37
|
+
if (args.model) {
|
|
38
|
+
const def = resolveModelDef(collection, args.model)
|
|
39
|
+
if (def) {
|
|
40
|
+
const prefix = (def as any).prefix + '/'
|
|
41
|
+
ids = ids.filter((id: string) => id.startsWith(prefix))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const results: Array<{ pathId: string; matches: string[] }> = []
|
|
46
|
+
|
|
47
|
+
for (const pathId of ids) {
|
|
48
|
+
const doc = collection.document(pathId)
|
|
49
|
+
const content = doc.content
|
|
50
|
+
const matches: string[] = []
|
|
51
|
+
|
|
52
|
+
for (const line of content.split('\n')) {
|
|
53
|
+
if (regex.test(line)) {
|
|
54
|
+
matches.push(line.trim())
|
|
55
|
+
}
|
|
56
|
+
regex.lastIndex = 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (matches.length > 0) {
|
|
60
|
+
results.push({ pathId, matches: matches.slice(0, 10) })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return textResult(JSON.stringify(results, null, 2))
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
mcpServer.tool('text_search', {
|
|
69
|
+
description: 'Search file contents with pattern matching using ripgrep. Returns distinct file matches by default, or line-level detail with expanded=true.',
|
|
70
|
+
schema: z.object({
|
|
71
|
+
pattern: z.string().describe('Text or regex pattern to search for'),
|
|
72
|
+
expanded: z.boolean().default(false).describe('Return line-level matches instead of just file paths'),
|
|
73
|
+
include: z.string().optional().describe('Glob filter (e.g. "*.md")'),
|
|
74
|
+
exclude: z.string().optional().describe('Glob filter (e.g. "node_modules")'),
|
|
75
|
+
ignoreCase: z.boolean().default(false).describe('Case insensitive search'),
|
|
76
|
+
maxResults: z.number().optional().describe('Limit number of results'),
|
|
77
|
+
}),
|
|
78
|
+
handler: async (args: any) => {
|
|
79
|
+
const grep = container.feature('grep')
|
|
80
|
+
const searchPath = collection.rootPath
|
|
81
|
+
|
|
82
|
+
const grepOpts: any = {
|
|
83
|
+
path: searchPath,
|
|
84
|
+
ignoreCase: args.ignoreCase,
|
|
85
|
+
maxResults: args.maxResults,
|
|
86
|
+
include: args.include,
|
|
87
|
+
exclude: args.exclude,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!args.expanded) {
|
|
91
|
+
const files = await grep.filesContaining(args.pattern, grepOpts)
|
|
92
|
+
return textResult(JSON.stringify({ files, count: files.length }, null, 2))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const results = await grep.search({ ...grepOpts, pattern: args.pattern })
|
|
96
|
+
const grouped = new Map<string, Array<{ line: number; column?: number; content: string }>>()
|
|
97
|
+
for (const match of results) {
|
|
98
|
+
if (!grouped.has(match.file)) grouped.set(match.file, [])
|
|
99
|
+
grouped.get(match.file)!.push({ line: match.line, column: match.column, content: match.content })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const files = Array.from(grouped.entries()).map(([file, matches]) => ({ file, matches }))
|
|
103
|
+
return textResult(JSON.stringify({ files, count: files.length }, null, 2))
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
mcpServer.tool('keyword_search', {
|
|
108
|
+
description: 'Fast keyword search using BM25 ranking. Best for exact terms, identifiers, and known phrases. Requires a search index — run `cnotes embed` first if not indexed.',
|
|
109
|
+
schema: z.object({
|
|
110
|
+
query: z.string().describe('Search query text'),
|
|
111
|
+
limit: z.number().optional().default(10).describe('Maximum results to return'),
|
|
112
|
+
model: z.string().optional().describe('Filter results to a specific model name'),
|
|
113
|
+
}),
|
|
114
|
+
handler: async (args: { query: string; limit: number; model?: string }) => {
|
|
115
|
+
if (!hasSearchIndex()) {
|
|
116
|
+
return errorResult('No search index found. Run: cnotes embed')
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const ss = await getSemanticSearch()
|
|
120
|
+
const results = await ss.search(args.query, {
|
|
121
|
+
limit: args.limit,
|
|
122
|
+
model: args.model,
|
|
123
|
+
})
|
|
124
|
+
return textResult(JSON.stringify(results, null, 2))
|
|
125
|
+
} catch (error: any) {
|
|
126
|
+
return errorResult(`Search failed: ${error.message}`)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
mcpServer.tool('semantic_search', {
|
|
132
|
+
description: 'Search by meaning using vector embeddings. Finds conceptually related documents even without keyword matches. Requires a search index — run `cnotes embed` first if not indexed.',
|
|
133
|
+
schema: z.object({
|
|
134
|
+
query: z.string().describe('Search query text'),
|
|
135
|
+
limit: z.number().optional().default(10).describe('Maximum results to return'),
|
|
136
|
+
model: z.string().optional().describe('Filter results to a specific model name'),
|
|
137
|
+
}),
|
|
138
|
+
handler: async (args: { query: string; limit: number; model?: string }) => {
|
|
139
|
+
if (!hasSearchIndex()) {
|
|
140
|
+
return errorResult('No search index found. Run: cnotes embed')
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const ss = await getSemanticSearch()
|
|
144
|
+
const results = await ss.vectorSearch(args.query, {
|
|
145
|
+
limit: args.limit,
|
|
146
|
+
model: args.model,
|
|
147
|
+
})
|
|
148
|
+
return textResult(JSON.stringify(results, null, 2))
|
|
149
|
+
} catch (error: any) {
|
|
150
|
+
return errorResult(`Search failed: ${error.message}`)
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
mcpServer.tool('hybrid_search', {
|
|
156
|
+
description: 'Combined keyword + semantic search with score fusion. Best for general questions about the collection. Requires a search index — run `cnotes embed` first if not indexed.',
|
|
157
|
+
schema: z.object({
|
|
158
|
+
query: z.string().describe('Search query text'),
|
|
159
|
+
limit: z.number().optional().default(10).describe('Maximum results to return'),
|
|
160
|
+
model: z.string().optional().describe('Filter results to a specific model name'),
|
|
161
|
+
where: z.record(z.string(), z.any()).optional().describe('Metadata filters, e.g. {"status": "approved"}'),
|
|
162
|
+
}),
|
|
163
|
+
handler: async (args: { query: string; limit: number; model?: string; where?: Record<string, any> }) => {
|
|
164
|
+
if (!hasSearchIndex()) {
|
|
165
|
+
return errorResult('No search index found. Run: cnotes embed')
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const ss = await getSemanticSearch()
|
|
169
|
+
const results = await ss.hybridSearch(args.query, {
|
|
170
|
+
limit: args.limit,
|
|
171
|
+
model: args.model,
|
|
172
|
+
where: args.where,
|
|
173
|
+
})
|
|
174
|
+
return textResult(JSON.stringify(results, null, 2))
|
|
175
|
+
} catch (error: any) {
|
|
176
|
+
return errorResult(`Search failed: ${error.message}`)
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
}
|
package/src/parse.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { toString } from "mdast-util-to-string";
|
|
|
7
7
|
import { AstQuery } from "./ast-query";
|
|
8
8
|
import { NodeShortcuts } from "./node-shortcuts";
|
|
9
9
|
import { stringifyAst } from "./utils/stringify-ast";
|
|
10
|
+
import { stripMarkdown } from "./utils/strip-markdown";
|
|
11
|
+
import type { StripMarkdownOptions } from "./utils/strip-markdown";
|
|
10
12
|
import type { Root, Content, RootContent } from "mdast";
|
|
11
13
|
|
|
12
14
|
const processor = unified().use(remarkParse).use(remarkGfm);
|
|
@@ -26,6 +28,8 @@ export interface ParsedDocument {
|
|
|
26
28
|
title: string;
|
|
27
29
|
/** Stringify an AST back to markdown */
|
|
28
30
|
stringify(ast?: Root): string;
|
|
31
|
+
/** Strip markdown syntax, returning plain text */
|
|
32
|
+
stripMarkdown(options?: StripMarkdownOptions): string;
|
|
29
33
|
/** Extract a section by heading text */
|
|
30
34
|
extractSection(heading: string | Content): Content[];
|
|
31
35
|
/** Get a queryable AstQuery scoped to a section */
|
|
@@ -107,6 +111,7 @@ export async function parse(input: string): Promise<ParsedDocument> {
|
|
|
107
111
|
nodes,
|
|
108
112
|
title,
|
|
109
113
|
stringify: (tree: Root = ast) => stringifyAst(tree),
|
|
114
|
+
stripMarkdown: (options?: StripMarkdownOptions) => stripMarkdown(ast, options),
|
|
110
115
|
extractSection,
|
|
111
116
|
querySection,
|
|
112
117
|
};
|
package/src/query/query-dsl.ts
CHANGED
|
@@ -135,7 +135,7 @@ export class HasManyRelationship<
|
|
|
135
135
|
// e.g. Project hasMany Plans → looks for meta.project on Plan documents.
|
|
136
136
|
const fk = this.#definition.foreignKey || this.#inferForeignKey();
|
|
137
137
|
const slug = this.#document.slug;
|
|
138
|
-
const parentPrefix = this.#document.id.split("/")[0]
|
|
138
|
+
const parentPrefix = this.#document.id.split("/")[0]!;
|
|
139
139
|
const idSegment = this.#document.id.slice(parentPrefix.length + 1);
|
|
140
140
|
const prefix = targetDef.prefix;
|
|
141
141
|
const results: InferModelInstance<TTarget>[] = [];
|
|
@@ -162,7 +162,7 @@ export class HasManyRelationship<
|
|
|
162
162
|
* e.g. if the parent is in "projects/", the FK is "project" (singularized, lowercased).
|
|
163
163
|
*/
|
|
164
164
|
#inferForeignKey(): string {
|
|
165
|
-
const parentPrefix = this.#document.id.split("/")[0]
|
|
165
|
+
const parentPrefix = this.#document.id.split("/")[0]!;
|
|
166
166
|
// Simple singularize: strip trailing "s"
|
|
167
167
|
return parentPrefix.replace(/s$/, "");
|
|
168
168
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface SearchDocumentSection {
|
|
2
|
+
heading: string
|
|
3
|
+
headingPath: string
|
|
4
|
+
content: string
|
|
5
|
+
level: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SearchDocumentInput {
|
|
9
|
+
pathId: string
|
|
10
|
+
model?: string
|
|
11
|
+
title?: string
|
|
12
|
+
slug?: string
|
|
13
|
+
meta?: Record<string, unknown>
|
|
14
|
+
content: string
|
|
15
|
+
sections?: SearchDocumentSection[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchCollectionLike {
|
|
19
|
+
available: string[]
|
|
20
|
+
document(pathId: string): {
|
|
21
|
+
title?: string
|
|
22
|
+
slug?: string
|
|
23
|
+
meta?: Record<string, unknown>
|
|
24
|
+
content: string
|
|
25
|
+
}
|
|
26
|
+
findModelDefinition?(pathId: string): { name?: string } | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function collectDocumentInputs(collection: SearchCollectionLike): SearchDocumentInput[] {
|
|
30
|
+
const inputs: SearchDocumentInput[] = []
|
|
31
|
+
|
|
32
|
+
for (const pathId of collection.available) {
|
|
33
|
+
const doc = collection.document(pathId)
|
|
34
|
+
const modelDef = collection.findModelDefinition?.(pathId)
|
|
35
|
+
const sections = collectH2Sections(doc.content)
|
|
36
|
+
|
|
37
|
+
inputs.push({
|
|
38
|
+
pathId,
|
|
39
|
+
model: modelDef?.name ?? undefined,
|
|
40
|
+
title: doc.title,
|
|
41
|
+
slug: doc.slug,
|
|
42
|
+
meta: doc.meta,
|
|
43
|
+
content: doc.content,
|
|
44
|
+
sections: sections.length > 0 ? sections : undefined,
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return inputs
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function collectH2Sections(content: string): SearchDocumentSection[] {
|
|
52
|
+
const sections: SearchDocumentSection[] = []
|
|
53
|
+
const lines = content.split('\n')
|
|
54
|
+
let currentHeading: string | null = null
|
|
55
|
+
let currentContent: string[] = []
|
|
56
|
+
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
const h2Match = line.match(/^## (.+)/)
|
|
59
|
+
if (h2Match) {
|
|
60
|
+
if (currentHeading) {
|
|
61
|
+
sections.push({
|
|
62
|
+
heading: currentHeading,
|
|
63
|
+
headingPath: currentHeading,
|
|
64
|
+
content: currentContent.join('\n').trim(),
|
|
65
|
+
level: 2,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
currentHeading = h2Match[1].trim()
|
|
69
|
+
currentContent = []
|
|
70
|
+
} else if (currentHeading) {
|
|
71
|
+
currentContent.push(line)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (currentHeading) {
|
|
76
|
+
sections.push({
|
|
77
|
+
heading: currentHeading,
|
|
78
|
+
headingPath: currentHeading,
|
|
79
|
+
content: currentContent.join('\n').trim(),
|
|
80
|
+
level: 2,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return sections
|
|
85
|
+
}
|