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.
Files changed (79) hide show
  1. package/AGENTS_SNIPPET.md +36 -0
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +281 -0
  4. package/SKILL.md +153 -0
  5. package/bin/cli.js +18 -0
  6. package/index.html +929 -0
  7. package/nano-brain +4 -0
  8. package/opencode-mcp.json +9 -0
  9. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/.openspec.yaml +2 -0
  10. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/design.md +68 -0
  11. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/proposal.md +27 -0
  12. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-integration-testing/spec.md +50 -0
  13. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/mcp-server/spec.md +40 -0
  14. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/specs/search-pipeline/spec.md +29 -0
  15. package/openspec/changes/archive/2026-02-16-fix-mcp-server-bugs/tasks.md +37 -0
  16. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/.openspec.yaml +2 -0
  17. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/design.md +111 -0
  18. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/proposal.md +30 -0
  19. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/mcp-server/spec.md +33 -0
  20. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/storage-limits/spec.md +90 -0
  21. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/specs/workspace-scoping/spec.md +66 -0
  22. package/openspec/changes/archive/2026-02-23-workspace-scoped-memory-and-storage-limits/tasks.md +199 -0
  23. package/openspec/changes/codebase-indexing/.openspec.yaml +2 -0
  24. package/openspec/changes/codebase-indexing/design.md +169 -0
  25. package/openspec/changes/codebase-indexing/proposal.md +30 -0
  26. package/openspec/changes/codebase-indexing/specs/codebase-collection/spec.md +187 -0
  27. package/openspec/changes/codebase-indexing/specs/mcp-server/spec.md +36 -0
  28. package/openspec/changes/codebase-indexing/tasks.md +56 -0
  29. package/openspec/specs/mcp-integration-testing/spec.md +50 -0
  30. package/openspec/specs/mcp-server/spec.md +75 -0
  31. package/openspec/specs/search-pipeline/spec.md +29 -0
  32. package/openspec/specs/storage-limits/spec.md +94 -0
  33. package/openspec/specs/workspace-scoping/spec.md +70 -0
  34. package/package.json +34 -0
  35. package/site/build.js +66 -0
  36. package/site/partials/_api.html +83 -0
  37. package/site/partials/_compare.html +100 -0
  38. package/site/partials/_config.html +23 -0
  39. package/site/partials/_features.html +43 -0
  40. package/site/partials/_footer.html +6 -0
  41. package/site/partials/_hero.html +9 -0
  42. package/site/partials/_how-it-works.html +26 -0
  43. package/site/partials/_models.html +18 -0
  44. package/site/partials/_quick-start.html +15 -0
  45. package/site/partials/_stats.html +1 -0
  46. package/site/partials/_tech-stack.html +13 -0
  47. package/site/script.js +12 -0
  48. package/site/shell.html +44 -0
  49. package/site/styles.css +548 -0
  50. package/src/chunker.ts +427 -0
  51. package/src/codebase.ts +331 -0
  52. package/src/collections.ts +192 -0
  53. package/src/embeddings.ts +293 -0
  54. package/src/expansion.ts +79 -0
  55. package/src/harvester.ts +306 -0
  56. package/src/index.ts +503 -0
  57. package/src/reranker.ts +103 -0
  58. package/src/search.ts +294 -0
  59. package/src/server.ts +664 -0
  60. package/src/storage.ts +221 -0
  61. package/src/store.ts +623 -0
  62. package/src/types.ts +202 -0
  63. package/src/watcher.ts +384 -0
  64. package/test/chunker.test.ts +479 -0
  65. package/test/cli.test.ts +309 -0
  66. package/test/codebase-chunker.test.ts +446 -0
  67. package/test/codebase.test.ts +678 -0
  68. package/test/collections.test.ts +571 -0
  69. package/test/harvester.test.ts +636 -0
  70. package/test/integration.test.ts +150 -0
  71. package/test/llm.test.ts +322 -0
  72. package/test/search.test.ts +572 -0
  73. package/test/server.test.ts +541 -0
  74. package/test/storage.test.ts +302 -0
  75. package/test/store.test.ts +465 -0
  76. package/test/watcher.test.ts +656 -0
  77. package/test/workspace.test.ts +239 -0
  78. package/tsconfig.json +19 -0
  79. package/vitest.config.ts +16 -0
