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
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { parseSize, parseDuration, parseStorageConfig, checkDiskSpace, evictExpiredSessions, evictBySize } from '../src/storage.js';
|
|
3
|
+
import { createStore, computeHash } from '../src/store.js';
|
|
4
|
+
import type { Store } from '../src/types.js';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
|
|
9
|
+
describe('Storage Limits', () => {
|
|
10
|
+
describe('parseSize', () => {
|
|
11
|
+
it('should parse MB', () => {
|
|
12
|
+
expect(parseSize('500MB')).toBe(524288000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should parse GB', () => {
|
|
16
|
+
expect(parseSize('2GB')).toBe(2147483648);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should parse TB', () => {
|
|
20
|
+
expect(parseSize('1TB')).toBe(1099511627776);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should parse KB', () => {
|
|
24
|
+
expect(parseSize('100KB')).toBe(102400);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should be case insensitive', () => {
|
|
28
|
+
expect(parseSize('2gb')).toBe(2147483648);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return -1 for invalid input', () => {
|
|
32
|
+
expect(parseSize('banana')).toBe(-1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return -1 for empty string', () => {
|
|
36
|
+
expect(parseSize('')).toBe(-1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should handle decimal values', () => {
|
|
40
|
+
expect(parseSize('1.5GB')).toBe(Math.floor(1.5 * 1024 * 1024 * 1024));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('parseDuration', () => {
|
|
45
|
+
it('should parse days', () => {
|
|
46
|
+
expect(parseDuration('30d')).toBe(2592000000);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should parse weeks', () => {
|
|
50
|
+
expect(parseDuration('2w')).toBe(1209600000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should parse months', () => {
|
|
54
|
+
expect(parseDuration('3m')).toBe(7776000000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should parse years', () => {
|
|
58
|
+
expect(parseDuration('1y')).toBe(31536000000);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return -1 for invalid input', () => {
|
|
62
|
+
expect(parseDuration('banana')).toBe(-1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return -1 for empty string', () => {
|
|
66
|
+
expect(parseDuration('')).toBe(-1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('parseStorageConfig', () => {
|
|
71
|
+
it('should use defaults when no config provided', () => {
|
|
72
|
+
const config = parseStorageConfig();
|
|
73
|
+
expect(config).toEqual({
|
|
74
|
+
maxSize: 2147483648,
|
|
75
|
+
retention: 7776000000,
|
|
76
|
+
minFreeDisk: 104857600,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should use defaults when undefined provided', () => {
|
|
81
|
+
const config = parseStorageConfig(undefined);
|
|
82
|
+
expect(config).toEqual({
|
|
83
|
+
maxSize: 2147483648,
|
|
84
|
+
retention: 7776000000,
|
|
85
|
+
minFreeDisk: 104857600,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should parse all fields', () => {
|
|
90
|
+
const config = parseStorageConfig({
|
|
91
|
+
maxSize: '1GB',
|
|
92
|
+
retention: '30d',
|
|
93
|
+
minFreeDisk: '200MB',
|
|
94
|
+
});
|
|
95
|
+
expect(config).toEqual({
|
|
96
|
+
maxSize: 1073741824,
|
|
97
|
+
retention: 2592000000,
|
|
98
|
+
minFreeDisk: 209715200,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should use defaults for invalid values', () => {
|
|
103
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
104
|
+
|
|
105
|
+
const config = parseStorageConfig({ maxSize: 'banana' });
|
|
106
|
+
|
|
107
|
+
expect(config.maxSize).toBe(2147483648);
|
|
108
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
109
|
+
|
|
110
|
+
warnSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle partial config', () => {
|
|
114
|
+
const config = parseStorageConfig({ maxSize: '500MB' });
|
|
115
|
+
expect(config.maxSize).toBe(524288000);
|
|
116
|
+
expect(config.retention).toBe(7776000000);
|
|
117
|
+
expect(config.minFreeDisk).toBe(104857600);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('checkDiskSpace', () => {
|
|
122
|
+
it('should return ok when enough space', () => {
|
|
123
|
+
const result = checkDiskSpace(os.tmpdir(), 1);
|
|
124
|
+
expect(result.ok).toBe(true);
|
|
125
|
+
expect(result.freeBytes).toBeGreaterThan(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should return not ok when minFreeDisk is huge', () => {
|
|
129
|
+
const result = checkDiskSpace(os.tmpdir(), Number.MAX_SAFE_INTEGER);
|
|
130
|
+
expect(result.ok).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('eviction', () => {
|
|
135
|
+
let tmpDir: string;
|
|
136
|
+
let sessionsDir: string;
|
|
137
|
+
let dbPath: string;
|
|
138
|
+
let store: Store;
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-brain-storage-test-'));
|
|
142
|
+
sessionsDir = path.join(tmpDir, 'sessions');
|
|
143
|
+
dbPath = path.join(tmpDir, 'test.db');
|
|
144
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
145
|
+
store = createStore(dbPath);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(() => {
|
|
149
|
+
store.close();
|
|
150
|
+
if (fs.existsSync(tmpDir)) {
|
|
151
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function createSessionFile(hashDir: string, filename: string, content: string, mtime?: Date): string {
|
|
156
|
+
const dirPath = path.join(sessionsDir, hashDir);
|
|
157
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
158
|
+
const filePath = path.join(dirPath, filename);
|
|
159
|
+
fs.writeFileSync(filePath, content);
|
|
160
|
+
|
|
161
|
+
if (mtime) {
|
|
162
|
+
fs.utimesSync(filePath, mtime, mtime);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return filePath;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
it('should evict expired sessions', () => {
|
|
169
|
+
const oldDate = new Date(Date.now() - 100 * 24 * 60 * 60 * 1000);
|
|
170
|
+
const filePath = createSessionFile('hash123456ab', 'old.md', 'old content', oldDate);
|
|
171
|
+
|
|
172
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
173
|
+
|
|
174
|
+
const evicted = evictExpiredSessions(sessionsDir, 7776000000, store);
|
|
175
|
+
|
|
176
|
+
expect(evicted).toBe(1);
|
|
177
|
+
expect(fs.existsSync(filePath)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should not evict recent sessions', () => {
|
|
181
|
+
const filePath = createSessionFile('hash123456ab', 'recent.md', 'recent content');
|
|
182
|
+
|
|
183
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
184
|
+
|
|
185
|
+
const evicted = evictExpiredSessions(sessionsDir, 7776000000, store);
|
|
186
|
+
|
|
187
|
+
expect(evicted).toBe(0);
|
|
188
|
+
expect(fs.existsSync(filePath)).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should evict by size oldest first', () => {
|
|
192
|
+
const oldDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
|
|
193
|
+
const newerDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
|
194
|
+
|
|
195
|
+
const oldFile = createSessionFile('hash111111ab', 'old.md', 'A'.repeat(1000), oldDate);
|
|
196
|
+
const newFile = createSessionFile('hash222222ab', 'new.md', 'B'.repeat(1000), newerDate);
|
|
197
|
+
|
|
198
|
+
const evicted = evictBySize(sessionsDir, dbPath, 100, store);
|
|
199
|
+
|
|
200
|
+
expect(evicted).toBeGreaterThan(0);
|
|
201
|
+
expect(fs.existsSync(oldFile)).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should not evict when under maxSize', () => {
|
|
205
|
+
const file1 = createSessionFile('hash111111ab', 'file1.md', 'small content');
|
|
206
|
+
const file2 = createSessionFile('hash222222ab', 'file2.md', 'small content');
|
|
207
|
+
|
|
208
|
+
const evicted = evictBySize(sessionsDir, dbPath, Number.MAX_SAFE_INTEGER, store);
|
|
209
|
+
|
|
210
|
+
expect(evicted).toBe(0);
|
|
211
|
+
expect(fs.existsSync(file1)).toBe(true);
|
|
212
|
+
expect(fs.existsSync(file2)).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('deleteDocumentsByPath', () => {
|
|
217
|
+
let tmpDir: string;
|
|
218
|
+
let dbPath: string;
|
|
219
|
+
let store: Store;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-brain-delete-test-'));
|
|
223
|
+
dbPath = path.join(tmpDir, 'test.db');
|
|
224
|
+
store = createStore(dbPath);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
afterEach(() => {
|
|
228
|
+
store.close();
|
|
229
|
+
if (fs.existsSync(tmpDir)) {
|
|
230
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should delete document by path', () => {
|
|
235
|
+
const body = '# Delete Me\n\nContent.';
|
|
236
|
+
const hash = computeHash(body);
|
|
237
|
+
|
|
238
|
+
store.insertContent(hash, body);
|
|
239
|
+
store.insertDocument({
|
|
240
|
+
collection: 'test',
|
|
241
|
+
path: 'delete/me.md',
|
|
242
|
+
title: 'Delete Me',
|
|
243
|
+
hash,
|
|
244
|
+
createdAt: new Date().toISOString(),
|
|
245
|
+
modifiedAt: new Date().toISOString(),
|
|
246
|
+
active: true,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const docBefore = store.findDocument('delete/me.md');
|
|
250
|
+
expect(docBefore).not.toBeNull();
|
|
251
|
+
|
|
252
|
+
const deleted = store.deleteDocumentsByPath('delete/me.md');
|
|
253
|
+
expect(deleted).toBe(1);
|
|
254
|
+
|
|
255
|
+
const docAfter = store.findDocument('delete/me.md');
|
|
256
|
+
expect(docAfter).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('cleanOrphanedEmbeddings', () => {
|
|
261
|
+
let tmpDir: string;
|
|
262
|
+
let dbPath: string;
|
|
263
|
+
let store: Store;
|
|
264
|
+
|
|
265
|
+
beforeEach(() => {
|
|
266
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-brain-orphan-test-'));
|
|
267
|
+
dbPath = path.join(tmpDir, 'test.db');
|
|
268
|
+
store = createStore(dbPath);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
afterEach(() => {
|
|
272
|
+
store.close();
|
|
273
|
+
if (fs.existsSync(tmpDir)) {
|
|
274
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should clean orphaned embeddings', () => {
|
|
279
|
+
const body = '# Orphan Test\n\nContent.';
|
|
280
|
+
const hash = computeHash(body);
|
|
281
|
+
|
|
282
|
+
store.insertContent(hash, body);
|
|
283
|
+
store.insertDocument({
|
|
284
|
+
collection: 'test',
|
|
285
|
+
path: 'orphan/test.md',
|
|
286
|
+
title: 'Orphan Test',
|
|
287
|
+
hash,
|
|
288
|
+
createdAt: new Date().toISOString(),
|
|
289
|
+
modifiedAt: new Date().toISOString(),
|
|
290
|
+
active: true,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
store.insertEmbedding(hash, 0, 0, new Array(384).fill(0.1), 'test-model');
|
|
294
|
+
|
|
295
|
+
store.deactivateDocument('test', 'orphan/test.md');
|
|
296
|
+
|
|
297
|
+
const cleaned = store.cleanOrphanedEmbeddings();
|
|
298
|
+
|
|
299
|
+
expect(cleaned).toBeGreaterThanOrEqual(1);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|