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,479 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findBreakPoints, findCodeFences, findBestCutoff, chunkMarkdown } from '../src/chunker.js';
3
+
4
+ describe('findBreakPoints', () => {
5
+ it('detects H1 headings with score 100', () => {
6
+ const content = '# Heading 1\nSome text';
7
+ const breakPoints = findBreakPoints(content);
8
+
9
+ const h1 = breakPoints.find(bp => bp.type === 'h1');
10
+ expect(h1).toBeDefined();
11
+ expect(h1?.score).toBe(100);
12
+ expect(h1?.pos).toBe(0);
13
+ expect(h1?.lineNo).toBe(1);
14
+ });
15
+
16
+ it('detects H2 headings with score 90', () => {
17
+ const content = '## Heading 2\nSome text';
18
+ const breakPoints = findBreakPoints(content);
19
+
20
+ const h2 = breakPoints.find(bp => bp.type === 'h2');
21
+ expect(h2).toBeDefined();
22
+ expect(h2?.score).toBe(90);
23
+ expect(h2?.pos).toBe(0);
24
+ });
25
+
26
+ it('detects H3 headings with score 80', () => {
27
+ const content = '### Heading 3\nSome text';
28
+ const breakPoints = findBreakPoints(content);
29
+
30
+ const h3 = breakPoints.find(bp => bp.type === 'h3');
31
+ expect(h3).toBeDefined();
32
+ expect(h3?.score).toBe(80);
33
+ });
34
+
35
+ it('detects H4-H6 headings with score 70', () => {
36
+ const content = '#### Heading 4\n##### Heading 5\n###### Heading 6';
37
+ const breakPoints = findBreakPoints(content);
38
+
39
+ const h4h6 = breakPoints.filter(bp => bp.type === 'h4-h6');
40
+ expect(h4h6.length).toBe(3);
41
+ expect(h4h6[0].score).toBe(70);
42
+ });
43
+
44
+ it('detects code fences with score 80', () => {
45
+ const content = '```typescript\ncode\n```';
46
+ const breakPoints = findBreakPoints(content);
47
+
48
+ const fences = breakPoints.filter(bp => bp.type === 'code-fence');
49
+ expect(fences.length).toBe(2);
50
+ expect(fences[0].score).toBe(80);
51
+ });
52
+
53
+ it('detects horizontal rules with score 60', () => {
54
+ const content = '---\n***\n___';
55
+ const breakPoints = findBreakPoints(content);
56
+
57
+ const hrs = breakPoints.filter(bp => bp.type === 'hr');
58
+ expect(hrs.length).toBe(3);
59
+ expect(hrs[0].score).toBe(60);
60
+ });
61
+
62
+ it('detects blank lines with score 20', () => {
63
+ const content = 'text\n\nmore text\n \nend';
64
+ const breakPoints = findBreakPoints(content);
65
+
66
+ const blanks = breakPoints.filter(bp => bp.type === 'blank');
67
+ expect(blanks.length).toBe(2);
68
+ expect(blanks[0].score).toBe(20);
69
+ });
70
+
71
+ it('detects list items with score 5', () => {
72
+ const content = '- Item 1\n* Item 2\n+ Item 3\n1. Numbered\n2. Item';
73
+ const breakPoints = findBreakPoints(content);
74
+
75
+ const lists = breakPoints.filter(bp => bp.type === 'list');
76
+ expect(lists.length).toBe(5);
77
+ expect(lists[0].score).toBe(5);
78
+ });
79
+
80
+ it('detects regular newlines with score 1', () => {
81
+ const content = 'regular text\nanother line';
82
+ const breakPoints = findBreakPoints(content);
83
+
84
+ const newlines = breakPoints.filter(bp => bp.type === 'newline');
85
+ expect(newlines.length).toBe(2);
86
+ expect(newlines[0].score).toBe(1);
87
+ });
88
+
89
+ it('calculates correct positions for multi-line content', () => {
90
+ const content = 'Line 1\nLine 2\nLine 3';
91
+ const breakPoints = findBreakPoints(content);
92
+
93
+ expect(breakPoints[0].pos).toBe(0);
94
+ expect(breakPoints[1].pos).toBe(7);
95
+ expect(breakPoints[2].pos).toBe(14);
96
+ });
97
+
98
+ it('assigns correct line numbers', () => {
99
+ const content = 'Line 1\nLine 2\nLine 3';
100
+ const breakPoints = findBreakPoints(content);
101
+
102
+ expect(breakPoints[0].lineNo).toBe(1);
103
+ expect(breakPoints[1].lineNo).toBe(2);
104
+ expect(breakPoints[2].lineNo).toBe(3);
105
+ });
106
+ });
107
+
108
+ describe('findCodeFences', () => {
109
+ it('detects simple code fence regions', () => {
110
+ const content = 'text\n```\ncode\n```\nmore text';
111
+ const fences = findCodeFences(content);
112
+
113
+ expect(fences.length).toBe(1);
114
+ expect(fences[0].start).toBe(5);
115
+ expect(fences[0].end).toBe(17);
116
+ });
117
+
118
+ it('handles code fences with language tags', () => {
119
+ const content = '```typescript\nconst x = 1;\n```';
120
+ const fences = findCodeFences(content);
121
+
122
+ expect(fences.length).toBe(1);
123
+ expect(fences[0].start).toBe(0);
124
+ });
125
+
126
+ it('handles multiple code fence regions', () => {
127
+ const content = '```\ncode1\n```\ntext\n```\ncode2\n```';
128
+ const fences = findCodeFences(content);
129
+
130
+ expect(fences.length).toBe(2);
131
+ expect(fences[0].start).toBe(0);
132
+ expect(fences[1].start).toBe(19);
133
+ });
134
+
135
+ it('handles unclosed code fences', () => {
136
+ const content = 'text\n```\ncode that never closes';
137
+ const fences = findCodeFences(content);
138
+
139
+ expect(fences.length).toBe(1);
140
+ expect(fences[0].start).toBe(5);
141
+ expect(fences[0].end).toBe(content.length);
142
+ });
143
+
144
+ it('returns empty array for content without code fences', () => {
145
+ const content = 'just regular text\nno code here';
146
+ const fences = findCodeFences(content);
147
+
148
+ expect(fences.length).toBe(0);
149
+ });
150
+
151
+ it('handles nested-like fences (treats as separate regions)', () => {
152
+ const content = '```\nouter\n```inner\nstill outer\n```';
153
+ const fences = findCodeFences(content);
154
+
155
+ expect(fences.length).toBe(2);
156
+ });
157
+ });
158
+
159
+ describe('findBestCutoff', () => {
160
+ it('returns highest scoring break point in window', () => {
161
+ const breakPoints = [
162
+ { pos: 100, score: 20, type: 'blank', lineNo: 1 },
163
+ { pos: 500, score: 100, type: 'h1', lineNo: 5 },
164
+ { pos: 900, score: 20, type: 'blank', lineNo: 9 },
165
+ ];
166
+ const targetPos = 500;
167
+ const windowSize = 400;
168
+ const codeFences = [];
169
+
170
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
171
+ expect(cutoff).toBe(500);
172
+ });
173
+
174
+ it('applies distance decay to scores', () => {
175
+ const breakPoints = [
176
+ { pos: 100, score: 100, type: 'h1', lineNo: 1 },
177
+ { pos: 500, score: 50, type: 'h2', lineNo: 5 },
178
+ ];
179
+ const targetPos = 500;
180
+ const windowSize = 400;
181
+ const codeFences = [];
182
+
183
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
184
+ expect(cutoff).toBe(500);
185
+ });
186
+
187
+ it('never cuts inside code fences', () => {
188
+ const breakPoints = [
189
+ { pos: 100, score: 100, type: 'h1', lineNo: 1 },
190
+ { pos: 500, score: 100, type: 'h1', lineNo: 5 },
191
+ { pos: 900, score: 50, type: 'h2', lineNo: 9 },
192
+ ];
193
+ const targetPos = 500;
194
+ const windowSize = 400;
195
+ const codeFences = [{ start: 400, end: 600 }];
196
+
197
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
198
+ expect(cutoff).not.toBe(500);
199
+ expect(cutoff === 100 || cutoff === 900).toBe(true);
200
+ });
201
+
202
+ it('returns targetPos when no break points in window', () => {
203
+ const breakPoints = [
204
+ { pos: 0, score: 100, type: 'h1', lineNo: 1 },
205
+ { pos: 2000, score: 100, type: 'h1', lineNo: 20 },
206
+ ];
207
+ const targetPos = 1000;
208
+ const windowSize = 200;
209
+ const codeFences = [];
210
+
211
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
212
+ expect(cutoff).toBe(1000);
213
+ });
214
+
215
+ it('returns fence end when targetPos is inside code fence', () => {
216
+ const breakPoints = [
217
+ { pos: 500, score: 100, type: 'h1', lineNo: 5 },
218
+ ];
219
+ const targetPos = 500;
220
+ const windowSize = 400;
221
+ const codeFences = [{ start: 0, end: 1000 }];
222
+
223
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
224
+ expect(cutoff).toBe(1000);
225
+ });
226
+
227
+ it('prefers closer break points with similar scores', () => {
228
+ const breakPoints = [
229
+ { pos: 300, score: 90, type: 'h2', lineNo: 3 },
230
+ { pos: 500, score: 90, type: 'h2', lineNo: 5 },
231
+ ];
232
+ const targetPos = 500;
233
+ const windowSize = 400;
234
+ const codeFences = [];
235
+
236
+ const cutoff = findBestCutoff(breakPoints, targetPos, windowSize, codeFences);
237
+ expect(cutoff).toBe(500);
238
+ });
239
+ });
240
+
241
+ describe('chunkMarkdown - basic', () => {
242
+ it('returns single chunk for short content', () => {
243
+ const content = 'Short content';
244
+ const hash = 'test-hash';
245
+ const chunks = chunkMarkdown(content, hash);
246
+
247
+ expect(chunks.length).toBe(1);
248
+ expect(chunks[0].hash).toBe(hash);
249
+ expect(chunks[0].seq).toBe(0);
250
+ expect(chunks[0].pos).toBe(0);
251
+ expect(chunks[0].text).toBe(content);
252
+ expect(chunks[0].startLine).toBe(1);
253
+ expect(chunks[0].endLine).toBe(1);
254
+ });
255
+
256
+ it('handles empty string', () => {
257
+ const content = '';
258
+ const hash = 'test-hash';
259
+ const chunks = chunkMarkdown(content, hash);
260
+
261
+ expect(chunks.length).toBe(1);
262
+ expect(chunks[0].text).toBe('');
263
+ expect(chunks[0].startLine).toBe(1);
264
+ expect(chunks[0].endLine).toBe(1);
265
+ });
266
+
267
+ it('handles single line', () => {
268
+ const content = 'Single line';
269
+ const hash = 'test-hash';
270
+ const chunks = chunkMarkdown(content, hash);
271
+
272
+ expect(chunks.length).toBe(1);
273
+ expect(chunks[0].endLine).toBe(1);
274
+ });
275
+
276
+ it('handles content exactly at maxChunkSize', () => {
277
+ const content = 'x'.repeat(3600);
278
+ const hash = 'test-hash';
279
+ const chunks = chunkMarkdown(content, hash);
280
+
281
+ expect(chunks.length).toBe(1);
282
+ });
283
+ });
284
+
285
+ describe('chunkMarkdown - multi-chunk', () => {
286
+ it('produces multiple chunks for long content', () => {
287
+ const content = 'x'.repeat(8000);
288
+ const hash = 'test-hash';
289
+ const chunks = chunkMarkdown(content, hash);
290
+
291
+ expect(chunks.length).toBeGreaterThan(1);
292
+ });
293
+
294
+ it('assigns sequential seq numbers', () => {
295
+ const content = 'x'.repeat(8000);
296
+ const hash = 'test-hash';
297
+ const chunks = chunkMarkdown(content, hash);
298
+
299
+ for (let i = 0; i < chunks.length; i++) {
300
+ expect(chunks[i].seq).toBe(i);
301
+ }
302
+ });
303
+
304
+ it('assigns correct positions', () => {
305
+ const content = 'x'.repeat(8000);
306
+ const hash = 'test-hash';
307
+ const chunks = chunkMarkdown(content, hash);
308
+
309
+ expect(chunks[0].pos).toBe(0);
310
+ for (let i = 1; i < chunks.length; i++) {
311
+ expect(chunks[i].pos).toBeGreaterThan(0);
312
+ }
313
+ });
314
+
315
+ it('respects custom maxChunkSize', () => {
316
+ const content = 'x'.repeat(3000);
317
+ const hash = 'test-hash';
318
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 1000 });
319
+
320
+ expect(chunks.length).toBeGreaterThan(2);
321
+ });
322
+ });
323
+
324
+ describe('chunkMarkdown - overlap', () => {
325
+ it('creates overlapping chunks', () => {
326
+ const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`);
327
+ const content = lines.join('\n');
328
+ const hash = 'test-hash';
329
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 500, overlap: 100 });
330
+
331
+ if (chunks.length > 1) {
332
+ const chunk0End = chunks[0].pos + chunks[0].text.length;
333
+ const chunk1Start = chunks[1].pos;
334
+
335
+ expect(chunk1Start).toBeLessThan(chunk0End);
336
+ }
337
+ });
338
+
339
+ it('respects custom overlap size', () => {
340
+ const content = 'x'.repeat(6000);
341
+ const hash = 'test-hash';
342
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 2000, overlap: 300 });
343
+
344
+ if (chunks.length > 1) {
345
+ const actualGap = chunks[1].pos - (chunks[0].pos + chunks[0].text.length);
346
+ expect(actualGap).toBeLessThan(0);
347
+ }
348
+ });
349
+ });
350
+
351
+ describe('chunkMarkdown - heading-aware', () => {
352
+ it('prefers cutting at headings over blank lines', () => {
353
+ const content = `
354
+ ${'x'.repeat(3000)}
355
+
356
+ ## Important Section
357
+ ${'y'.repeat(3000)}
358
+ `.trim();
359
+
360
+ const hash = 'test-hash';
361
+ const chunks = chunkMarkdown(content, hash);
362
+
363
+ if (chunks.length > 1) {
364
+ const hasHeadingNearBoundary = chunks.some(chunk =>
365
+ chunk.text.includes('## Important Section')
366
+ );
367
+ expect(hasHeadingNearBoundary).toBe(true);
368
+ }
369
+ });
370
+
371
+ it('respects heading hierarchy in chunking', () => {
372
+ const content = `
373
+ # Main Title
374
+ ${'x'.repeat(2500)}
375
+
376
+ ## Section 1
377
+ ${'y'.repeat(2500)}
378
+
379
+ ### Subsection
380
+ ${'z'.repeat(2500)}
381
+ `.trim();
382
+
383
+ const hash = 'test-hash';
384
+ const chunks = chunkMarkdown(content, hash);
385
+
386
+ expect(chunks.length).toBeGreaterThan(1);
387
+ });
388
+ });
389
+
390
+ describe('chunkMarkdown - code fence protection', () => {
391
+ it('avoids cutting at positions inside code blocks when possible', () => {
392
+ const codeBlock = '```typescript\n' + 'x'.repeat(2000) + '\n```';
393
+ const content = 'y'.repeat(2000) + '\n' + codeBlock + '\n' + 'z'.repeat(2000);
394
+ const hash = 'test-hash';
395
+ const chunks = chunkMarkdown(content, hash, { overlap: 0 });
396
+
397
+ for (const chunk of chunks) {
398
+ const openFences = (chunk.text.match(/```/g) || []).length;
399
+ if (openFences > 0) {
400
+ expect(openFences % 2).toBe(0);
401
+ }
402
+ }
403
+ });
404
+
405
+ it('keeps code blocks intact when possible', () => {
406
+ const content = `
407
+ Some text before
408
+
409
+ \`\`\`typescript
410
+ function example() {
411
+ return "code";
412
+ }
413
+ \`\`\`
414
+
415
+ Some text after
416
+ `.trim();
417
+
418
+ const hash = 'test-hash';
419
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 200 });
420
+
421
+ const codeChunk = chunks.find(c => c.text.includes('```typescript'));
422
+ if (codeChunk) {
423
+ expect(codeChunk.text.includes('```typescript')).toBe(true);
424
+ expect(codeChunk.text.match(/```/g)?.length).toBeGreaterThanOrEqual(2);
425
+ }
426
+ });
427
+ });
428
+
429
+ describe('chunkMarkdown - edge cases', () => {
430
+ it('handles content with only newlines', () => {
431
+ const content = '\n\n\n\n\n';
432
+ const hash = 'test-hash';
433
+ const chunks = chunkMarkdown(content, hash);
434
+
435
+ expect(chunks.length).toBe(1);
436
+ expect(chunks[0].text).toBe(content);
437
+ });
438
+
439
+ it('handles content with no break points', () => {
440
+ const content = 'x'.repeat(8000);
441
+ const hash = 'test-hash';
442
+ const chunks = chunkMarkdown(content, hash);
443
+
444
+ expect(chunks.length).toBeGreaterThan(1);
445
+ });
446
+
447
+ it('calculates correct line numbers across chunks', () => {
448
+ const lines = Array.from({ length: 150 }, (_, i) => `Line ${i + 1}`);
449
+ const content = lines.join('\n');
450
+ const hash = 'test-hash';
451
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 500 });
452
+
453
+ for (const chunk of chunks) {
454
+ expect(chunk.startLine).toBeGreaterThan(0);
455
+ expect(chunk.endLine).toBeGreaterThanOrEqual(chunk.startLine);
456
+ }
457
+
458
+ if (chunks.length > 1) {
459
+ expect(chunks[chunks.length - 1].endLine).toBeLessThanOrEqual(lines.length);
460
+ }
461
+ });
462
+
463
+ it('handles very small maxChunkSize', () => {
464
+ const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5';
465
+ const hash = 'test-hash';
466
+ const chunks = chunkMarkdown(content, hash, { maxChunkSize: 10 });
467
+
468
+ expect(chunks.length).toBeGreaterThan(1);
469
+ });
470
+
471
+ it('handles content with mixed line endings', () => {
472
+ const content = 'Line 1\nLine 2\nLine 3';
473
+ const hash = 'test-hash';
474
+ const chunks = chunkMarkdown(content, hash);
475
+
476
+ expect(chunks.length).toBeGreaterThan(0);
477
+ expect(chunks[0].text).toContain('Line 1');
478
+ });
479
+ });