@nano-step/nano-brain 2026.1.14
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/.opencode/command/nano-brain-init.md +13 -0
- package/.opencode/command/nano-brain-reindex.md +11 -0
- package/.opencode/command/nano-brain-status.md +12 -0
- package/AGENTS.md +41 -0
- package/AGENTS_SNIPPET.md +44 -0
- package/CHANGELOG.md +186 -0
- package/README.md +298 -0
- package/SKILL.md +109 -0
- package/bin/cli.js +29 -0
- package/commands/nano-brain-init.md +36 -0
- package/commands/nano-brain-reindex.md +31 -0
- package/commands/nano-brain-status.md +32 -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/changes/fix-session-harvest-workspace-scoping/.openspec.yaml +2 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/design.md +84 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/proposal.md +26 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/specs/workspace-scoping/spec.md +65 -0
- package/openspec/changes/fix-session-harvest-workspace-scoping/tasks.md +33 -0
- package/openspec/changes/performance-and-search-quality/.openspec.yaml +2 -0
- package/openspec/changes/performance-and-search-quality/proposal.md +37 -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 +37 -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 +425 -0
- package/src/collections.ts +217 -0
- package/src/embeddings.ts +325 -0
- package/src/expansion.ts +79 -0
- package/src/harvester.ts +306 -0
- package/src/index.ts +778 -0
- package/src/reranker.ts +103 -0
- package/src/search.ts +294 -0
- package/src/server.ts +876 -0
- package/src/storage.ts +221 -0
- package/src/store.ts +653 -0
- package/src/types.ts +215 -0
- package/src/watcher.ts +389 -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 +219 -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 +530 -0
- package/test/watcher.test.ts +717 -0
- package/test/workspace.test.ts +239 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +16 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { startServer } from './server.js';
|
|
2
|
+
import { createStore, computeHash, indexDocument, extractProjectHashFromPath } from './store.js';
|
|
3
|
+
import { loadCollectionConfig, addCollection, removeCollection, renameCollection, listCollections, getCollections, scanCollectionFiles, saveCollectionConfig } from './collections.js';
|
|
4
|
+
import { harvestSessions } from './harvester.js';
|
|
5
|
+
import { createEmbeddingProvider, detectOllamaUrl, checkOllamaHealth } from './embeddings.js';
|
|
6
|
+
import { hybridSearch } from './search.js';
|
|
7
|
+
import { indexCodebase, embedPendingCodebase } from './codebase.js';
|
|
8
|
+
import type { SearchResult } from './types.js';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import * as os from 'os';
|
|
12
|
+
import * as crypto from 'crypto';
|
|
13
|
+
|
|
14
|
+
function resolveOpenCodeStorageDir(): string {
|
|
15
|
+
// XDG path (Linux): ~/.local/share/opencode/storage
|
|
16
|
+
const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
|
17
|
+
const xdgPath = path.join(xdgData, 'opencode', 'storage');
|
|
18
|
+
if (fs.existsSync(xdgPath)) return xdgPath;
|
|
19
|
+
// macOS / legacy fallback: ~/.opencode/storage
|
|
20
|
+
return path.join(os.homedir(), '.opencode', 'storage');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const NANO_BRAIN_HOME = path.join(os.homedir(), '.nano-brain');
|
|
24
|
+
const DEFAULT_DB_DIR = path.join(NANO_BRAIN_HOME, 'data');
|
|
25
|
+
const DEFAULT_CONFIG = path.join(NANO_BRAIN_HOME, 'config.yml');
|
|
26
|
+
const DEFAULT_OUTPUT_DIR = path.join(NANO_BRAIN_HOME, 'sessions');
|
|
27
|
+
const DEFAULT_MEMORY_DIR = path.join(NANO_BRAIN_HOME, 'memory');
|
|
28
|
+
|
|
29
|
+
interface GlobalOptions {
|
|
30
|
+
dbPath: string;
|
|
31
|
+
configPath: string;
|
|
32
|
+
remaining: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseGlobalOptions(args: string[]): GlobalOptions {
|
|
36
|
+
let dbPath = path.join(DEFAULT_DB_DIR, 'default.sqlite');
|
|
37
|
+
let configPath = DEFAULT_CONFIG;
|
|
38
|
+
const remaining: string[] = [];
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < args.length; i++) {
|
|
41
|
+
const arg = args[i];
|
|
42
|
+
|
|
43
|
+
if (arg.startsWith('--db=')) {
|
|
44
|
+
dbPath = arg.substring(5);
|
|
45
|
+
} else if (arg === '--db' && i + 1 < args.length) {
|
|
46
|
+
dbPath = args[++i];
|
|
47
|
+
} else if (arg.startsWith('--config=')) {
|
|
48
|
+
configPath = arg.substring(9);
|
|
49
|
+
} else if (arg === '--config' && i + 1 < args.length) {
|
|
50
|
+
configPath = args[++i];
|
|
51
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
52
|
+
showHelp();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
55
|
+
showVersion();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
} else {
|
|
58
|
+
remaining.push(arg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { dbPath, configPath, remaining };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve per-workspace database path.
|
|
68
|
+
* If dbPath ends with 'default.sqlite', replace with '{dirName}-{hash}.sqlite'
|
|
69
|
+
* where dirName is the sanitized basename of workspaceRoot and hash is first 12 chars of SHA-256.
|
|
70
|
+
* If user explicitly set --db=, that path is returned unchanged.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveDbPath(dbPath: string, workspaceRoot: string): string {
|
|
73
|
+
const isDefaultDb = dbPath.endsWith('/default.sqlite') || dbPath.endsWith('\\default.sqlite');
|
|
74
|
+
if (!isDefaultDb) return dbPath;
|
|
75
|
+
const hash = crypto.createHash('sha256').update(workspaceRoot).digest('hex').substring(0, 12);
|
|
76
|
+
const dirName = path.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
77
|
+
return path.join(path.dirname(dbPath), `${dirName}-${hash}.sqlite`);
|
|
78
|
+
}
|
|
79
|
+
export function showHelp(): void {
|
|
80
|
+
console.log(`
|
|
81
|
+
nano-brain - Memory system with hybrid search
|
|
82
|
+
nano-brain [global-options] <command> [command-options]
|
|
83
|
+
--db=<path> SQLite database path (default: ~/.nano-brain/data/default.sqlite)
|
|
84
|
+
--config=<path> Config YAML path (default: ~/.nano-brain/config.yml)
|
|
85
|
+
--help, -h Show help
|
|
86
|
+
--version, -v Show version
|
|
87
|
+
init Initialize nano-brain for current workspace
|
|
88
|
+
--root=<path> Workspace root (default: current directory)
|
|
89
|
+
mcp Start MCP server (default command if no args)
|
|
90
|
+
--http Use HTTP transport instead of stdio
|
|
91
|
+
--port=<n> HTTP port (default: 8282)
|
|
92
|
+
--daemon Run as background daemon
|
|
93
|
+
stop Stop running daemon
|
|
94
|
+
status Show index health, embedding server status, and stats
|
|
95
|
+
collection Manage collections
|
|
96
|
+
add <name> <path> [--pattern=<glob>]
|
|
97
|
+
remove <name>
|
|
98
|
+
list
|
|
99
|
+
rename <old> <new>
|
|
100
|
+
embed Generate embeddings for unembedded chunks
|
|
101
|
+
--force Re-embed all chunks
|
|
102
|
+
-n <limit> Max results (default: 10)
|
|
103
|
+
-c <collection> Filter by collection
|
|
104
|
+
--json Output as JSON
|
|
105
|
+
--files Show file paths only
|
|
106
|
+
query <query> Full hybrid search (same options as search)
|
|
107
|
+
--min-score=<n> Minimum score threshold
|
|
108
|
+
--full Show full content
|
|
109
|
+
--from=<line> Start line
|
|
110
|
+
--lines=<n> Number of lines
|
|
111
|
+
harvest Manually trigger session harvesting
|
|
112
|
+
Embedding Config (~/.nano-brain/config.yml):
|
|
113
|
+
embedding:
|
|
114
|
+
provider: ollama # 'ollama' or 'local'
|
|
115
|
+
url: http://localhost:11434 # Ollama API URL
|
|
116
|
+
model: nomic-embed-text # embedding model name
|
|
117
|
+
watcher:
|
|
118
|
+
pollIntervalMs: 120000 # reindex interval (default: 120000 = 2min)
|
|
119
|
+
sessionPollMs: 120000 # session harvest interval (default: 120000)
|
|
120
|
+
embedIntervalMs: 60000 # embedding interval (default: 60000 = 1min)
|
|
121
|
+
workspaces:
|
|
122
|
+
/path/to/project-a:
|
|
123
|
+
codebase:
|
|
124
|
+
enabled: true
|
|
125
|
+
/path/to/project-b:
|
|
126
|
+
codebase:
|
|
127
|
+
enabled: true
|
|
128
|
+
extensions: [".ts", ".vue"]
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function showVersion(): void {
|
|
133
|
+
const pkgPath = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'package.json');
|
|
134
|
+
try {
|
|
135
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
136
|
+
console.log(`nano-brain v${pkg.version}`);
|
|
137
|
+
} catch {
|
|
138
|
+
console.log('nano-brain (unknown version)');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatSearchOutput(results: SearchResult[], format: 'text' | 'json' | 'files'): string {
|
|
143
|
+
if (format === 'json') {
|
|
144
|
+
return JSON.stringify(results, null, 2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (format === 'files') {
|
|
148
|
+
return results.map(r => r.path).join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const lines: string[] = [];
|
|
152
|
+
for (const result of results) {
|
|
153
|
+
lines.push(`[${result.docid}] ${result.collection}/${result.path}`);
|
|
154
|
+
lines.push(` Score: ${result.score.toFixed(4)} | ${result.title}`);
|
|
155
|
+
if (result.snippet) {
|
|
156
|
+
lines.push(` ${result.snippet}`);
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
}
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function handleMcp(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
164
|
+
let useHttp = false;
|
|
165
|
+
let port = 8282;
|
|
166
|
+
let daemon = false;
|
|
167
|
+
|
|
168
|
+
for (const arg of commandArgs) {
|
|
169
|
+
if (arg === '--http') {
|
|
170
|
+
useHttp = true;
|
|
171
|
+
} else if (arg.startsWith('--port=')) {
|
|
172
|
+
port = parseInt(arg.substring(7), 10);
|
|
173
|
+
} else if (arg === '--daemon') {
|
|
174
|
+
daemon = true;
|
|
175
|
+
} else if (arg === 'stop') {
|
|
176
|
+
console.log('Daemon stop not implemented yet');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
await startServer({
|
|
182
|
+
dbPath: globalOpts.dbPath,
|
|
183
|
+
configPath: globalOpts.configPath,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function handleCollection(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
188
|
+
const subcommand = commandArgs[0];
|
|
189
|
+
|
|
190
|
+
if (!subcommand) {
|
|
191
|
+
console.error('Missing collection subcommand (add, remove, list, rename)');
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
switch (subcommand) {
|
|
196
|
+
case 'add': {
|
|
197
|
+
const name = commandArgs[1];
|
|
198
|
+
const collectionPath = commandArgs[2];
|
|
199
|
+
let pattern = '**/*.md';
|
|
200
|
+
|
|
201
|
+
for (const arg of commandArgs.slice(3)) {
|
|
202
|
+
if (arg.startsWith('--pattern=')) {
|
|
203
|
+
pattern = arg.substring(10);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!name || !collectionPath) {
|
|
208
|
+
console.error('Usage: collection add <name> <path> [--pattern=<glob>]');
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
addCollection(globalOpts.configPath, name, collectionPath, pattern);
|
|
213
|
+
console.log(`✅ Added collection "${name}"`);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case 'remove': {
|
|
218
|
+
const name = commandArgs[1];
|
|
219
|
+
if (!name) {
|
|
220
|
+
console.error('Usage: collection remove <name>');
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
removeCollection(globalOpts.configPath, name);
|
|
225
|
+
console.log(`✅ Removed collection "${name}"`);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
case 'list': {
|
|
230
|
+
const config = loadCollectionConfig(globalOpts.configPath);
|
|
231
|
+
if (!config) {
|
|
232
|
+
console.log('No collections configured');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const names = listCollections(config);
|
|
237
|
+
if (names.length === 0) {
|
|
238
|
+
console.log('No collections configured');
|
|
239
|
+
} else {
|
|
240
|
+
console.log('Collections:');
|
|
241
|
+
for (const name of names) {
|
|
242
|
+
const coll = config.collections[name];
|
|
243
|
+
console.log(` ${name}: ${coll.path} (${coll.pattern || '**/*.md'})`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case 'rename': {
|
|
250
|
+
const oldName = commandArgs[1];
|
|
251
|
+
const newName = commandArgs[2];
|
|
252
|
+
|
|
253
|
+
if (!oldName || !newName) {
|
|
254
|
+
console.error('Usage: collection rename <old> <new>');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
renameCollection(globalOpts.configPath, oldName, newName);
|
|
259
|
+
console.log(`✅ Renamed collection "${oldName}" to "${newName}"`);
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
default:
|
|
264
|
+
console.error(`Unknown collection subcommand: ${subcommand}`);
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function handleStatus(globalOpts: GlobalOptions): Promise<void> {
|
|
270
|
+
const store = createStore(globalOpts.dbPath);
|
|
271
|
+
const config = loadCollectionConfig(globalOpts.configPath);
|
|
272
|
+
const health = store.getIndexHealth();
|
|
273
|
+
console.log('nano-brain Status');
|
|
274
|
+
console.log('═══════════════════════════════════════════════════');
|
|
275
|
+
console.log('');
|
|
276
|
+
console.log('Index:');
|
|
277
|
+
console.log(` Documents: ${health.documentCount}`);
|
|
278
|
+
console.log(` Embedded: ${health.embeddedCount}`);
|
|
279
|
+
console.log(` Pending embeddings: ${health.pendingEmbeddings}`);
|
|
280
|
+
console.log(` Database size: ${(health.databaseSize / 1024 / 1024).toFixed(2)} MB`);
|
|
281
|
+
console.log('');
|
|
282
|
+
|
|
283
|
+
if (health.collections.length > 0) {
|
|
284
|
+
console.log('Collections:');
|
|
285
|
+
for (const coll of health.collections) {
|
|
286
|
+
console.log(` ${coll.name}: ${coll.documentCount} documents`);
|
|
287
|
+
}
|
|
288
|
+
console.log('');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const embeddingConfig = config?.embedding;
|
|
292
|
+
const ollamaUrl = embeddingConfig?.url || detectOllamaUrl();
|
|
293
|
+
const ollamaModel = embeddingConfig?.model || 'nomic-embed-text';
|
|
294
|
+
const provider = embeddingConfig?.provider || 'ollama';
|
|
295
|
+
|
|
296
|
+
console.log('Embedding Server:');
|
|
297
|
+
console.log(` Provider: ${provider}`);
|
|
298
|
+
console.log(` URL: ${ollamaUrl}`);
|
|
299
|
+
console.log(` Model: ${ollamaModel}`);
|
|
300
|
+
|
|
301
|
+
if (provider !== 'local') {
|
|
302
|
+
const ollamaHealth = await checkOllamaHealth(ollamaUrl);
|
|
303
|
+
if (ollamaHealth.reachable) {
|
|
304
|
+
const hasModel = ollamaHealth.models?.some(m => m.startsWith(ollamaModel));
|
|
305
|
+
console.log(` Status: ✅ connected`);
|
|
306
|
+
console.log(` Model: ${hasModel ? '✅ available' : '❌ not found — run: ollama pull ' + ollamaModel}`);
|
|
307
|
+
if (ollamaHealth.models && ollamaHealth.models.length > 0) {
|
|
308
|
+
console.log(` Available: ${ollamaHealth.models.join(', ')}`);
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
console.log(` Status: ❌ unreachable (${ollamaHealth.error})`);
|
|
312
|
+
console.log(` Fallback: local GGUF (node-llama-cpp)`);
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
console.log(` Status: local GGUF mode`);
|
|
316
|
+
}
|
|
317
|
+
console.log('');
|
|
318
|
+
|
|
319
|
+
console.log('Models:');
|
|
320
|
+
console.log(` Embedding: ${health.modelStatus.embedding}`);
|
|
321
|
+
console.log(` Reranker: ${health.modelStatus.reranker}`);
|
|
322
|
+
console.log(` Expander: ${health.modelStatus.expander}`);
|
|
323
|
+
store.close();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function handleInit(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
327
|
+
let root = process.cwd();
|
|
328
|
+
|
|
329
|
+
for (const arg of commandArgs) {
|
|
330
|
+
if (arg.startsWith('--root=')) {
|
|
331
|
+
root = arg.substring(7);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
// Resolve per-workspace DB path with the actual root
|
|
337
|
+
globalOpts.dbPath = resolveDbPath(globalOpts.dbPath, root);
|
|
338
|
+
const configDir = path.dirname(globalOpts.configPath);
|
|
339
|
+
const configPath = globalOpts.configPath;
|
|
340
|
+
|
|
341
|
+
if (!fs.existsSync(configDir)) {
|
|
342
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let config = loadCollectionConfig(configPath);
|
|
346
|
+
const isNewConfig = !config;
|
|
347
|
+
|
|
348
|
+
if (!config) {
|
|
349
|
+
config = {
|
|
350
|
+
collections: {
|
|
351
|
+
memory: {
|
|
352
|
+
path: DEFAULT_MEMORY_DIR,
|
|
353
|
+
pattern: '**/*.md',
|
|
354
|
+
update: 'auto'
|
|
355
|
+
},
|
|
356
|
+
sessions: {
|
|
357
|
+
path: DEFAULT_OUTPUT_DIR,
|
|
358
|
+
pattern: '**/*.md',
|
|
359
|
+
update: 'auto'
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
embedding: {
|
|
363
|
+
provider: 'ollama',
|
|
364
|
+
url: detectOllamaUrl(),
|
|
365
|
+
model: 'nomic-embed-text'
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
saveCollectionConfig(configPath, config);
|
|
369
|
+
console.log(`✅ Created config: ${configPath}`);
|
|
370
|
+
} else if (!config.embedding) {
|
|
371
|
+
config.embedding = {
|
|
372
|
+
provider: 'ollama',
|
|
373
|
+
url: detectOllamaUrl(),
|
|
374
|
+
model: 'nomic-embed-text'
|
|
375
|
+
};
|
|
376
|
+
saveCollectionConfig(configPath, config);
|
|
377
|
+
console.log(`✅ Updated config with embedding section`);
|
|
378
|
+
} else {
|
|
379
|
+
console.log(`ℹ️ Config exists: ${configPath}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const ollamaUrl = config.embedding?.url || detectOllamaUrl();
|
|
383
|
+
const ollamaHealth = await checkOllamaHealth(ollamaUrl);
|
|
384
|
+
|
|
385
|
+
if (ollamaHealth.reachable) {
|
|
386
|
+
console.log(`✅ Ollama reachable at ${ollamaUrl}`);
|
|
387
|
+
} else {
|
|
388
|
+
console.log(`⚠️ Ollama not reachable at ${ollamaUrl} — will use local GGUF fallback`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const store = createStore(globalOpts.dbPath);
|
|
392
|
+
if (!config.workspaces) {
|
|
393
|
+
config.workspaces = {};
|
|
394
|
+
}
|
|
395
|
+
if (!config.workspaces[root]) {
|
|
396
|
+
config.workspaces[root] = {
|
|
397
|
+
codebase: { enabled: true }
|
|
398
|
+
};
|
|
399
|
+
saveCollectionConfig(configPath, config);
|
|
400
|
+
console.log(`✅ Enabled codebase indexing for workspace: ${root}`);
|
|
401
|
+
} else {
|
|
402
|
+
console.log(`ℹ️ Workspace already configured: ${root}`);
|
|
403
|
+
}
|
|
404
|
+
console.log('📂 Indexing codebase...');
|
|
405
|
+
const projectHash = crypto.createHash('sha256').update(root).digest('hex').substring(0, 12);
|
|
406
|
+
const wsConfig = config.workspaces[root];
|
|
407
|
+
const codebaseConfig = wsConfig?.codebase ?? { enabled: true };
|
|
408
|
+
const codebaseStats = await indexCodebase(store, root, codebaseConfig, projectHash);
|
|
409
|
+
console.log(`✅ Indexed ${codebaseStats.filesIndexed} files (${codebaseStats.filesSkippedUnchanged} unchanged)`);
|
|
410
|
+
|
|
411
|
+
console.log('📜 Harvesting sessions...');
|
|
412
|
+
const sessionDir = resolveOpenCodeStorageDir();
|
|
413
|
+
const sessions = await harvestSessions({ sessionDir, outputDir: DEFAULT_OUTPUT_DIR });
|
|
414
|
+
console.log(`✅ Harvested ${sessions.length} sessions`);
|
|
415
|
+
|
|
416
|
+
console.log('📚 Indexing collections...');
|
|
417
|
+
const collections = getCollections(config);
|
|
418
|
+
// Only index core collections during init; MCP watcher handles the rest
|
|
419
|
+
const initCollections = collections.filter(c => c.name === 'memory' || c.name === 'sessions');
|
|
420
|
+
const skippedCount = collections.length - initCollections.length;
|
|
421
|
+
let totalIndexed = 0;
|
|
422
|
+
for (const collection of initCollections) {
|
|
423
|
+
const files = await scanCollectionFiles(collection);
|
|
424
|
+
let collIndexed = 0;
|
|
425
|
+
for (const file of files) {
|
|
426
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
427
|
+
const title = path.basename(file, path.extname(file));
|
|
428
|
+
const effectiveProjectHash = collection.name === 'sessions'
|
|
429
|
+
? extractProjectHashFromPath(file, DEFAULT_OUTPUT_DIR) ?? projectHash
|
|
430
|
+
: projectHash;
|
|
431
|
+
const result = indexDocument(store, collection.name, file, content, title, effectiveProjectHash);
|
|
432
|
+
if (!result.skipped) {
|
|
433
|
+
collIndexed++;
|
|
434
|
+
totalIndexed++;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
console.log(` ${collection.name}: ${files.length} files (${collIndexed} new)`);
|
|
438
|
+
}
|
|
439
|
+
if (skippedCount > 0) {
|
|
440
|
+
console.log(` (${skippedCount} other collection(s) deferred to MCP watcher)`);
|
|
441
|
+
}
|
|
442
|
+
console.log(`✅ Indexed ${totalIndexed} documents from collections`);
|
|
443
|
+
// Generate embeddings — cap at 50 during init, MCP server handles the rest
|
|
444
|
+
console.log('🧠 Generating embeddings...');
|
|
445
|
+
const embeddingConfig = config.embedding;
|
|
446
|
+
const provider = await createEmbeddingProvider({ embeddingConfig });
|
|
447
|
+
const INIT_EMBED_CAP = 50;
|
|
448
|
+
if (provider) {
|
|
449
|
+
store.ensureVecTable(provider.getDimensions());
|
|
450
|
+
let embedded = 0;
|
|
451
|
+
// Embed up to INIT_EMBED_CAP documents during init for quick startup
|
|
452
|
+
while (embedded < INIT_EMBED_CAP) {
|
|
453
|
+
const row = store.getNextHashNeedingEmbedding(projectHash);
|
|
454
|
+
if (!row) break;
|
|
455
|
+
try {
|
|
456
|
+
const result = await provider.embed(row.body.slice(0, 8000));
|
|
457
|
+
store.insertEmbedding(row.hash, 0, 0, result.embedding, 'nomic-embed-text-v1.5');
|
|
458
|
+
embedded++;
|
|
459
|
+
} catch {
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const remaining = store.getHashesNeedingEmbedding().length;
|
|
464
|
+
console.log(`✅ Embedded ${embedded} documents${remaining > 0 ? ` (${remaining} remaining — MCP server will continue in background)` : ''}`);
|
|
465
|
+
provider.dispose();
|
|
466
|
+
} else {
|
|
467
|
+
const pending = store.getHashesNeedingEmbedding();
|
|
468
|
+
console.log(`⚠️ No embedding provider available — ${pending.length} documents pending`);
|
|
469
|
+
console.log(` Run 'npx nano-brain embed' later to generate embeddings`);
|
|
470
|
+
}
|
|
471
|
+
store.close();
|
|
472
|
+
|
|
473
|
+
const agentsPath = path.join(root, 'AGENTS.md');
|
|
474
|
+
const snippetPath = path.join(path.dirname(import.meta.url.replace('file://', '')), '..', 'AGENTS_SNIPPET.md');
|
|
475
|
+
const startMarker = '<!-- OPENCODE-MEMORY:START -->';
|
|
476
|
+
const endMarker = '<!-- OPENCODE-MEMORY:END -->';
|
|
477
|
+
|
|
478
|
+
if (fs.existsSync(snippetPath)) {
|
|
479
|
+
const snippet = fs.readFileSync(snippetPath, 'utf-8');
|
|
480
|
+
|
|
481
|
+
if (fs.existsSync(agentsPath)) {
|
|
482
|
+
let agentsContent = fs.readFileSync(agentsPath, 'utf-8');
|
|
483
|
+
const startIdx = agentsContent.indexOf(startMarker);
|
|
484
|
+
const endIdx = agentsContent.indexOf(endMarker);
|
|
485
|
+
|
|
486
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
487
|
+
agentsContent = agentsContent.substring(0, startIdx) + snippet + agentsContent.substring(endIdx + endMarker.length);
|
|
488
|
+
fs.writeFileSync(agentsPath, agentsContent);
|
|
489
|
+
console.log(`✅ Updated AGENTS.md with memory snippet`);
|
|
490
|
+
} else {
|
|
491
|
+
fs.appendFileSync(agentsPath, '\n\n' + snippet);
|
|
492
|
+
console.log(`✅ Appended memory snippet to AGENTS.md`);
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
fs.writeFileSync(agentsPath, snippet);
|
|
496
|
+
console.log(`✅ Created AGENTS.md with memory snippet`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
// Install slash commands to both global and project .opencode/command/
|
|
502
|
+
const commandsDir = path.join(path.dirname(new URL(import.meta.url).pathname), '..', 'commands');
|
|
503
|
+
if (fs.existsSync(commandsDir)) {
|
|
504
|
+
const commandFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
|
505
|
+
const targets = [
|
|
506
|
+
path.join(os.homedir(), '.config', 'opencode', '.opencode', 'command'),
|
|
507
|
+
path.join(root, '.opencode', 'command'),
|
|
508
|
+
];
|
|
509
|
+
for (const targetDir of targets) {
|
|
510
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
511
|
+
for (const file of commandFiles) {
|
|
512
|
+
fs.copyFileSync(path.join(commandsDir, file), path.join(targetDir, file));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
console.log(`✅ Installed ${commandFiles.length} slash commands (global + project)`);
|
|
516
|
+
}
|
|
517
|
+
console.log('');
|
|
518
|
+
console.log('nano-brain initialized! Run `npx nano-brain status` to verify.');
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function handleUpdate(globalOpts: GlobalOptions): Promise<void> {
|
|
522
|
+
const store = createStore(globalOpts.dbPath);
|
|
523
|
+
const config = loadCollectionConfig(globalOpts.configPath);
|
|
524
|
+
|
|
525
|
+
if (!config) {
|
|
526
|
+
console.error('No config file found');
|
|
527
|
+
store.close();
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const collections = getCollections(config);
|
|
532
|
+
let totalIndexed = 0;
|
|
533
|
+
let totalSkipped = 0;
|
|
534
|
+
|
|
535
|
+
for (const collection of collections) {
|
|
536
|
+
console.log(`Scanning collection: ${collection.name}`);
|
|
537
|
+
const files = await scanCollectionFiles(collection);
|
|
538
|
+
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
const content = fs.readFileSync(file, 'utf-8');
|
|
541
|
+
const title = path.basename(file, path.extname(file));
|
|
542
|
+
const effectiveProjectHash = collection.name === 'sessions'
|
|
543
|
+
? extractProjectHashFromPath(file, DEFAULT_OUTPUT_DIR)
|
|
544
|
+
: undefined;
|
|
545
|
+
const result = indexDocument(store, collection.name, file, content, title, effectiveProjectHash);
|
|
546
|
+
|
|
547
|
+
if (result.skipped) {
|
|
548
|
+
totalSkipped++;
|
|
549
|
+
} else {
|
|
550
|
+
totalIndexed++;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
console.log(`✅ Indexed ${totalIndexed} documents, skipped ${totalSkipped}`);
|
|
556
|
+
store.close();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function handleEmbed(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
560
|
+
let force = false;
|
|
561
|
+
|
|
562
|
+
for (const arg of commandArgs) {
|
|
563
|
+
if (arg === '--force') {
|
|
564
|
+
force = true;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const store = createStore(globalOpts.dbPath);
|
|
569
|
+
const hashes = store.getHashesNeedingEmbedding();
|
|
570
|
+
|
|
571
|
+
if (hashes.length === 0) {
|
|
572
|
+
console.log('No chunks need embedding');
|
|
573
|
+
store.close();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
console.log(`Found ${hashes.length} chunks needing embeddings`);
|
|
578
|
+
console.log('Loading embedding model...');
|
|
579
|
+
|
|
580
|
+
const provider = await createEmbeddingProvider();
|
|
581
|
+
|
|
582
|
+
if (!provider) {
|
|
583
|
+
console.error('Failed to load embedding provider');
|
|
584
|
+
store.close();
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
console.log('Generating embeddings...');
|
|
589
|
+
|
|
590
|
+
for (let i = 0; i < hashes.length; i++) {
|
|
591
|
+
const { hash, body } = hashes[i];
|
|
592
|
+
const result = await provider.embed(body);
|
|
593
|
+
store.insertEmbedding(hash, 0, 0, result.embedding, result.model);
|
|
594
|
+
|
|
595
|
+
if ((i + 1) % 10 === 0) {
|
|
596
|
+
console.log(` Progress: ${i + 1}/${hashes.length}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
console.log(`✅ Generated ${hashes.length} embeddings`);
|
|
601
|
+
|
|
602
|
+
provider.dispose();
|
|
603
|
+
store.close();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async function handleSearch(
|
|
607
|
+
globalOpts: GlobalOptions,
|
|
608
|
+
commandArgs: string[],
|
|
609
|
+
mode: 'fts' | 'vec' | 'hybrid'
|
|
610
|
+
): Promise<void> {
|
|
611
|
+
const query = commandArgs[0];
|
|
612
|
+
|
|
613
|
+
if (!query) {
|
|
614
|
+
console.error('Missing query argument');
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let limit = 10;
|
|
619
|
+
let collection: string | undefined;
|
|
620
|
+
let format: 'text' | 'json' | 'files' = 'text';
|
|
621
|
+
let minScore = 0;
|
|
622
|
+
|
|
623
|
+
for (let i = 1; i < commandArgs.length; i++) {
|
|
624
|
+
const arg = commandArgs[i];
|
|
625
|
+
|
|
626
|
+
if (arg === '-n' && i + 1 < commandArgs.length) {
|
|
627
|
+
limit = parseInt(commandArgs[++i], 10);
|
|
628
|
+
} else if (arg === '-c' && i + 1 < commandArgs.length) {
|
|
629
|
+
collection = commandArgs[++i];
|
|
630
|
+
} else if (arg === '--json') {
|
|
631
|
+
format = 'json';
|
|
632
|
+
} else if (arg === '--files') {
|
|
633
|
+
format = 'files';
|
|
634
|
+
} else if (arg.startsWith('--min-score=')) {
|
|
635
|
+
minScore = parseFloat(arg.substring(12));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const store = createStore(globalOpts.dbPath);
|
|
640
|
+
let results: SearchResult[];
|
|
641
|
+
|
|
642
|
+
if (mode === 'fts') {
|
|
643
|
+
results = store.searchFTS(query, limit, collection);
|
|
644
|
+
} else if (mode === 'vec') {
|
|
645
|
+
const provider = await createEmbeddingProvider();
|
|
646
|
+
if (!provider) {
|
|
647
|
+
console.error('Vector search requires embedding model');
|
|
648
|
+
store.close();
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const { embedding } = await provider.embed(query);
|
|
653
|
+
results = store.searchVec(query, embedding, limit, collection);
|
|
654
|
+
provider.dispose();
|
|
655
|
+
} else {
|
|
656
|
+
const provider = await createEmbeddingProvider();
|
|
657
|
+
results = await hybridSearch(
|
|
658
|
+
store,
|
|
659
|
+
{ query, limit, collection, minScore },
|
|
660
|
+
{ embedder: provider }
|
|
661
|
+
);
|
|
662
|
+
provider?.dispose();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
console.log(formatSearchOutput(results, format));
|
|
666
|
+
store.close();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function handleGet(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
|
|
670
|
+
const id = commandArgs[0];
|
|
671
|
+
|
|
672
|
+
if (!id) {
|
|
673
|
+
console.error('Missing document id or path');
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
let full = false;
|
|
678
|
+
let fromLine: number | undefined;
|
|
679
|
+
let maxLines: number | undefined;
|
|
680
|
+
|
|
681
|
+
for (let i = 1; i < commandArgs.length; i++) {
|
|
682
|
+
const arg = commandArgs[i];
|
|
683
|
+
|
|
684
|
+
if (arg === '--full') {
|
|
685
|
+
full = true;
|
|
686
|
+
} else if (arg.startsWith('--from=')) {
|
|
687
|
+
fromLine = parseInt(arg.substring(7), 10);
|
|
688
|
+
} else if (arg.startsWith('--lines=')) {
|
|
689
|
+
maxLines = parseInt(arg.substring(8), 10);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const store = createStore(globalOpts.dbPath);
|
|
694
|
+
const doc = store.findDocument(id);
|
|
695
|
+
|
|
696
|
+
if (!doc) {
|
|
697
|
+
console.error(`Document not found: ${id}`);
|
|
698
|
+
store.close();
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
console.log(`Document: ${doc.collection}/${doc.path}`);
|
|
703
|
+
console.log(`Title: ${doc.title}`);
|
|
704
|
+
console.log(`Docid: ${doc.hash.substring(0, 6)}`);
|
|
705
|
+
console.log('');
|
|
706
|
+
|
|
707
|
+
const body = store.getDocumentBody(doc.hash, fromLine, maxLines);
|
|
708
|
+
if (body) {
|
|
709
|
+
console.log(body);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
store.close();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function handleHarvest(globalOpts: GlobalOptions): Promise<void> {
|
|
716
|
+
const sessionDir = resolveOpenCodeStorageDir();
|
|
717
|
+
const outputDir = DEFAULT_OUTPUT_DIR;
|
|
718
|
+
|
|
719
|
+
console.log('Harvesting sessions...');
|
|
720
|
+
const sessions = await harvestSessions({ sessionDir, outputDir });
|
|
721
|
+
|
|
722
|
+
console.log(`✅ Harvested ${sessions.length} sessions to ${outputDir}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
async function main() {
|
|
726
|
+
const args = process.argv.slice(2);
|
|
727
|
+
|
|
728
|
+
const globalOpts = parseGlobalOptions(args);
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
const command = globalOpts.remaining[0] || 'mcp';
|
|
732
|
+
const commandArgs = globalOpts.remaining.slice(1);
|
|
733
|
+
|
|
734
|
+
// Resolve per-workspace DB path (init command handles this separately with --root)
|
|
735
|
+
if (command !== 'init') {
|
|
736
|
+
globalOpts.dbPath = resolveDbPath(globalOpts.dbPath, process.cwd());
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
switch (command) {
|
|
740
|
+
case 'mcp':
|
|
741
|
+
return handleMcp(globalOpts, commandArgs);
|
|
742
|
+
case 'init':
|
|
743
|
+
return handleInit(globalOpts, commandArgs);
|
|
744
|
+
case 'collection':
|
|
745
|
+
return handleCollection(globalOpts, commandArgs);
|
|
746
|
+
case 'status':
|
|
747
|
+
return handleStatus(globalOpts);
|
|
748
|
+
case 'update':
|
|
749
|
+
return handleUpdate(globalOpts);
|
|
750
|
+
case 'embed':
|
|
751
|
+
return handleEmbed(globalOpts, commandArgs);
|
|
752
|
+
case 'search':
|
|
753
|
+
return handleSearch(globalOpts, commandArgs, 'fts');
|
|
754
|
+
case 'vsearch':
|
|
755
|
+
return handleSearch(globalOpts, commandArgs, 'vec');
|
|
756
|
+
case 'query':
|
|
757
|
+
return handleSearch(globalOpts, commandArgs, 'hybrid');
|
|
758
|
+
case 'get':
|
|
759
|
+
return handleGet(globalOpts, commandArgs);
|
|
760
|
+
case 'harvest':
|
|
761
|
+
return handleHarvest(globalOpts);
|
|
762
|
+
default:
|
|
763
|
+
console.error(`Unknown command: ${command}`);
|
|
764
|
+
showHelp();
|
|
765
|
+
process.exit(1);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const isMain = process.argv[1]?.endsWith('index.ts') ||
|
|
770
|
+
process.argv[1]?.endsWith('cli.js') ||
|
|
771
|
+
import.meta.url === `file://${process.argv[1]}`;
|
|
772
|
+
|
|
773
|
+
if (isMain) {
|
|
774
|
+
main().catch(err => {
|
|
775
|
+
console.error('Fatal error:', err);
|
|
776
|
+
process.exit(1);
|
|
777
|
+
});
|
|
778
|
+
}
|