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,571 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
loadCollectionConfig,
|
|
4
|
+
saveCollectionConfig,
|
|
5
|
+
getCollections,
|
|
6
|
+
addCollection,
|
|
7
|
+
removeCollection,
|
|
8
|
+
listCollections,
|
|
9
|
+
renameCollection,
|
|
10
|
+
addContext,
|
|
11
|
+
findContextForPath,
|
|
12
|
+
listAllContexts,
|
|
13
|
+
scanCollectionFiles,
|
|
14
|
+
} from '../src/collections.js';
|
|
15
|
+
import type { CollectionConfig, Collection } from '../src/types.js';
|
|
16
|
+
import * as fs from 'fs';
|
|
17
|
+
import * as path from 'path';
|
|
18
|
+
import * as os from 'os';
|
|
19
|
+
|
|
20
|
+
describe('Collections', () => {
|
|
21
|
+
let tmpDir: string;
|
|
22
|
+
let configPath: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nano-brain-collections-test-'));
|
|
26
|
+
configPath = path.join(tmpDir, 'config.yml');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (fs.existsSync(tmpDir)) {
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('loadCollectionConfig', () => {
|
|
36
|
+
it('should return null for non-existent config', () => {
|
|
37
|
+
const result = loadCollectionConfig(configPath);
|
|
38
|
+
expect(result).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should load valid YAML config', () => {
|
|
42
|
+
const config: CollectionConfig = {
|
|
43
|
+
globalContext: 'Test context',
|
|
44
|
+
collections: {
|
|
45
|
+
sessions: {
|
|
46
|
+
path: '~/.nano-brain/sessions',
|
|
47
|
+
pattern: '**/*.md',
|
|
48
|
+
context: {
|
|
49
|
+
'sessions/': 'Harvested sessions',
|
|
50
|
+
},
|
|
51
|
+
update: 'auto',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
saveCollectionConfig(configPath, config);
|
|
57
|
+
const loaded = loadCollectionConfig(configPath);
|
|
58
|
+
|
|
59
|
+
expect(loaded).not.toBeNull();
|
|
60
|
+
expect(loaded?.globalContext).toBe('Test context');
|
|
61
|
+
expect(loaded?.collections.sessions).toBeDefined();
|
|
62
|
+
expect(loaded?.collections.sessions.path).toBe('~/.nano-brain/sessions');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should throw on malformed YAML', () => {
|
|
66
|
+
fs.writeFileSync(configPath, 'invalid: yaml: content: [', 'utf-8');
|
|
67
|
+
expect(() => loadCollectionConfig(configPath)).toThrow();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('saveCollectionConfig', () => {
|
|
72
|
+
it('should save config to YAML file', () => {
|
|
73
|
+
const config: CollectionConfig = {
|
|
74
|
+
globalContext: 'Test context',
|
|
75
|
+
collections: {
|
|
76
|
+
memory: {
|
|
77
|
+
path: '~/.nano-brain/memory',
|
|
78
|
+
pattern: '**/*.md',
|
|
79
|
+
update: 'auto',
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
saveCollectionConfig(configPath, config);
|
|
85
|
+
|
|
86
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
87
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
88
|
+
expect(content).toContain('globalContext: Test context');
|
|
89
|
+
expect(content).toContain('memory:');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should create parent directories if needed', () => {
|
|
93
|
+
const nestedPath = path.join(tmpDir, 'nested', 'dir', 'config.yml');
|
|
94
|
+
const config: CollectionConfig = {
|
|
95
|
+
collections: {
|
|
96
|
+
test: {
|
|
97
|
+
path: '/test',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
saveCollectionConfig(nestedPath, config);
|
|
103
|
+
|
|
104
|
+
expect(fs.existsSync(nestedPath)).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('getCollections', () => {
|
|
109
|
+
it('should convert config to Collection array', () => {
|
|
110
|
+
const config: CollectionConfig = {
|
|
111
|
+
collections: {
|
|
112
|
+
sessions: {
|
|
113
|
+
path: '~/.nano-brain/sessions',
|
|
114
|
+
pattern: '**/*.md',
|
|
115
|
+
context: {
|
|
116
|
+
'sessions/': 'Harvested sessions',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
memory: {
|
|
120
|
+
path: '~/.nano-brain/memory',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const collections = getCollections(config);
|
|
126
|
+
|
|
127
|
+
expect(collections).toHaveLength(2);
|
|
128
|
+
expect(collections[0].name).toBe('sessions');
|
|
129
|
+
expect(collections[0].path).toBe('~/.nano-brain/sessions');
|
|
130
|
+
expect(collections[0].pattern).toBe('**/*.md');
|
|
131
|
+
expect(collections[0].context).toEqual({ 'sessions/': 'Harvested sessions' });
|
|
132
|
+
|
|
133
|
+
expect(collections[1].name).toBe('memory');
|
|
134
|
+
expect(collections[1].pattern).toBe('**/*.md');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should use default pattern when not specified', () => {
|
|
138
|
+
const config: CollectionConfig = {
|
|
139
|
+
collections: {
|
|
140
|
+
test: {
|
|
141
|
+
path: '/test/path',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const collections = getCollections(config);
|
|
147
|
+
|
|
148
|
+
expect(collections[0].pattern).toBe('**/*.md');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('addCollection', () => {
|
|
153
|
+
it('should create new collection in existing config', () => {
|
|
154
|
+
const initialConfig: CollectionConfig = {
|
|
155
|
+
collections: {
|
|
156
|
+
existing: {
|
|
157
|
+
path: '/existing',
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
saveCollectionConfig(configPath, initialConfig);
|
|
163
|
+
|
|
164
|
+
const updated = addCollection(configPath, 'new', '/new/path', '**/*.txt');
|
|
165
|
+
|
|
166
|
+
expect(updated.collections.existing).toBeDefined();
|
|
167
|
+
expect(updated.collections.new).toBeDefined();
|
|
168
|
+
expect(updated.collections.new.path).toBe('/new/path');
|
|
169
|
+
expect(updated.collections.new.pattern).toBe('**/*.txt');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should create new config if none exists', () => {
|
|
173
|
+
const config = addCollection(configPath, 'first', '/first/path');
|
|
174
|
+
|
|
175
|
+
expect(config.collections.first).toBeDefined();
|
|
176
|
+
expect(config.collections.first.path).toBe('/first/path');
|
|
177
|
+
expect(config.collections.first.pattern).toBe('**/*.md');
|
|
178
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should use default pattern if not provided', () => {
|
|
182
|
+
const config = addCollection(configPath, 'test', '/test');
|
|
183
|
+
|
|
184
|
+
expect(config.collections.test.pattern).toBe('**/*.md');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('removeCollection', () => {
|
|
189
|
+
it('should remove collection from config', () => {
|
|
190
|
+
const config: CollectionConfig = {
|
|
191
|
+
collections: {
|
|
192
|
+
keep: {
|
|
193
|
+
path: '/keep',
|
|
194
|
+
},
|
|
195
|
+
remove: {
|
|
196
|
+
path: '/remove',
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
saveCollectionConfig(configPath, config);
|
|
202
|
+
|
|
203
|
+
const updated = removeCollection(configPath, 'remove');
|
|
204
|
+
|
|
205
|
+
expect(updated.collections.keep).toBeDefined();
|
|
206
|
+
expect(updated.collections.remove).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should throw if config does not exist', () => {
|
|
210
|
+
expect(() => removeCollection(configPath, 'test')).toThrow('Config file not found');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('listCollections', () => {
|
|
215
|
+
it('should return array of collection names', () => {
|
|
216
|
+
const config: CollectionConfig = {
|
|
217
|
+
collections: {
|
|
218
|
+
sessions: { path: '/sessions' },
|
|
219
|
+
memory: { path: '/memory' },
|
|
220
|
+
docs: { path: '/docs' },
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const names = listCollections(config);
|
|
225
|
+
|
|
226
|
+
expect(names).toHaveLength(3);
|
|
227
|
+
expect(names).toContain('sessions');
|
|
228
|
+
expect(names).toContain('memory');
|
|
229
|
+
expect(names).toContain('docs');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('renameCollection', () => {
|
|
234
|
+
it('should rename collection key', () => {
|
|
235
|
+
const config: CollectionConfig = {
|
|
236
|
+
collections: {
|
|
237
|
+
oldName: {
|
|
238
|
+
path: '/test/path',
|
|
239
|
+
pattern: '**/*.md',
|
|
240
|
+
context: {
|
|
241
|
+
'test/': 'Test context',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
saveCollectionConfig(configPath, config);
|
|
248
|
+
|
|
249
|
+
const updated = renameCollection(configPath, 'oldName', 'newName');
|
|
250
|
+
|
|
251
|
+
expect(updated.collections.oldName).toBeUndefined();
|
|
252
|
+
expect(updated.collections.newName).toBeDefined();
|
|
253
|
+
expect(updated.collections.newName.path).toBe('/test/path');
|
|
254
|
+
expect(updated.collections.newName.pattern).toBe('**/*.md');
|
|
255
|
+
expect(updated.collections.newName.context).toEqual({ 'test/': 'Test context' });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should throw if config does not exist', () => {
|
|
259
|
+
expect(() => renameCollection(configPath, 'old', 'new')).toThrow('Config file not found');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should throw if collection does not exist', () => {
|
|
263
|
+
const config: CollectionConfig = {
|
|
264
|
+
collections: {
|
|
265
|
+
existing: { path: '/test' },
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
saveCollectionConfig(configPath, config);
|
|
270
|
+
|
|
271
|
+
expect(() => renameCollection(configPath, 'nonexistent', 'new')).toThrow(
|
|
272
|
+
'Collection "nonexistent" not found'
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('addContext', () => {
|
|
278
|
+
it('should add context to collection', () => {
|
|
279
|
+
const config: CollectionConfig = {
|
|
280
|
+
collections: {
|
|
281
|
+
sessions: {
|
|
282
|
+
path: '/sessions',
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
saveCollectionConfig(configPath, config);
|
|
288
|
+
|
|
289
|
+
const updated = addContext(configPath, 'sessions', 'sessions/', 'Harvested sessions');
|
|
290
|
+
|
|
291
|
+
expect(updated.collections.sessions.context).toBeDefined();
|
|
292
|
+
expect(updated.collections.sessions.context!['sessions/']).toBe('Harvested sessions');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should add context to existing context map', () => {
|
|
296
|
+
const config: CollectionConfig = {
|
|
297
|
+
collections: {
|
|
298
|
+
memory: {
|
|
299
|
+
path: '/memory',
|
|
300
|
+
context: {
|
|
301
|
+
'MEMORY.md': 'Main memory',
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
saveCollectionConfig(configPath, config);
|
|
308
|
+
|
|
309
|
+
const updated = addContext(configPath, 'memory', 'daily/', 'Daily logs');
|
|
310
|
+
|
|
311
|
+
expect(updated.collections.memory.context!['MEMORY.md']).toBe('Main memory');
|
|
312
|
+
expect(updated.collections.memory.context!['daily/']).toBe('Daily logs');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should throw if config does not exist', () => {
|
|
316
|
+
expect(() => addContext(configPath, 'test', 'prefix/', 'desc')).toThrow(
|
|
317
|
+
'Config file not found'
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should throw if collection does not exist', () => {
|
|
322
|
+
const config: CollectionConfig = {
|
|
323
|
+
collections: {
|
|
324
|
+
existing: { path: '/test' },
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
saveCollectionConfig(configPath, config);
|
|
329
|
+
|
|
330
|
+
expect(() => addContext(configPath, 'nonexistent', 'prefix/', 'desc')).toThrow(
|
|
331
|
+
'Collection "nonexistent" not found'
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe('findContextForPath', () => {
|
|
337
|
+
it('should find context for matching path', () => {
|
|
338
|
+
const config: CollectionConfig = {
|
|
339
|
+
collections: {
|
|
340
|
+
sessions: {
|
|
341
|
+
path: '/sessions',
|
|
342
|
+
context: {
|
|
343
|
+
'sessions/': 'Harvested sessions',
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const result = findContextForPath(config, 'sessions/2024-01-01.md');
|
|
350
|
+
|
|
351
|
+
expect(result).toBe('Harvested sessions');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should return longest matching prefix', () => {
|
|
355
|
+
const config: CollectionConfig = {
|
|
356
|
+
collections: {
|
|
357
|
+
memory: {
|
|
358
|
+
path: '/memory',
|
|
359
|
+
context: {
|
|
360
|
+
'memory/': 'All memory files',
|
|
361
|
+
'memory/daily/': 'Daily logs',
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const result = findContextForPath(config, 'memory/daily/2024-01-01.md');
|
|
368
|
+
|
|
369
|
+
expect(result).toBe('Daily logs');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should return null for no match', () => {
|
|
373
|
+
const config: CollectionConfig = {
|
|
374
|
+
collections: {
|
|
375
|
+
sessions: {
|
|
376
|
+
path: '/sessions',
|
|
377
|
+
context: {
|
|
378
|
+
'sessions/': 'Harvested sessions',
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const result = findContextForPath(config, 'other/file.md');
|
|
385
|
+
|
|
386
|
+
expect(result).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('should search across multiple collections', () => {
|
|
390
|
+
const config: CollectionConfig = {
|
|
391
|
+
collections: {
|
|
392
|
+
sessions: {
|
|
393
|
+
path: '/sessions',
|
|
394
|
+
context: {
|
|
395
|
+
'sessions/': 'Harvested sessions',
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
memory: {
|
|
399
|
+
path: '/memory',
|
|
400
|
+
context: {
|
|
401
|
+
'MEMORY.md': 'Main memory',
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const result1 = findContextForPath(config, 'sessions/test.md');
|
|
408
|
+
const result2 = findContextForPath(config, 'MEMORY.md');
|
|
409
|
+
|
|
410
|
+
expect(result1).toBe('Harvested sessions');
|
|
411
|
+
expect(result2).toBe('Main memory');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('listAllContexts', () => {
|
|
416
|
+
it('should flatten all contexts across collections', () => {
|
|
417
|
+
const config: CollectionConfig = {
|
|
418
|
+
collections: {
|
|
419
|
+
sessions: {
|
|
420
|
+
path: '/sessions',
|
|
421
|
+
context: {
|
|
422
|
+
'sessions/': 'Harvested sessions',
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
memory: {
|
|
426
|
+
path: '/memory',
|
|
427
|
+
context: {
|
|
428
|
+
'MEMORY.md': 'Main memory',
|
|
429
|
+
'daily/': 'Daily logs',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const contexts = listAllContexts(config);
|
|
436
|
+
|
|
437
|
+
expect(contexts).toHaveLength(3);
|
|
438
|
+
expect(contexts).toContainEqual({
|
|
439
|
+
collection: 'sessions',
|
|
440
|
+
prefix: 'sessions/',
|
|
441
|
+
description: 'Harvested sessions',
|
|
442
|
+
});
|
|
443
|
+
expect(contexts).toContainEqual({
|
|
444
|
+
collection: 'memory',
|
|
445
|
+
prefix: 'MEMORY.md',
|
|
446
|
+
description: 'Main memory',
|
|
447
|
+
});
|
|
448
|
+
expect(contexts).toContainEqual({
|
|
449
|
+
collection: 'memory',
|
|
450
|
+
prefix: 'daily/',
|
|
451
|
+
description: 'Daily logs',
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should return empty array if no contexts', () => {
|
|
456
|
+
const config: CollectionConfig = {
|
|
457
|
+
collections: {
|
|
458
|
+
test: {
|
|
459
|
+
path: '/test',
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
const contexts = listAllContexts(config);
|
|
465
|
+
|
|
466
|
+
expect(contexts).toHaveLength(0);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('scanCollectionFiles', () => {
|
|
471
|
+
it('should find markdown files matching pattern', async () => {
|
|
472
|
+
const collectionDir = path.join(tmpDir, 'collection');
|
|
473
|
+
fs.mkdirSync(collectionDir, { recursive: true });
|
|
474
|
+
|
|
475
|
+
fs.writeFileSync(path.join(collectionDir, 'file1.md'), '# File 1');
|
|
476
|
+
fs.writeFileSync(path.join(collectionDir, 'file2.md'), '# File 2');
|
|
477
|
+
fs.writeFileSync(path.join(collectionDir, 'file3.txt'), 'Text file');
|
|
478
|
+
|
|
479
|
+
const collection: Collection = {
|
|
480
|
+
name: 'test',
|
|
481
|
+
path: collectionDir,
|
|
482
|
+
pattern: '**/*.md',
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const files = await scanCollectionFiles(collection);
|
|
486
|
+
|
|
487
|
+
expect(files).toHaveLength(2);
|
|
488
|
+
expect(files.some((f) => f.endsWith('file1.md'))).toBe(true);
|
|
489
|
+
expect(files.some((f) => f.endsWith('file2.md'))).toBe(true);
|
|
490
|
+
expect(files.some((f) => f.endsWith('file3.txt'))).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('should find files in nested directories', async () => {
|
|
494
|
+
const collectionDir = path.join(tmpDir, 'collection');
|
|
495
|
+
const nestedDir = path.join(collectionDir, 'nested', 'deep');
|
|
496
|
+
fs.mkdirSync(nestedDir, { recursive: true });
|
|
497
|
+
|
|
498
|
+
fs.writeFileSync(path.join(collectionDir, 'root.md'), '# Root');
|
|
499
|
+
fs.writeFileSync(path.join(nestedDir, 'nested.md'), '# Nested');
|
|
500
|
+
|
|
501
|
+
const collection: Collection = {
|
|
502
|
+
name: 'test',
|
|
503
|
+
path: collectionDir,
|
|
504
|
+
pattern: '**/*.md',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const files = await scanCollectionFiles(collection);
|
|
508
|
+
|
|
509
|
+
expect(files).toHaveLength(2);
|
|
510
|
+
expect(files.some((f) => f.endsWith('root.md'))).toBe(true);
|
|
511
|
+
expect(files.some((f) => f.endsWith('nested.md'))).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('should return empty array for non-existent directory', async () => {
|
|
515
|
+
const collection: Collection = {
|
|
516
|
+
name: 'test',
|
|
517
|
+
path: '/nonexistent/path',
|
|
518
|
+
pattern: '**/*.md',
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const files = await scanCollectionFiles(collection);
|
|
522
|
+
|
|
523
|
+
expect(files).toHaveLength(0);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should expand tilde in path', async () => {
|
|
527
|
+
const homeDir = os.homedir();
|
|
528
|
+
const testDir = path.join(homeDir, '.nano-brain-test-scan');
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
532
|
+
fs.writeFileSync(path.join(testDir, 'test.md'), '# Test');
|
|
533
|
+
|
|
534
|
+
const collection: Collection = {
|
|
535
|
+
name: 'test',
|
|
536
|
+
path: '~/.nano-brain-test-scan',
|
|
537
|
+
pattern: '**/*.md',
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const files = await scanCollectionFiles(collection);
|
|
541
|
+
|
|
542
|
+
expect(files.length).toBeGreaterThan(0);
|
|
543
|
+
expect(files.some((f) => f.endsWith('test.md'))).toBe(true);
|
|
544
|
+
} finally {
|
|
545
|
+
if (fs.existsSync(testDir)) {
|
|
546
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should respect custom pattern', async () => {
|
|
552
|
+
const collectionDir = path.join(tmpDir, 'collection');
|
|
553
|
+
fs.mkdirSync(collectionDir, { recursive: true });
|
|
554
|
+
|
|
555
|
+
fs.writeFileSync(path.join(collectionDir, 'file.md'), '# Markdown');
|
|
556
|
+
fs.writeFileSync(path.join(collectionDir, 'file.txt'), 'Text');
|
|
557
|
+
fs.writeFileSync(path.join(collectionDir, 'file.json'), '{}');
|
|
558
|
+
|
|
559
|
+
const collection: Collection = {
|
|
560
|
+
name: 'test',
|
|
561
|
+
path: collectionDir,
|
|
562
|
+
pattern: '**/*.txt',
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const files = await scanCollectionFiles(collection);
|
|
566
|
+
|
|
567
|
+
expect(files).toHaveLength(1);
|
|
568
|
+
expect(files[0].endsWith('file.txt')).toBe(true);
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
});
|