nano-brain 2026.7.13 → 2026.8.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.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/cli/commands/cache.ts +74 -0
  3. package/src/cli/commands/categorize-backfill.ts +116 -0
  4. package/src/cli/commands/code-impact.ts +106 -0
  5. package/src/cli/commands/collection.ts +85 -0
  6. package/src/cli/commands/consolidate.ts +49 -0
  7. package/src/cli/commands/context.ts +112 -0
  8. package/src/cli/commands/detect-changes.ts +82 -0
  9. package/src/cli/commands/docker.ts +191 -0
  10. package/src/cli/commands/embed.ts +121 -0
  11. package/src/cli/commands/focus.ts +59 -0
  12. package/src/cli/commands/get.ts +49 -0
  13. package/src/cli/commands/graph-stats.ts +45 -0
  14. package/src/cli/commands/harvest.ts +15 -0
  15. package/src/cli/commands/impact.ts +75 -0
  16. package/src/cli/commands/init.ts +303 -0
  17. package/src/cli/commands/learning.ts +41 -0
  18. package/src/cli/commands/logs.ts +87 -0
  19. package/src/cli/commands/mcp.ts +43 -0
  20. package/src/cli/commands/qdrant.ts +772 -0
  21. package/src/cli/commands/reindex.ts +79 -0
  22. package/src/cli/commands/reset.ts +151 -0
  23. package/src/cli/commands/rm.ts +284 -0
  24. package/src/cli/commands/search.ts +165 -0
  25. package/src/cli/commands/status.ts +340 -0
  26. package/src/cli/commands/symbols.ts +71 -0
  27. package/src/cli/commands/tags.ts +20 -0
  28. package/src/cli/commands/update.ts +46 -0
  29. package/src/cli/commands/wake-up.ts +79 -0
  30. package/src/cli/commands/write.ts +112 -0
  31. package/src/cli/index.ts +151 -0
  32. package/src/cli/types.ts +5 -0
  33. package/src/cli/utils.ts +324 -0
  34. package/src/codebase.ts +0 -1
  35. package/src/consolidation-worker.ts +1 -109
  36. package/src/consolidation.ts +1 -436
  37. package/src/embeddings.ts +1 -490
  38. package/src/expansion.ts +1 -61
  39. package/src/http/routes.ts +961 -0
  40. package/src/http/server.ts +26 -0
  41. package/src/http/sse.ts +50 -0
  42. package/src/index.ts +1 -3988
  43. package/src/jobs/consolidation-worker.ts +109 -0
  44. package/src/jobs/consolidation.ts +435 -0
  45. package/src/jobs/watcher.ts +874 -0
  46. package/src/llm-provider.ts +1 -140
  47. package/src/mcp/index.ts +50 -0
  48. package/src/mcp/tools/code.ts +578 -0
  49. package/src/mcp/tools/graph.ts +637 -0
  50. package/src/mcp/tools/indexing.ts +97 -0
  51. package/src/mcp/tools/memory.ts +1161 -0
  52. package/src/mcp/tools/types.ts +17 -0
  53. package/src/providers/embeddings.ts +490 -0
  54. package/src/providers/expansion.ts +61 -0
  55. package/src/providers/llm-provider.ts +140 -0
  56. package/src/providers/reranker.ts +104 -0
  57. package/src/providers/vector-store.ts +84 -0
  58. package/src/reranker.ts +1 -104
  59. package/src/search.ts +3 -2
  60. package/src/server/bootstrap.ts +452 -0
  61. package/src/server/types.ts +42 -0
  62. package/src/server/utils.ts +271 -0
  63. package/src/server.ts +4 -4167
  64. package/src/store/cache.ts +113 -0
  65. package/src/store/documents.ts +390 -0
  66. package/src/store/graph.ts +559 -0
  67. package/src/store/index.ts +1033 -0
  68. package/src/store/schema.ts +984 -0
  69. package/src/store/vectors.ts +351 -0
  70. package/src/store.ts +3 -3648
  71. package/src/vector-store.ts +1 -84
  72. package/src/watcher.ts +1 -874
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nano-brain",
3
- "version": "2026.7.13",
3
+ "version": "2026.8.0",
4
4
  "description": "Persistent memory and code intelligence for AI coding agents. Local MCP server with self-learning hybrid search (BM25 + vector + knowledge graph + LLM reranking), automatic session ingestion, codebase indexing, and 22 tools. Learns your preferences over time. Works with OpenCode, Claude, Cursor, Windsurf, and any MCP client.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,74 @@
