nano-brain 2026.1.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/AGENTS_SNIPPET.md +36 -0
- package/CHANGELOG.md +68 -0
- package/README.md +281 -0
- package/SKILL.md +153 -0
- package/bin/cli.js +18 -0
- package/index.html +929 -0
- package/nano-brain +4 -0
- package/opencode-mcp.json +9 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
- package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
- package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
- package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
- package/openspec/changes/codebase-indexing/design.md +169 -0
- package/openspec/changes/codebase-indexing/proposal.md +30 -0
- package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
- package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
- package/openspec/changes/codebase-indexing/tasks.md +56 -0
- package/openspec/specs/mcp-integration-testing/spec.md +50 -0
- package/openspec/specs/mcp-server/spec.md +75 -0
- package/openspec/specs/search-pipeline/spec.md +29 -0
- package/openspec/specs/storage-limits/spec.md +94 -0
- package/openspec/specs/workspace-scoping/spec.md +70 -0
- package/package.json +34 -0
- package/site/build.js +66 -0
- package/site/partials/_api.html +83 -0
- package/site/partials/_compare.html +100 -0
- package/site/partials/_config.html +23 -0
- package/site/partials/_features.html +43 -0
- package/site/partials/_footer.html +6 -0
- package/site/partials/_hero.html +9 -0
- package/site/partials/_how-it-works.html +26 -0
- package/site/partials/_models.html +18 -0
- package/site/partials/_quick-start.html +15 -0
- package/site/partials/_stats.html +1 -0
- package/site/partials/_tech-stack.html +13 -0
- package/site/script.js +12 -0
- package/site/shell.html +44 -0
- package/site/styles.css +548 -0
- package/src/chunker.ts +427 -0
- package/src/codebase.ts +331 -0
- package/src/collections.ts +192 -0
- package/src/embeddings.ts +293 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +503 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +664 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +623 -0
- package/src/types.ts +202 -0
- package/src/watcher.ts +384 -0
- package/test/chunker.test.ts +479 -0
- package/test/cli.test.ts +309 -0
- package/test/codebase-chunker.test.ts +446 -0
- package/test/codebase.test.ts +678 -0
- package/test/collections.test.ts +571 -0
- package/test/harvester.test.ts +636 -0
- package/test/integration.test.ts +150 -0
- package/test/llm.test.ts +322 -0
- package/test/search.test.ts +572 -0
- package/test/server.test.ts +541 -0
- package/test/storage.test.ts +302 -0
- package/test/store.test.ts +465 -0
- package/test/watcher.test.ts +656 -0
- package/test/workspace.test.ts +239 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
package/src/codebase.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import type { Store, CodebaseConfig, CodebaseIndexResult } from './types.js'
|
|
2
|
+
import { computeHash } from './store.js'
|
|
3
|
+
import { chunkSourceCode } from './chunker.js'
|
|
4
|
+
import { parseSize } from './storage.js'
|
|
5
|
+
import * as fs from 'fs'
|
|
6
|
+
import * as path from 'path'
|
|
7
|
+
import fg from 'fast-glob'
|
|
8
|
+
|
|
9
|
+
const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024
|
|
10
|
+
const DEFAULT_CODEBASE_MAX_SIZE = 2 * 1024 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
const BUILTIN_EXCLUDE_PATTERNS = [
|
|
13
|
+
'**/node_modules/**',
|
|
14
|
+
'**/.git/**',
|
|
15
|
+
'**/dist/**',
|
|
16
|
+
'**/build/**',
|
|
17
|
+
'**/__pycache__/**',
|
|
18
|
+
'**/vendor/**',
|
|
19
|
+
'**/.next/**',
|
|
20
|
+
'**/.nuxt/**',
|
|
21
|
+
'**/target/**',
|
|
22
|
+
'**/*.min.js',
|
|
23
|
+
'**/*.map',
|
|
24
|
+
'**/*.lock',
|
|
25
|
+
'**/*.sum',
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const PROJECT_TYPE_MARKERS: Record<string, string[]> = {
|
|
29
|
+
'package.json': ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json'],
|
|
30
|
+
'pyproject.toml': ['.py', '.pyi'],
|
|
31
|
+
'setup.py': ['.py', '.pyi'],
|
|
32
|
+
'requirements.txt': ['.py', '.pyi'],
|
|
33
|
+
'go.mod': ['.go'],
|
|
34
|
+
'Cargo.toml': ['.rs'],
|
|
35
|
+
'pom.xml': ['.java', '.kt', '.kts'],
|
|
36
|
+
'build.gradle': ['.java', '.kt', '.kts'],
|
|
37
|
+
'build.gradle.kts': ['.java', '.kt', '.kts'],
|
|
38
|
+
'Gemfile': ['.rb', '.erb'],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function detectProjectType(workspaceRoot: string): string[] {
|
|
42
|
+
const extensions = new Set<string>()
|
|
43
|
+
|
|
44
|
+
for (const [marker, exts] of Object.entries(PROJECT_TYPE_MARKERS)) {
|
|
45
|
+
const markerPath = path.join(workspaceRoot, marker)
|
|
46
|
+
if (fs.existsSync(markerPath)) {
|
|
47
|
+
for (const ext of exts) {
|
|
48
|
+
extensions.add(ext)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
extensions.add('.md')
|
|
54
|
+
|
|
55
|
+
if (extensions.size === 1) {
|
|
56
|
+
return ['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.rb', '.md']
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return Array.from(extensions)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadGitignorePatterns(workspaceRoot: string): string[] {
|
|
63
|
+
const gitignorePath = path.join(workspaceRoot, '.gitignore')
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(gitignorePath)) {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8')
|
|
71
|
+
const patterns: string[] = []
|
|
72
|
+
|
|
73
|
+
for (const line of content.split('\n')) {
|
|
74
|
+
const trimmed = line.trim()
|
|
75
|
+
if (trimmed && !trimmed.startsWith('#')) {
|
|
76
|
+
patterns.push(trimmed)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return patterns
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function mergeExcludePatterns(config: CodebaseConfig, workspaceRoot: string): string[] {
|
|
87
|
+
const patterns = new Set<string>(BUILTIN_EXCLUDE_PATTERNS)
|
|
88
|
+
|
|
89
|
+
const gitignorePatterns = loadGitignorePatterns(workspaceRoot)
|
|
90
|
+
for (const pattern of gitignorePatterns) {
|
|
91
|
+
patterns.add(pattern)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (config.exclude) {
|
|
95
|
+
for (const pattern of config.exclude) {
|
|
96
|
+
patterns.add(pattern)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return Array.from(patterns)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function resolveExtensions(config: CodebaseConfig, workspaceRoot: string): string[] {
|
|
104
|
+
if (config.extensions && config.extensions.length > 0) {
|
|
105
|
+
return config.extensions
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return detectProjectType(workspaceRoot)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function scanCodebaseFiles(
|
|
112
|
+
workspaceRoot: string,
|
|
113
|
+
config: CodebaseConfig
|
|
114
|
+
): Promise<{ files: string[]; skippedTooLarge: number }> {
|
|
115
|
+
const extensions = resolveExtensions(config, workspaceRoot)
|
|
116
|
+
const excludePatterns = mergeExcludePatterns(config, workspaceRoot)
|
|
117
|
+
|
|
118
|
+
const maxFileSize = config.maxFileSize
|
|
119
|
+
? parseSize(config.maxFileSize)
|
|
120
|
+
: DEFAULT_MAX_FILE_SIZE
|
|
121
|
+
|
|
122
|
+
const effectiveMaxSize = maxFileSize > 0 ? maxFileSize : DEFAULT_MAX_FILE_SIZE
|
|
123
|
+
|
|
124
|
+
const patterns = extensions.map(ext => `**/*${ext}`)
|
|
125
|
+
|
|
126
|
+
const allFiles = await fg(patterns, {
|
|
127
|
+
cwd: workspaceRoot,
|
|
128
|
+
absolute: true,
|
|
129
|
+
onlyFiles: true,
|
|
130
|
+
ignore: excludePatterns,
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const files: string[] = []
|
|
134
|
+
let skippedTooLarge = 0
|
|
135
|
+
|
|
136
|
+
for (const filePath of allFiles) {
|
|
137
|
+
try {
|
|
138
|
+
const stats = fs.statSync(filePath)
|
|
139
|
+
if (stats.size <= effectiveMaxSize) {
|
|
140
|
+
files.push(filePath)
|
|
141
|
+
} else {
|
|
142
|
+
skippedTooLarge++
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { files, skippedTooLarge }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function indexCodebase(
|
|
153
|
+
store: Store,
|
|
154
|
+
workspaceRoot: string,
|
|
155
|
+
config: CodebaseConfig,
|
|
156
|
+
projectHash: string,
|
|
157
|
+
_embedder?: { embed(text: string): Promise<{ embedding: number[] }> } | null
|
|
158
|
+
): Promise<CodebaseIndexResult> {
|
|
159
|
+
const { files, skippedTooLarge } = await scanCodebaseFiles(workspaceRoot, config)
|
|
160
|
+
const maxSizeBytes = config.maxSize
|
|
161
|
+
? parseSize(config.maxSize)
|
|
162
|
+
: DEFAULT_CODEBASE_MAX_SIZE
|
|
163
|
+
const effectiveMaxSize = maxSizeBytes > 0 ? maxSizeBytes : DEFAULT_CODEBASE_MAX_SIZE
|
|
164
|
+
const batchSize = config.batchSize ?? 50
|
|
165
|
+
let currentStorageUsed = store.getCollectionStorageSize('codebase')
|
|
166
|
+
let filesIndexed = 0
|
|
167
|
+
let filesSkippedUnchanged = 0
|
|
168
|
+
let filesSkippedBudget = 0
|
|
169
|
+
let chunksCreated = 0
|
|
170
|
+
const activePaths: string[] = []
|
|
171
|
+
let batchNum = 0
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < files.length; i++) {
|
|
174
|
+
const filePath = files[i]
|
|
175
|
+
try {
|
|
176
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
177
|
+
const contentSize = Buffer.byteLength(content, 'utf-8')
|
|
178
|
+
const hash = computeHash(content)
|
|
179
|
+
const existingDoc = store.findDocument(filePath)
|
|
180
|
+
if (existingDoc && existingDoc.hash === hash) {
|
|
181
|
+
filesSkippedUnchanged++
|
|
182
|
+
activePaths.push(filePath)
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const existingSize = existingDoc ? Buffer.byteLength(store.getDocumentBody(existingDoc.hash) ?? '', 'utf-8') : 0
|
|
186
|
+
const netIncrease = contentSize - existingSize
|
|
187
|
+
if (currentStorageUsed + netIncrease > effectiveMaxSize) {
|
|
188
|
+
filesSkippedBudget++
|
|
189
|
+
if (existingDoc) {
|
|
190
|
+
activePaths.push(filePath)
|
|
191
|
+
}
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
store.insertContent(hash, content)
|
|
195
|
+
const chunks = chunkSourceCode(content, hash, filePath, workspaceRoot)
|
|
196
|
+
chunksCreated += chunks.length
|
|
197
|
+
const title = path.basename(filePath)
|
|
198
|
+
const now = new Date().toISOString()
|
|
199
|
+
store.insertDocument({
|
|
200
|
+
collection: 'codebase',
|
|
201
|
+
path: filePath,
|
|
202
|
+
title,
|
|
203
|
+
hash,
|
|
204
|
+
createdAt: existingDoc?.createdAt ?? now,
|
|
205
|
+
modifiedAt: now,
|
|
206
|
+
active: true,
|
|
207
|
+
projectHash,
|
|
208
|
+
})
|
|
209
|
+
currentStorageUsed += netIncrease
|
|
210
|
+
filesIndexed++
|
|
211
|
+
activePaths.push(filePath)
|
|
212
|
+
} catch {
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if ((i + 1) % batchSize === 0) {
|
|
217
|
+
batchNum++
|
|
218
|
+
console.error(`[codebase] Batch ${batchNum}: indexed ${i + 1}/${files.length} files`)
|
|
219
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
store.bulkDeactivateExcept('codebase', activePaths)
|
|
224
|
+
const finalStorageUsed = store.getCollectionStorageSize('codebase')
|
|
225
|
+
return {
|
|
226
|
+
filesScanned: files.length,
|
|
227
|
+
filesIndexed,
|
|
228
|
+
filesSkippedUnchanged,
|
|
229
|
+
filesSkippedTooLarge: skippedTooLarge,
|
|
230
|
+
filesSkippedBudget,
|
|
231
|
+
chunksCreated,
|
|
232
|
+
storageUsedBytes: finalStorageUsed,
|
|
233
|
+
maxSizeBytes: effectiveMaxSize,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const MAX_EMBED_CHARS = 1800
|
|
238
|
+
|
|
239
|
+
function truncateForEmbedding(text: string): string {
|
|
240
|
+
if (text.length <= MAX_EMBED_CHARS) return text
|
|
241
|
+
return text.substring(0, MAX_EMBED_CHARS)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function embedPendingCodebase(
|
|
245
|
+
store: Store,
|
|
246
|
+
embedder: { embed(text: string): Promise<{ embedding: number[] }>; embedBatch?(texts: string[]): Promise<Array<{ embedding: number[] }>> },
|
|
247
|
+
batchSize: number = 10,
|
|
248
|
+
projectHash?: string
|
|
249
|
+
): Promise<number> {
|
|
250
|
+
let embedded = 0
|
|
251
|
+
while (true) {
|
|
252
|
+
const batch: Array<{ hash: string; body: string; path: string }> = []
|
|
253
|
+
for (let i = 0; i < batchSize; i++) {
|
|
254
|
+
const row = store.getNextHashNeedingEmbedding(projectHash)
|
|
255
|
+
if (!row) break
|
|
256
|
+
batch.push(row)
|
|
257
|
+
}
|
|
258
|
+
if (batch.length === 0) break
|
|
259
|
+
|
|
260
|
+
const texts = batch.map(row => truncateForEmbedding(row.body))
|
|
261
|
+
|
|
262
|
+
console.error(`[embed] Batch ${batch.length} docs: ${batch.map((b, i) => `${b.path.split('/').pop()}(${texts[i].length}ch)`).join(', ')}`)
|
|
263
|
+
try {
|
|
264
|
+
if (embedder.embedBatch && batch.length > 1) {
|
|
265
|
+
const results = await embedder.embedBatch(texts)
|
|
266
|
+
for (let i = 0; i < batch.length; i++) {
|
|
267
|
+
store.insertEmbedding(batch[i].hash, 0, 0, results[i].embedding, 'nomic-embed-text-v1.5')
|
|
268
|
+
}
|
|
269
|
+
embedded += batch.length
|
|
270
|
+
} else {
|
|
271
|
+
for (let i = 0; i < batch.length; i++) {
|
|
272
|
+
try {
|
|
273
|
+
const result = await embedder.embed(texts[i])
|
|
274
|
+
store.insertEmbedding(batch[i].hash, 0, 0, result.embedding, 'nomic-embed-text-v1.5')
|
|
275
|
+
embedded++
|
|
276
|
+
} catch {
|
|
277
|
+
console.warn(`[embed] Failed to embed ${batch[i].path}, skipping`)
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.warn('[embed] Batch failed, falling back to sequential:', err)
|
|
284
|
+
for (const item of batch) {
|
|
285
|
+
try {
|
|
286
|
+
const result = await embedder.embed(truncateForEmbedding(item.body))
|
|
287
|
+
store.insertEmbedding(item.hash, 0, 0, result.embedding, 'nomic-embed-text-v1.5')
|
|
288
|
+
embedded++
|
|
289
|
+
} catch {
|
|
290
|
+
console.warn(`[embed] Skipping ${item.path}`)
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (embedded > 0 && embedded % 50 === 0) {
|
|
297
|
+
console.log(`[embed] Embedded ${embedded} document(s)...`)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
301
|
+
}
|
|
302
|
+
return embedded
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function getCodebaseStats(
|
|
306
|
+
store: Store,
|
|
307
|
+
config: CodebaseConfig | undefined,
|
|
308
|
+
workspaceRoot: string
|
|
309
|
+
): { enabled: boolean; documents: number; chunks: number; extensions: string[]; excludeCount: number; storageUsed: number; maxSize: number } | undefined {
|
|
310
|
+
if (!config?.enabled) {
|
|
311
|
+
return undefined
|
|
312
|
+
}
|
|
313
|
+
const health = store.getIndexHealth()
|
|
314
|
+
const codebaseCollection = health.collections.find(c => c.name === 'codebase')
|
|
315
|
+
const extensions = resolveExtensions(config, workspaceRoot)
|
|
316
|
+
const excludePatterns = mergeExcludePatterns(config, workspaceRoot)
|
|
317
|
+
const storageUsed = store.getCollectionStorageSize('codebase')
|
|
318
|
+
const maxSize = config.maxSize
|
|
319
|
+
? parseSize(config.maxSize)
|
|
320
|
+
: DEFAULT_CODEBASE_MAX_SIZE
|
|
321
|
+
const effectiveMaxSize = maxSize > 0 ? maxSize : DEFAULT_CODEBASE_MAX_SIZE
|
|
322
|
+
return {
|
|
323
|
+
enabled: true,
|
|
324
|
+
documents: codebaseCollection?.documentCount ?? 0,
|
|
325
|
+
chunks: 0,
|
|
326
|
+
extensions,
|
|
327
|
+
excludeCount: excludePatterns.length,
|
|
328
|
+
storageUsed,
|
|
329
|
+
maxSize: effectiveMaxSize,
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Collection, CollectionConfig } from './types.js';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { parse, stringify } from 'yaml';
|
|
6
|
+
import fg from 'fast-glob';
|
|
7
|
+
|
|
8
|
+
export function loadCollectionConfig(configPath: string): CollectionConfig | null {
|
|
9
|
+
if (!fs.existsSync(configPath)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
14
|
+
const config = parse(content) as CollectionConfig;
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveCollectionConfig(configPath: string, config: CollectionConfig): void {
|
|
19
|
+
const dir = path.dirname(configPath);
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const yaml = stringify(config);
|
|
25
|
+
fs.writeFileSync(configPath, yaml, 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getCollections(config: CollectionConfig): Collection[] {
|
|
29
|
+
const collections: Collection[] = [];
|
|
30
|
+
|
|
31
|
+
for (const [name, collectionData] of Object.entries(config.collections)) {
|
|
32
|
+
collections.push({
|
|
33
|
+
name,
|
|
34
|
+
path: collectionData.path,
|
|
35
|
+
pattern: collectionData.pattern || '**/*.md',
|
|
36
|
+
context: collectionData.context,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return collections;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function addCollection(
|
|
44
|
+
configPath: string,
|
|
45
|
+
name: string,
|
|
46
|
+
collectionPath: string,
|
|
47
|
+
pattern?: string
|
|
48
|
+
): CollectionConfig {
|
|
49
|
+
let config = loadCollectionConfig(configPath);
|
|
50
|
+
|
|
51
|
+
if (!config) {
|
|
52
|
+
config = {
|
|
53
|
+
collections: {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
config.collections[name] = {
|
|
58
|
+
path: collectionPath,
|
|
59
|
+
pattern: pattern || '**/*.md',
|
|
60
|
+
update: 'auto',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
saveCollectionConfig(configPath, config);
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function removeCollection(configPath: string, name: string): CollectionConfig {
|
|
68
|
+
const config = loadCollectionConfig(configPath);
|
|
69
|
+
|
|
70
|
+
if (!config) {
|
|
71
|
+
throw new Error('Config file not found');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
delete config.collections[name];
|
|
75
|
+
|
|
76
|
+
saveCollectionConfig(configPath, config);
|
|
77
|
+
return config;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function listCollections(config: CollectionConfig): string[] {
|
|
81
|
+
return Object.keys(config.collections);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renameCollection(
|
|
85
|
+
configPath: string,
|
|
86
|
+
oldName: string,
|
|
87
|
+
newName: string
|
|
88
|
+
): CollectionConfig {
|
|
89
|
+
const config = loadCollectionConfig(configPath);
|
|
90
|
+
|
|
91
|
+
if (!config) {
|
|
92
|
+
throw new Error('Config file not found');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!config.collections[oldName]) {
|
|
96
|
+
throw new Error(`Collection "${oldName}" not found`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
config.collections[newName] = config.collections[oldName];
|
|
100
|
+
delete config.collections[oldName];
|
|
101
|
+
|
|
102
|
+
saveCollectionConfig(configPath, config);
|
|
103
|
+
return config;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function addContext(
|
|
107
|
+
configPath: string,
|
|
108
|
+
collectionName: string,
|
|
109
|
+
pathPrefix: string,
|
|
110
|
+
description: string
|
|
111
|
+
): CollectionConfig {
|
|
112
|
+
const config = loadCollectionConfig(configPath);
|
|
113
|
+
|
|
114
|
+
if (!config) {
|
|
115
|
+
throw new Error('Config file not found');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!config.collections[collectionName]) {
|
|
119
|
+
throw new Error(`Collection "${collectionName}" not found`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!config.collections[collectionName].context) {
|
|
123
|
+
config.collections[collectionName].context = {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
config.collections[collectionName].context![pathPrefix] = description;
|
|
127
|
+
|
|
128
|
+
saveCollectionConfig(configPath, config);
|
|
129
|
+
return config;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function findContextForPath(config: CollectionConfig, filePath: string): string | null {
|
|
133
|
+
let longestMatch: { prefix: string; description: string } | null = null;
|
|
134
|
+
|
|
135
|
+
for (const collectionData of Object.values(config.collections)) {
|
|
136
|
+
if (!collectionData.context) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [prefix, description] of Object.entries(collectionData.context)) {
|
|
141
|
+
if (filePath.includes(prefix)) {
|
|
142
|
+
if (!longestMatch || prefix.length > longestMatch.prefix.length) {
|
|
143
|
+
longestMatch = { prefix, description };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return longestMatch ? longestMatch.description : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function listAllContexts(
|
|
153
|
+
config: CollectionConfig
|
|
154
|
+
): Array<{ collection: string; prefix: string; description: string }> {
|
|
155
|
+
const contexts: Array<{ collection: string; prefix: string; description: string }> = [];
|
|
156
|
+
|
|
157
|
+
for (const [collectionName, collectionData] of Object.entries(config.collections)) {
|
|
158
|
+
if (!collectionData.context) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const [prefix, description] of Object.entries(collectionData.context)) {
|
|
163
|
+
contexts.push({
|
|
164
|
+
collection: collectionName,
|
|
165
|
+
prefix,
|
|
166
|
+
description,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return contexts;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function scanCollectionFiles(collection: Collection): Promise<string[]> {
|
|
175
|
+
const expandedPath = collection.path.replace(/^~/, os.homedir());
|
|
176
|
+
|
|
177
|
+
if (!fs.existsSync(expandedPath)) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const files = await fg(collection.pattern, {
|
|
182
|
+
cwd: expandedPath,
|
|
183
|
+
absolute: true,
|
|
184
|
+
onlyFiles: true,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return files;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resolveCollectionPath(collection: Collection, basePath: string): string {
|
|
191
|
+
return collection.path;
|
|
192
|
+
}
|