@@ -0,0 +1,150 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import * as crypto from 'crypto';
6
+ import { createStore, sanitizeFTS5Query } from '../src/store.js';
7
+ import type { Store } from '../src/types.js';
8
+
9
+ describe('FTS5 Query Sanitization', () => {
10
+ it('wraps normal query in quotes', () => {
11
+ expect(sanitizeFTS5Query('hello world')).toBe('"hello world"');
12
+ });
13
+
14
+ it('preserves hyphenated words', () => {
15
+ expect(sanitizeFTS5Query('nano-brain')).toBe('"nano-brain"');
16
+ });
17
+
18
+ it('escapes internal double quotes', () => {
19
+ expect(sanitizeFTS5Query('hello "world"')).toBe('"hello ""world"""');
20
+ });
21
+
22
+ it('neutralizes FTS5 operators', () => {
23
+ expect(sanitizeFTS5Query('AND OR NOT')).toBe('"AND OR NOT"');
24
+ });
25
+
26
+ it('neutralizes FTS5 column names', () => {
27
+ expect(sanitizeFTS5Query('filepath: test')).toBe('"filepath: test"');
28
+ });
29
+
30
+ it('returns empty string for empty input', () => {
31
+ expect(sanitizeFTS5Query('')).toBe('');
32
+ });
33
+
34
+ it('returns empty string for whitespace-only input', () => {
35
+ expect(sanitizeFTS5Query(' ')).toBe('');
36
+ });
37
+ });
38
+
39
+ describe('Real Database Integration', () => {
40
+ let tempDir: string;
41
+ let dbPath: string;
42
+ let store: Store;
43
+
44
+ beforeAll(() => {
45
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-brain-test-'));
46
+ dbPath = path.join(tempDir, 'test.db');
47
+ store = createStore(dbPath);
48
+
49
+ const doc1Content = '# Nano Brain\n\nThis is a test document about nano-brain architecture.';
50
+ const doc1Hash = crypto.createHash('sha256').update(doc1Content).digest('hex');
51
+ store.insertContent(doc1Hash, doc1Content);
52
+ store.insertDocument({
53
+ collection: 'test-collection',
54
+ path: '/test/doc1.md',
55
+ title: 'Nano Brain',
56
+ hash: doc1Hash,
57
+ createdAt: new Date().toISOString(),
58
+ modifiedAt: new Date().toISOString(),
59
+ active: true,
60
+ });
61
+
62
+ const doc2Content = '# Search Guide\n\nHow to use AND OR NOT operators in search queries.';
63
+ const doc2Hash = crypto.createHash('sha256').update(doc2Content).digest('hex');
64
+ store.insertContent(doc2Hash, doc2Content);
65
+ store.insertDocument({
66
+ collection: 'test-collection',
67
+ path: '/test/doc2.md',
68
+ title: 'Search Guide',
69
+ hash: doc2Hash,
70
+ createdAt: new Date().toISOString(),
71
+ modifiedAt: new Date().toISOString(),
72
+ active: true,
73
+ });
74
+
75
+ const doc3Content = '# Daily Log\n\nToday I worked on the filepath indexing feature.';
76
+ const doc3Hash = crypto.createHash('sha256').update(doc3Content).digest('hex');
77
+ store.insertContent(doc3Hash, doc3Content);
78
+ store.insertDocument({
79
+ collection: 'daily',
80
+ path: '/daily/2024-01-01.md',
81
+ title: 'Daily Log',
82
+ hash: doc3Hash,
83
+ createdAt: new Date().toISOString(),
84
+ modifiedAt: new Date().toISOString(),
85
+ active: true,
86
+ });
87
+ });
88
+
89
+ afterAll(() => {
90
+ store.close();
91
+ fs.rmSync(tempDir, { recursive: true, force: true });
92
+ expect(fs.existsSync(tempDir)).toBe(false);
93
+ });
94
+
95
+ it('search finds indexed documents', () => {
96
+ const results = store.searchFTS('memory', 10);
97
+ expect(results.length).toBeGreaterThan(0);
98
+ expect(results.some(r => r.title === 'Nano Brain')).toBe(true);
99
+ });
100
+
101
+ it('hyphenated query works without error', () => {
102
+ expect(() => {
103
+ const results = store.searchFTS('nano-brain', 10);
104
+ expect(Array.isArray(results)).toBe(true);
105
+ }).not.toThrow();
106
+ });
107
+
108
+ it('FTS5 operator words work without error', () => {
109
+ expect(() => {
110
+ const results = store.searchFTS('AND OR NOT', 10);
111
+ expect(Array.isArray(results)).toBe(true);
112
+ }).not.toThrow();
113
+ });
114
+
115
+ it('FTS5 column name words work without error', () => {
116
+ expect(() => {
117
+ const results = store.searchFTS('filepath', 10);
118
+ expect(Array.isArray(results)).toBe(true);
119
+ }).not.toThrow();
120
+ });
121
+
122
+ it('empty query returns empty array', () => {
123
+ const results = store.searchFTS('', 10);
124
+ expect(results).toEqual([]);
125
+ });
126
+
127
+ it('collection filter works', () => {
128
+ const results = store.searchFTS('log', 10, 'daily');
129
+ expect(results.length).toBeGreaterThan(0);
130
+ expect(results.every(r => r.collection === 'daily')).toBe(true);
131
+ });
132
+
133
+ it('getHealth() returns correct document count', () => {
134
+ const health = store.getIndexHealth();
135
+ expect(health.documentCount).toBe(3);
136
+ });
137
+
138
+ it('getHealth() returns correct collection stats', () => {
139
+ const health = store.getIndexHealth();
140
+ expect(health.collections.length).toBe(2);
141
+
142
+ const testCollection = health.collections.find(c => c.name === 'test-collection');
143
+ expect(testCollection).toBeDefined();
144
+ expect(testCollection?.documentCount).toBe(2);
145
+
146
+ const dailyCollection = health.collections.find(c => c.name === 'daily');
147
+ expect(dailyCollection).toBeDefined();
148
+ expect(dailyCollection?.documentCount).toBe(1);
149
+ });
150
+ });
@@ -0,0 +1,322 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ createEmbeddingProvider,
4
+ parseModelURI,
5
+ formatQueryPrompt,
6
+ formatDocumentPrompt,
7
+ } from '../src/embeddings.js';
8
+ import { createReranker } from '../src/reranker.js';
9
+ import { createQueryExpander } from '../src/expansion.js';
10
+
11
+ vi.mock('node-llama-cpp', () => {
12
+ const mockEmbeddingContext = {
13
+ getEmbeddingFor: vi.fn().mockResolvedValue({
14
+ vector: new Array(768).fill(0.1),
15
+ }),
16
+ };
17
+
18
+ const mockContext = {
19
+ evaluate: vi.fn().mockResolvedValue({
20
+ logits: [0.5],
21
+ text: '1. alternative query one\n2. alternative query two',
22
+ }),
23
+ };
24
+
25
+ const mockModel = {
26
+ createEmbeddingContext: vi.fn().mockResolvedValue(mockEmbeddingContext),
27
+ createContext: vi.fn().mockResolvedValue(mockContext),
28
+ };
29
+
30
+ const mockLlama = {
31
+ loadModel: vi.fn().mockResolvedValue(mockModel),
32
+ };
33
+
34
+ return {
35
+ getLlama: vi.fn().mockResolvedValue(mockLlama),
36
+ };
37
+ });
38
+
39
+ vi.mock('fs', () => ({
40
+ promises: {
41
+ access: vi.fn().mockResolvedValue(undefined),
42
+ mkdir: vi.fn().mockResolvedValue(undefined),
43
+ open: vi.fn().mockResolvedValue({
44
+ write: vi.fn().mockResolvedValue(undefined),
45
+ close: vi.fn().mockResolvedValue(undefined),
46
+ }),
47
+ rename: vi.fn().mockResolvedValue(undefined),
48
+ },
49
+ }));
50
+
51
+ describe('Model URI Parsing', () => {
52
+ it('should parse valid HuggingFace model URI', () => {
53
+ const uri = 'hf:nicoboss/EmbeddingGemma-300M-GGUF/EmbeddingGemma-300M-Q8_0.gguf';
54
+ const parsed = parseModelURI(uri);
55
+
56
+ expect(parsed).toEqual({
57
+ org: 'nicoboss',
58
+ repo: 'EmbeddingGemma-300M-GGUF',
59
+ file: 'EmbeddingGemma-300M-Q8_0.gguf',
60
+ });
61
+ });
62
+
63
+ it('should return null for invalid URI format', () => {
64
+ const uri = 'invalid-uri';
65
+ const parsed = parseModelURI(uri);
66
+
67
+ expect(parsed).toBeNull();
68
+ });
69
+
70
+ it('should parse reranker model URI', () => {
71
+ const uri = 'hf:nicoboss/Qwen3-Reranker-0.6B-GGUF/Qwen3-Reranker-0.6B-Q8_0.gguf';
72
+ const parsed = parseModelURI(uri);
73
+
74
+ expect(parsed).toEqual({
75
+ org: 'nicoboss',
76
+ repo: 'Qwen3-Reranker-0.6B-GGUF',
77
+ file: 'Qwen3-Reranker-0.6B-Q8_0.gguf',
78
+ });
79
+ });
80
+
81
+ it('should parse query expander model URI', () => {
82
+ const uri = 'hf:tobi/qmd-query-expansion-1.7B-GGUF/qmd-query-expansion-1.7B-Q8_0.gguf';
83
+ const parsed = parseModelURI(uri);
84
+
85
+ expect(parsed).toEqual({
86
+ org: 'tobi',
87
+ repo: 'qmd-query-expansion-1.7B-GGUF',
88
+ file: 'qmd-query-expansion-1.7B-Q8_0.gguf',
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('Prompt Formatting', () => {
94
+ it('should format query prompt correctly', () => {
95
+ const query = 'test search query';
96
+ const formatted = formatQueryPrompt(query);
97
+
98
+ expect(formatted).toBe('search_query: test search query');
99
+ });
100
+
101
+ it('should format document prompt correctly', () => {
102
+ const title = 'Document Title';
103
+ const content = 'Document content here';
104
+ const formatted = formatDocumentPrompt(title, content);
105
+
106
+ expect(formatted).toBe('search_document: Document content here');
107
+ });
108
+ });
109
+
110
+ describe('EmbeddingProvider', () => {
111
+ it('should create embedding provider successfully', async () => {
112
+ const provider = await createEmbeddingProvider();
113
+
114
+ expect(provider).not.toBeNull();
115
+ expect(provider?.getDimensions()).toBe(768);
116
+ expect(provider?.getModel()).toBe('nomic-embed-text-v1.5');
117
+ });
118
+
119
+ it('should embed single text', async () => {
120
+ const provider = await createEmbeddingProvider();
121
+ expect(provider).not.toBeNull();
122
+
123
+ if (provider) {
124
+ const result = await provider.embed('test text');
125
+
126
+ expect(result).toHaveProperty('embedding');
127
+ expect(result).toHaveProperty('model');
128
+ expect(result).toHaveProperty('dimensions');
129
+ expect(result.embedding).toHaveLength(768);
130
+ expect(result.model).toBe('nomic-embed-text-v1.5');
131
+ expect(result.dimensions).toBe(768);
132
+ }
133
+ });
134
+
135
+ it('should embed batch of texts', async () => {
136
+ const provider = await createEmbeddingProvider();
137
+ expect(provider).not.toBeNull();
138
+
139
+ if (provider) {
140
+ const texts = ['text 1', 'text 2', 'text 3'];
141
+ const results = await provider.embedBatch(texts);
142
+
143
+ expect(results).toHaveLength(3);
144
+ results.forEach(result => {
145
+ expect(result.embedding).toHaveLength(768);
146
+ expect(result.model).toBe('nomic-embed-text-v1.5');
147
+ expect(result.dimensions).toBe(768);
148
+ });
149
+ }
150
+ });
151
+
152
+ it('should return correct dimensions', async () => {
153
+ const provider = await createEmbeddingProvider();
154
+ expect(provider).not.toBeNull();
155
+
156
+ if (provider) {
157
+ expect(provider.getDimensions()).toBe(768);
158
+ }
159
+ });
160
+
161
+ it('should return correct model name', async () => {
162
+ const provider = await createEmbeddingProvider();
163
+ expect(provider).not.toBeNull();
164
+
165
+ if (provider) {
166
+ expect(provider.getModel()).toBe('nomic-embed-text-v1.5');
167
+ }
168
+ });
169
+
170
+ it('should have dispose method', async () => {
171
+ const provider = await createEmbeddingProvider();
172
+ expect(provider).not.toBeNull();
173
+
174
+ if (provider) {
175
+ expect(provider.dispose).toBeDefined();
176
+ expect(typeof provider.dispose).toBe('function');
177
+ provider.dispose();
178
+ }
179
+ });
180
+ });
181
+
182
+ describe('Reranker', () => {
183
+ it('should create reranker successfully', async () => {
184
+ const reranker = await createReranker();
185
+
186
+ expect(reranker).not.toBeNull();
187
+ });
188
+
189
+ it('should rerank documents', async () => {
190
+ const reranker = await createReranker();
191
+ expect(reranker).not.toBeNull();
192
+
193
+ if (reranker) {
194
+ const query = 'test query';
195
+ const documents = [
196
+ { text: 'document 1', file: 'file1.ts', index: 0 },
197
+ { text: 'document 2', file: 'file2.ts', index: 1 },
198
+ { text: 'document 3', file: 'file3.ts', index: 2 },
199
+ ];
200
+
201
+ const result = await reranker.rerank(query, documents);
202
+
203
+ expect(result).toHaveProperty('results');
204
+ expect(result).toHaveProperty('model');
205
+ expect(result.model).toBe('bge-reranker-v2-m3');
206
+ expect(result.results).toHaveLength(3);
207
+
208
+ result.results.forEach(item => {
209
+ expect(item).toHaveProperty('file');
210
+ expect(item).toHaveProperty('score');
211
+ expect(item).toHaveProperty('index');
212
+ expect(item.score).toBeGreaterThanOrEqual(0);
213
+ expect(item.score).toBeLessThanOrEqual(1);
214
+ });
215
+ }
216
+ });
217
+
218
+ it('should sort results by score descending', async () => {
219
+ const reranker = await createReranker();
220
+ expect(reranker).not.toBeNull();
221
+
222
+ if (reranker) {
223
+ const query = 'test query';
224
+ const documents = [
225
+ { text: 'document 1', file: 'file1.ts', index: 0 },
226
+ { text: 'document 2', file: 'file2.ts', index: 1 },
227
+ ];
228
+
229
+ const result = await reranker.rerank(query, documents);
230
+
231
+ for (let i = 0; i < result.results.length - 1; i++) {
232
+ expect(result.results[i].score).toBeGreaterThanOrEqual(result.results[i + 1].score);
233
+ }
234
+ }
235
+ });
236
+
237
+ it('should have dispose method', async () => {
238
+ const reranker = await createReranker();
239
+ expect(reranker).not.toBeNull();
240
+
241
+ if (reranker) {
242
+ expect(reranker.dispose).toBeDefined();
243
+ expect(typeof reranker.dispose).toBe('function');
244
+ reranker.dispose();
245
+ }
246
+ });
247
+ });
248
+
249
+ describe('QueryExpander', () => {
250
+ it('should create query expander successfully', async () => {
251
+ const expander = await createQueryExpander();
252
+
253
+ expect(expander).not.toBeNull();
254
+ });
255
+
256
+ it('should expand query into variants', async () => {
257
+ const expander = await createQueryExpander();
258
+ expect(expander).not.toBeNull();
259
+
260
+ if (expander) {
261
+ const query = 'test query';
262
+ const variants = await expander.expand(query);
263
+
264
+ expect(Array.isArray(variants)).toBe(true);
265
+ expect(variants.length).toBeGreaterThanOrEqual(1);
266
+ expect(variants.length).toBeLessThanOrEqual(2);
267
+ }
268
+ });
269
+
270
+ it('should return 2 variants when successful', async () => {
271
+ const expander = await createQueryExpander();
272
+ expect(expander).not.toBeNull();
273
+
274
+ if (expander) {
275
+ const query = 'search for something';
276
+ const variants = await expander.expand(query);
277
+
278
+ expect(variants.length).toBe(2);
279
+ variants.forEach(variant => {
280
+ expect(typeof variant).toBe('string');
281
+ expect(variant.length).toBeGreaterThan(0);
282
+ });
283
+ }
284
+ });
285
+
286
+ it('should have dispose method', async () => {
287
+ const expander = await createQueryExpander();
288
+ expect(expander).not.toBeNull();
289
+
290
+ if (expander) {
291
+ expect(expander.dispose).toBeDefined();
292
+ expect(typeof expander.dispose).toBe('function');
293
+ expander.dispose();
294
+ }
295
+ });
296
+ });
297
+
298
+ describe('Graceful Fallback', () => {
299
+ it('should return null when embedding model fails to load', async () => {
300
+ const { getLlama } = await import('node-llama-cpp');
301
+ vi.mocked(getLlama).mockRejectedValueOnce(new Error('Model not found'));
302
+
303
+ const provider = await createEmbeddingProvider();
304
+ expect(provider).toBeNull();
305
+ });
306
+
307
+ it('should return null when reranker model fails to load', async () => {
308
+ const { getLlama } = await import('node-llama-cpp');
309
+ vi.mocked(getLlama).mockRejectedValueOnce(new Error('Model not found'));
310
+
311
+ const reranker = await createReranker();
312
+ expect(reranker).toBeNull();
313
+ });
314
+
315
+ it('should return null when query expander model fails to load', async () => {
316
+ const { getLlama } = await import('node-llama-cpp');
317
+ vi.mocked(getLlama).mockRejectedValueOnce(new Error('Model not found'));
318
+
319
+ const expander = await createQueryExpander();
320
+ expect(expander).toBeNull();
321
+ });
322
+ });