1
+ import { createStore, resolveProjectLabel } from '../../store.js';
2
+ import * as crypto from 'crypto';
3
+ import { log, cliOutput, cliError } from '../../logger.js';
4
+ import type { GlobalOptions } from '../types.js';
5
+
6
+ export async function handleCache(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
7
+ const subcommand = commandArgs[0];
8
+
9
+ if (!subcommand) {
10
+ cliError('Missing cache subcommand (clear, stats)');
11
+ process.exit(1);
12
+ }
13
+
14
+ log('cli', 'cache subcommand=' + subcommand);
15
+ const store = await createStore(globalOpts.dbPath);
16
+
17
+ switch (subcommand) {
18
+ case 'clear': {
19
+ let all = false;
20
+ let type: string | undefined;
21
+
22
+ for (const arg of commandArgs.slice(1)) {
23
+ if (arg === '--all') {
24
+ all = true;
25
+ } else if (arg.startsWith('--type=')) {
26
+ type = arg.substring(7);
27
+ }
28
+ }
29
+
30
+ if (type) {
31
+ const typeMap: Record<string, string> = { embed: 'qembed', expand: 'expand', rerank: 'rerank' };
32
+ if (!typeMap[type]) {
33
+ cliError(`Invalid cache type "${type}". Valid types: embed, expand, rerank`);
34
+ store.close();
35
+ process.exit(1);
36
+ }
37
+ type = typeMap[type];
38
+ }
39
+
40
+ let deleted: number;
41
+ if (all) {
42
+ deleted = store.clearCache(undefined, type);
43
+ cliOutput(`Cleared all cache entries${type ? ` of type ${type}` : ''} (${deleted} total)`);
44
+ } else {
45
+ const projectHash = crypto.createHash('sha256').update(process.cwd()).digest('hex').substring(0, 12);
46
+ deleted = store.clearCache(projectHash, type);
47
+ cliOutput(`Cleared ${deleted} cache entries for workspace ${resolveProjectLabel(projectHash)}${type ? ` (type: ${type})` : ''}`);
48
+ }
49
+ break;
50
+ }
51
+
52
+ case 'stats': {
53
+ const stats = store.getCacheStats();
54
+ if (stats.length === 0) {
55
+ cliOutput('No cache entries');
56
+ } else {
57
+ cliOutput('Cache Statistics:');
58
+ cliOutput(' Type Project Count');
59
+ cliOutput(' ────────── ────────────────────────────── ─────');
60
+ for (const row of stats) {
61
+ cliOutput(` ${row.type.padEnd(10)} ${resolveProjectLabel(row.projectHash).padEnd(30)} ${row.count}`);
62
+ }
63
+ }
64
+ break;
65
+ }
66
+
67
+ default:
68
+ cliError(`Unknown cache subcommand: ${subcommand}`);
69
+ store.close();
70
+ process.exit(1);
71
+ }
72
+
73
+ store.close();
74
+ }
@@ -0,0 +1,116 @@
1
+ import { createStore } from '../../store.js';
2
+ import { loadCollectionConfig } from '../../collections.js';
3
+ import { createLLMProvider } from '../../llm-provider.js';
4
+ import type { ConsolidationConfig } from '../../types.js';
5
+ import * as crypto from 'crypto';
6
+ import { log, cliOutput, cliError } from '../../logger.js';
7
+ import type { GlobalOptions } from '../types.js';
8
+
9
+ export async function handleCategorizeBackfill(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
10
+ let batchSize = 50;
11
+ let rateLimit = 10;
12
+ let dryRun = false;
13
+ let workspace: string | undefined;
14
+
15
+ for (const arg of commandArgs) {
16
+ if (arg.startsWith('--batch-size=')) {
17
+ batchSize = parseInt(arg.substring(13), 10);
18
+ } else if (arg.startsWith('--rate-limit=')) {
19
+ rateLimit = parseInt(arg.substring(13), 10);
20
+ } else if (arg === '--dry-run') {
21
+ dryRun = true;
22
+ } else if (arg.startsWith('--workspace=')) {
23
+ workspace = arg.substring(12);
24
+ }
25
+ }
26
+
27
+ log('cli', 'categorize-backfill batch=' + batchSize + ' rate=' + rateLimit + ' dry=' + dryRun);
28
+
29
+ const config = loadCollectionConfig(globalOpts.configPath);
30
+ if (!config?.consolidation) {
31
+ cliError('No consolidation config found. Set consolidation section in config.yml');
32
+ process.exit(1);
33
+ }
34
+
35
+ const consolidationConfig = config.consolidation as ConsolidationConfig;
36
+ const llmProvider = createLLMProvider(consolidationConfig);
37
+ if (!llmProvider) {
38
+ cliError('No LLM provider configured. Set consolidation.apiKey in config.yml or CONSOLIDATION_API_KEY env var');
39
+ process.exit(1);
40
+ }
41
+
42
+ const categorizationConfig = {
43
+ llm_enabled: config?.categorization?.llm_enabled ?? true,
44
+ confidence_threshold: config?.categorization?.confidence_threshold ?? 0.6,
45
+ max_content_length: config?.categorization?.max_content_length ?? 2000,
46
+ };
47
+
48
+ const store = await createStore(globalOpts.dbPath);
49
+ const projectHash = workspace
50
+ ? crypto.createHash('sha256').update(workspace).digest('hex').substring(0, 12)
51
+ : undefined;
52
+
53
+ const uncategorized = store.getUncategorizedDocuments(batchSize, projectHash);
54
+ const total = uncategorized.length;
55
+
56
+ if (total === 0) {
57
+ cliOutput('No uncategorized documents found.');
58
+ store.close();
59
+ return;
60
+ }
61
+
62
+ cliOutput(`Found ${total} uncategorized document(s)${dryRun ? ' (dry run)' : ''}`);
63
+
64
+ const tagCounts = new Map<string, number>();
65
+ let processed = 0;
66
+ let categorized = 0;
67
+ const delayMs = Math.ceil(1000 / rateLimit);
68
+
69
+ for (const doc of uncategorized) {
70
+ processed++;
71
+ const truncatedContent = doc.body.slice(0, categorizationConfig.max_content_length);
72
+
73
+ if (dryRun) {
74
+ cliOutput(`[${processed}/${total}] Would categorize: ${doc.path}`);
75
+ continue;
76
+ }
77
+
78
+ try {
79
+ const { categorizeMemory } = await import('../../llm-categorizer.js');
80
+ const tags = await categorizeMemory(truncatedContent, llmProvider, categorizationConfig);
81
+
82
+ if (tags.length > 0) {
83
+ store.insertTags(doc.id, tags);
84
+ categorized++;
85
+ for (const tag of tags) {
86
+ tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
87
+ }
88
+ }
89
+
90
+ const tagStr = tags.length > 0 ? tags.join(', ') : '(no tags)';
91
+ cliOutput(`[${processed}/${total}] ${doc.path}: ${tagStr}`);
92
+
93
+ if (processed < total) {
94
+ await new Promise(resolve => setTimeout(resolve, delayMs));
95
+ }
96
+ } catch (err) {
97
+ cliError(`[${processed}/${total}] Error categorizing ${doc.path}: ${err instanceof Error ? err.message : String(err)}`);
98
+ }
99
+ }
100
+
101
+ store.close();
102
+
103
+ cliOutput('');
104
+ if (dryRun) {
105
+ cliOutput(`Dry run complete. Would process ${total} document(s).`);
106
+ } else {
107
+ cliOutput(`Categorization complete: ${categorized}/${processed} documents tagged`);
108
+ if (tagCounts.size > 0) {
109
+ cliOutput('Tag distribution:');
110
+ const sorted = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
111
+ for (const [tag, count] of sorted) {
112
+ cliOutput(` ${tag}: ${count}`);
113
+ }
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,106 @@
1
+ import { openDatabase } from '../../store.js';
2
+ import { SymbolGraph } from '../../symbol-graph.js';
3
+ import * as crypto from 'crypto';
4
+ import Database from 'better-sqlite3';
5
+ import { log, cliOutput, cliError } from '../../logger.js';
6
+ import type { GlobalOptions } from '../types.js';
7
+ import { resolveDbPath } from '../utils.js';
8
+
9
+ function warnIfEmptySymbolGraph(db: Database.Database, projectHash: string): boolean {
10
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM code_symbols WHERE project_hash = ?').get(projectHash) as { cnt: number };
11
+ if (count.cnt === 0) {
12
+ cliError('⚠️ Symbol graph is empty. Run `npx nano-brain reindex` first.');
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ export async function handleCodeImpact(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
19
+ let target: string | undefined;
20
+ let direction: 'upstream' | 'downstream' = 'upstream';
21
+ let maxDepth = 5;
22
+ let minConfidence = 0;
23
+ let filePath: string | undefined;
24
+ let format: 'text' | 'json' = 'text';
25
+
26
+ for (const arg of commandArgs) {
27
+ if (arg.startsWith('--direction=')) {
28
+ const val = arg.substring(12);
29
+ if (val === 'upstream' || val === 'downstream') direction = val;
30
+ } else if (arg.startsWith('--max-depth=')) {
31
+ maxDepth = parseInt(arg.substring(12), 10);
32
+ } else if (arg.startsWith('--min-confidence=')) {
33
+ minConfidence = parseFloat(arg.substring(17));
34
+ } else if (arg.startsWith('--file=')) {
35
+ filePath = arg.substring(7);
36
+ } else if (arg === '--json') {
37
+ format = 'json';
38
+ } else if (!arg.startsWith('-')) {
39
+ target = arg;
40
+ }
41
+ }
42
+
43
+ if (!target) {
44
+ cliError('Usage: code-impact <symbol-name> [--direction=upstream|downstream] [--max-depth=N] [--min-confidence=N] [--file=<path>] [--json]');
45
+ process.exit(1);
46
+ }
47
+
48
+ log('cli', 'code-impact target=' + target + ' direction=' + direction + ' depth=' + maxDepth);
49
+ const workspaceRoot = process.cwd();
50
+ const projectHash = crypto.createHash('sha256').update(workspaceRoot).digest('hex').substring(0, 12);
51
+ const resolvedDbPath = resolveDbPath(globalOpts.dbPath, workspaceRoot);
52
+ const db = openDatabase(resolvedDbPath);
53
+
54
+ if (warnIfEmptySymbolGraph(db, projectHash)) {
55
+ db.close();
56
+ return;
57
+ }
58
+
59
+ const graph = new SymbolGraph(db);
60
+ const result = graph.handleImpact({ target, direction, maxDepth, minConfidence, filePath, projectHash });
61
+
62
+ if (format === 'json') {
63
+ cliOutput(JSON.stringify(result, null, 2));
64
+ db.close();
65
+ return;
66
+ }
67
+
68
+ if (!result.found) {
69
+ if (result.disambiguation) {
70
+ cliOutput(`Multiple symbols named "${target}". Use --file= to disambiguate:`);
71
+ for (const s of result.disambiguation) {
72
+ cliOutput(` ${s.kind} ${s.name} — ${s.filePath}`);
73
+ }
74
+ } else {
75
+ cliOutput(`Symbol "${target}" not found.`);
76
+ }
77
+ db.close();
78
+ return;
79
+ }
80
+
81
+ const t = result.target!;
82
+ cliOutput(`Impact Analysis: ${t.kind} ${t.name} (${t.filePath})`);
83
+ cliOutput(` Direction: ${direction}`);
84
+ cliOutput(` Risk: ${result.risk}`);
85
+ cliOutput(` Direct deps: ${result.summary.directDeps}, Total affected: ${result.summary.totalAffected}, Flows: ${result.summary.flowsAffected}`);
86
+ cliOutput('');
87
+
88
+ for (const [depth, symbols] of Object.entries(result.byDepth)) {
89
+ if (symbols.length > 0) {
90
+ cliOutput(`Depth ${depth} (${symbols.length}):`);
91
+ for (const s of symbols) {
92
+ cliOutput(` ${s.kind} ${s.name} (${s.filePath}) [${s.edgeType}, confidence=${s.confidence}]`);
93
+ }
94
+ }
95
+ }
96
+
97
+ if (result.affectedFlows.length > 0) {
98
+ cliOutput('');
99
+ cliOutput(`Affected Flows (${result.affectedFlows.length}):`);
100
+ for (const f of result.affectedFlows) {
101
+ cliOutput(` ${f.flowType}: ${f.label}`);
102
+ }
103
+ }
104
+
105
+ db.close();
106
+ }
@@ -0,0 +1,85 @@
1
+ import { loadCollectionConfig, addCollection, removeCollection, renameCollection, listCollections } from '../../collections.js';
2
+ import { cliOutput, cliError } from '../../logger.js';
3
+ import type { GlobalOptions } from '../types.js';
4
+
5
+ export async function handleCollection(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
6
+ const subcommand = commandArgs[0];
7
+
8
+ if (!subcommand) {
9
+ cliError('Missing collection subcommand (add, remove, list, rename)');
10
+ process.exit(1);
11
+ }
12
+
13
+ switch (subcommand) {
14
+ case 'add': {
15
+ const name = commandArgs[1];
16
+ const collectionPath = commandArgs[2];
17
+ let pattern = '**/*.md';
18
+
19
+ for (const arg of commandArgs.slice(3)) {
20
+ if (arg.startsWith('--pattern=')) {
21
+ pattern = arg.substring(10);
22
+ }
23
+ }
24
+
25
+ if (!name || !collectionPath) {
26
+ cliError('Usage: collection add <name> <path> [--pattern=<glob>]');
27
+ process.exit(1);
28
+ }
29
+
30
+ addCollection(globalOpts.configPath, name, collectionPath, pattern);
31
+ cliOutput(`✅ Added collection "${name}"`);
32
+ break;
33
+ }
34
+
35
+ case 'remove': {
36
+ const name = commandArgs[1];
37
+ if (!name) {
38
+ cliError('Usage: collection remove <name>');
39
+ process.exit(1);
40
+ }
41
+
42
+ removeCollection(globalOpts.configPath, name);
43
+ cliOutput(`✅ Removed collection "${name}"`);
44
+ break;
45
+ }
46
+
47
+ case 'list': {
48
+ const config = loadCollectionConfig(globalOpts.configPath);
49
+ if (!config) {
50
+ cliOutput('No collections configured');
51
+ return;
52
+ }
53
+
54
+ const names = listCollections(config);
55
+ if (names.length === 0) {
56
+ cliOutput('No collections configured');
57
+ } else {
58
+ cliOutput('Collections:');
59
+ for (const name of names) {
60
+ const coll = config.collections?.[name];
61
+ cliOutput(` ${name}: ${coll?.path} (${coll?.pattern || '**/*.md'})`);
62
+ }
63
+ }
64
+ break;
65
+ }
66
+
67
+ case 'rename': {
68
+ const oldName = commandArgs[1];
69
+ const newName = commandArgs[2];
70
+
71
+ if (!oldName || !newName) {
72
+ cliError('Usage: collection rename <old> <new>');
73
+ process.exit(1);
74
+ }
75
+
76
+ renameCollection(globalOpts.configPath, oldName, newName);
77
+ cliOutput(`✅ Renamed collection "${oldName}" to "${newName}"`);
78
+ break;
79
+ }
80
+
81
+ default:
82
+ cliError(`Unknown collection subcommand: ${subcommand}`);
83
+ process.exit(1);
84
+ }
85
+ }
@@ -0,0 +1,49 @@
1
+ import { createStore } from '../../store.js';
2
+ import { loadCollectionConfig } from '../../collections.js';
3
+ import { createLLMProvider } from '../../llm-provider.js';
4
+ import { ConsolidationAgent } from '../../consolidation.js';
5
+ import type { ConsolidationConfig } from '../../types.js';
6
+ import { log, cliOutput, cliError } from '../../logger.js';
7
+ import type { GlobalOptions } from '../types.js';
8
+
9
+ export async function handleConsolidate(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
10
+ log('cli', 'consolidate');
11
+ const store = await createStore(globalOpts.dbPath);
12
+ const config = loadCollectionConfig(globalOpts.configPath);
13
+
14
+ if (!config?.consolidation?.enabled) {
15
+ cliOutput('Consolidation is not enabled. Set consolidation.enabled=true in config.yml');
16
+ store.close();
17
+ return;
18
+ }
19
+
20
+ try {
21
+ const consolidationConfig = config.consolidation as ConsolidationConfig;
22
+ const provider = createLLMProvider(consolidationConfig);
23
+
24
+ if (!provider) {
25
+ cliOutput('No API key configured. Set consolidation.apiKey in config.yml or CONSOLIDATION_API_KEY env var');
26
+ return;
27
+ }
28
+
29
+ const agent = new ConsolidationAgent(store, {
30
+ llmProvider: provider,
31
+ maxMemoriesPerCycle: consolidationConfig.max_memories_per_cycle,
32
+ minMemoriesThreshold: consolidationConfig.min_memories_threshold,
33
+ confidenceThreshold: consolidationConfig.confidence_threshold,
34
+ });
35
+
36
+ const results = await agent.runConsolidationCycle();
37
+
38
+ if (results.length === 0) {
39
+ cliOutput('No memories to consolidate');
40
+ } else {
41
+ cliOutput(`Consolidation complete: ${results.length} consolidation(s) created`);
42
+ }
43
+ } catch (err) {
44
+ cliError('Consolidation failed:', err instanceof Error ? err.message : String(err));
45
+ process.exit(1);
46
+ } finally {
47
+ store.close();
48
+ }
49
+ }
@@ -0,0 +1,112 @@
1
+ import { openDatabase } from '../../store.js';
2
+ import { SymbolGraph } from '../../symbol-graph.js';
3
+ import * as crypto from 'crypto';
4
+ import Database from 'better-sqlite3';
5
+ import { log, cliOutput, cliError } from '../../logger.js';
6
+ import type { GlobalOptions } from '../types.js';
7
+ import { resolveDbPath } from '../utils.js';
8
+
9
+ function warnIfEmptySymbolGraph(db: Database.Database, projectHash: string): boolean {
10
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM code_symbols WHERE project_hash = ?').get(projectHash) as { cnt: number };
11
+ if (count.cnt === 0) {
12
+ cliError('⚠️ Symbol graph is empty. Run `npx nano-brain reindex` first.');
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ export async function handleContext(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
19
+ let name: string | undefined;
20
+ let filePath: string | undefined;
21
+ let format: 'text' | 'json' = 'text';
22
+
23
+ for (const arg of commandArgs) {
24
+ if (arg.startsWith('--file=')) {
25
+ filePath = arg.substring(7);
26
+ } else if (arg === '--json') {
27
+ format = 'json';
28
+ } else if (!arg.startsWith('-')) {
29
+ name = arg;
30
+ }
31
+ }
32
+
33
+ if (!name) {
34
+ cliError('Usage: context <symbol-name> [--file=<path>] [--json]');
35
+ process.exit(1);
36
+ }
37
+
38
+ log('cli', 'context name=' + name + ' file=' + (filePath || ''));
39
+ const workspaceRoot = process.cwd();
40
+ const projectHash = crypto.createHash('sha256').update(workspaceRoot).digest('hex').substring(0, 12);
41
+ const resolvedDbPath = resolveDbPath(globalOpts.dbPath, workspaceRoot);
42
+ const db = openDatabase(resolvedDbPath);
43
+
44
+ if (warnIfEmptySymbolGraph(db, projectHash)) {
45
+ db.close();
46
+ return;
47
+ }
48
+
49
+ const graph = new SymbolGraph(db);
50
+ const result = graph.handleContext({ name, filePath, projectHash });
51
+
52
+ if (format === 'json') {
53
+ cliOutput(JSON.stringify(result, null, 2));
54
+ db.close();
55
+ return;
56
+ }
57
+
58
+ if (!result.found) {
59
+ if (result.disambiguation) {
60
+ cliOutput(`Multiple symbols named "${name}". Use --file= to disambiguate:`);
61
+ for (const s of result.disambiguation) {
62
+ cliOutput(` ${s.kind} ${s.name} — ${s.filePath}:${s.startLine}`);
63
+ }
64
+ } else {
65
+ cliOutput(`Symbol "${name}" not found.`);
66
+ }
67
+ db.close();
68
+ return;
69
+ }
70
+
71
+ const sym = result.symbol!;
72
+ cliOutput(`${sym.kind} ${sym.name}`);
73
+ cliOutput(` File: ${sym.filePath}:${sym.startLine}-${sym.endLine}`);
74
+ cliOutput(` Exported: ${sym.exported ? 'yes' : 'no'}`);
75
+ if (result.clusterLabel) {
76
+ cliOutput(` Cluster: ${result.clusterLabel}`);
77
+ }
78
+ cliOutput('');
79
+
80
+ if (result.incoming && result.incoming.length > 0) {
81
+ cliOutput(`Callers (${result.incoming.length}):`);
82
+ for (const e of result.incoming) {
83
+ cliOutput(` ← ${e.kind} ${e.name} (${e.filePath}) [${e.edgeType}]`);
84
+ }
85
+ cliOutput('');
86
+ }
87
+
88
+ if (result.outgoing && result.outgoing.length > 0) {
89
+ cliOutput(`Callees (${result.outgoing.length}):`);
90
+ for (const e of result.outgoing) {
91
+ cliOutput(` → ${e.kind} ${e.name} (${e.filePath}) [${e.edgeType}]`);
92
+ }
93
+ cliOutput('');
94
+ }
95
+
96
+ if (result.flows && result.flows.length > 0) {
97
+ cliOutput(`Flows (${result.flows.length}):`);
98
+ for (const f of result.flows) {
99
+ cliOutput(` ${f.flowType}: ${f.label} (step ${f.stepIndex})`);
100
+ }
101
+ cliOutput('');
102
+ }
103
+
104
+ if (result.infrastructureSymbols && result.infrastructureSymbols.length > 0) {
105
+ cliOutput(`Infrastructure:`);
106
+ for (const s of result.infrastructureSymbols) {
107
+ cliOutput(` [${s.operation}] ${s.type}: ${s.pattern}`);
108
+ }
109
+ }
110
+
111
+ db.close();
112
+ }
@@ -0,0 +1,82 @@
1
+ import { openDatabase } from '../../store.js';
2
+ import { SymbolGraph } from '../../symbol-graph.js';
3
+ import * as crypto from 'crypto';
4
+ import Database from 'better-sqlite3';
5
+ import { log, cliOutput, cliError } from '../../logger.js';
6
+ import type { GlobalOptions } from '../types.js';
7
+ import { resolveDbPath } from '../utils.js';
8
+
9
+ function warnIfEmptySymbolGraph(db: Database.Database, projectHash: string): boolean {
10
+ const count = db.prepare('SELECT COUNT(*) as cnt FROM code_symbols WHERE project_hash = ?').get(projectHash) as { cnt: number };
11
+ if (count.cnt === 0) {
12
+ cliError('⚠️ Symbol graph is empty. Run `npx nano-brain reindex` first.');
13
+ return true;
14
+ }
15
+ return false;
16
+ }
17
+
18
+ export async function handleDetectChanges(globalOpts: GlobalOptions, commandArgs: string[]): Promise<void> {
19
+ let scope: 'unstaged' | 'staged' | 'all' = 'all';
20
+ let format: 'text' | 'json' = 'text';
21
+
22
+ for (const arg of commandArgs) {
23
+ if (arg.startsWith('--scope=')) {
24
+ const val = arg.substring(8);
25
+ if (val === 'unstaged' || val === 'staged' || val === 'all') scope = val;
26
+ } else if (arg === '--json') {
27
+ format = 'json';
28
+ }
29
+ }
30
+
31
+ log('cli', 'detect-changes scope=' + scope);
32
+ const workspaceRoot = process.cwd();
33
+ const projectHash = crypto.createHash('sha256').update(workspaceRoot).digest('hex').substring(0, 12);
34
+ const resolvedDbPath = resolveDbPath(globalOpts.dbPath, workspaceRoot);
35
+ const db = openDatabase(resolvedDbPath);
36
+
37
+ if (warnIfEmptySymbolGraph(db, projectHash)) {
38
+ db.close();
39
+ return;
40
+ }
41
+
42
+ const graph = new SymbolGraph(db);
43
+ const result = graph.handleDetectChanges({ scope, workspaceRoot, projectHash });
44
+
45
+ if (format === 'json') {
46
+ cliOutput(JSON.stringify(result, null, 2));
47
+ db.close();
48
+ return;
49
+ }
50
+
51
+ if (result.changedFiles.length === 0) {
52
+ cliOutput('No changed files detected.');
53
+ db.close();
54
+ return;
55
+ }
56
+
57
+ cliOutput(`Risk Level: ${result.riskLevel}`);
58
+ cliOutput('');
59
+
60
+ cliOutput(`Changed Files (${result.changedFiles.length}):`);
61
+ for (const f of result.changedFiles) {
62
+ cliOutput(` ${f}`);
63
+ }
64
+ cliOutput('');
65
+
66
+ if (result.changedSymbols.length > 0) {
67
+ cliOutput(`Changed Symbols (${result.changedSymbols.length}):`);
68
+ for (const s of result.changedSymbols) {
69
+ cliOutput(` ${s.kind} ${s.name} (${s.filePath})`);
70
+ }
71
+ cliOutput('');
72
+ }
73
+
74
+ if (result.affectedFlows.length > 0) {
75
+ cliOutput(`Affected Flows (${result.affectedFlows.length}):`);
76
+ for (const f of result.affectedFlows) {
77
+ cliOutput(` ${f.flowType}: ${f.label}`);
78
+ }
79
+ }
80
+
81
+ db.close();
82
+ }