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,636 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import {
6
+ sessionToMarkdown,
7
+ getOutputPath,
8
+ parseParts,
9
+ parseSession,
10
+ parseMessages,
11
+ harvestSessions,
12
+ loadHarvestState,
13
+ saveHarvestState
14
+ } from '../src/harvester.js';
15
+ import type { HarvestedSession } from '../src/types.js';
16
+
17
+ describe('sessionToMarkdown', () => {
18
+ it('generates correct YAML frontmatter and message sections', () => {
19
+ const session: HarvestedSession = {
20
+ sessionId: 'ses_abc123',
21
+ slug: 'test-session',
22
+ title: 'Implement auth flow',
23
+ agent: 'sisyphus',
24
+ date: '2026-02-16',
25
+ project: '/path/to/project',
26
+ projectHash: '0a86b20b1234',
27
+ messages: [
28
+ {
29
+ role: 'user',
30
+ text: 'How should we implement auth?'
31
+ },
32
+ {
33
+ role: 'assistant',
34
+ agent: 'sisyphus',
35
+ text: 'Let me help you implement authentication...'
36
+ },
37
+ {
38
+ role: 'user',
39
+ text: 'Can you add JWT support?'
40
+ },
41
+ {
42
+ role: 'assistant',
43
+ agent: 'sisyphus-junior',
44
+ text: "I'll add JWT support..."
45
+ }
46
+ ]
47
+ };
48
+
49
+ const markdown = sessionToMarkdown(session);
50
+
51
+ expect(markdown).toContain('---');
52
+ expect(markdown).toContain('session: ses_abc123');
53
+ expect(markdown).toContain('agent: sisyphus');
54
+ expect(markdown).toContain('date: "2026-02-16"');
55
+ expect(markdown).toContain('title: "Implement auth flow"');
56
+ expect(markdown).toContain('project: /path/to/project');
57
+ expect(markdown).toContain('projectHash: 0a86b20b1234');
58
+ expect(markdown).toContain('## User');
59
+ expect(markdown).toContain('How should we implement auth?');
60
+ expect(markdown).toContain('## Assistant (sisyphus)');
61
+ expect(markdown).toContain('Let me help you implement authentication...');
62
+ expect(markdown).toContain('## Assistant (sisyphus-junior)');
63
+ expect(markdown).toContain("I'll add JWT support...");
64
+ });
65
+
66
+ it('handles missing agent name', () => {
67
+ const session: HarvestedSession = {
68
+ sessionId: 'ses_xyz789',
69
+ slug: 'no-agent',
70
+ title: 'Test',
71
+ agent: 'assistant',
72
+ date: '2026-02-16',
73
+ project: '/test',
74
+ projectHash: 'abc123',
75
+ messages: [
76
+ {
77
+ role: 'user',
78
+ text: 'Hello'
79
+ },
80
+ {
81
+ role: 'assistant',
82
+ text: 'Hi there'
83
+ }
84
+ ]
85
+ };
86
+
87
+ const markdown = sessionToMarkdown(session);
88
+
89
+ expect(markdown).toContain('## Assistant (assistant)');
90
+ expect(markdown).toContain('Hi there');
91
+ });
92
+ });
93
+
94
+ describe('getOutputPath', () => {
95
+ it('generates correct path with sanitized slug', () => {
96
+ const outputDir = '/output';
97
+ const projectPath = '/path/to/project';
98
+ const date = '2026-02-16';
99
+ const slug = 'test-session';
100
+
101
+ const path = getOutputPath(outputDir, projectPath, date, slug);
102
+
103
+ expect(path).toMatch(/^\/output\/[a-f0-9]{12}\/2026-02-16-test-session\.md$/);
104
+ });
105
+
106
+ it('handles special characters in slug', () => {
107
+ const outputDir = '/output';
108
+ const projectPath = '/path/to/project';
109
+ const date = '2026-02-16';
110
+ const slug = 'Test Session! @#$ With Spaces';
111
+
112
+ const path = getOutputPath(outputDir, projectPath, date, slug);
113
+
114
+ expect(path).toMatch(/^\/output\/[a-f0-9]{12}\/2026-02-16-test-session-with-spaces\.md$/);
115
+ });
116
+
117
+ it('collapses multiple hyphens', () => {
118
+ const outputDir = '/output';
119
+ const projectPath = '/path/to/project';
120
+ const date = '2026-02-16';
121
+ const slug = 'test---multiple---hyphens';
122
+
123
+ const path = getOutputPath(outputDir, projectPath, date, slug);
124
+
125
+ expect(path).toMatch(/^\/output\/[a-f0-9]{12}\/2026-02-16-test-multiple-hyphens\.md$/);
126
+ });
127
+
128
+ it('removes leading and trailing hyphens', () => {
129
+ const outputDir = '/output';
130
+ const projectPath = '/path/to/project';
131
+ const date = '2026-02-16';
132
+ const slug = '---test-slug---';
133
+
134
+ const path = getOutputPath(outputDir, projectPath, date, slug);
135
+
136
+ expect(path).toMatch(/^\/output\/[a-f0-9]{12}\/2026-02-16-test-slug\.md$/);
137
+ });
138
+ });
139
+
140
+ describe('parseParts', () => {
141
+ let tmpDir: string;
142
+
143
+ beforeEach(() => {
144
+ tmpDir = join(tmpdir(), `harvester-test-${Date.now()}`);
145
+ mkdirSync(tmpDir, { recursive: true });
146
+ });
147
+
148
+ afterEach(() => {
149
+ if (existsSync(tmpDir)) {
150
+ rmSync(tmpDir, { recursive: true, force: true });
151
+ }
152
+ });
153
+
154
+ it('extracts only text parts, skips tool/step-start', () => {
155
+ const messageId = 'msg_001';
156
+ const partDir = join(tmpDir, 'part', messageId);
157
+ mkdirSync(partDir, { recursive: true });
158
+
159
+ writeFileSync(
160
+ join(partDir, 'prt_001.json'),
161
+ JSON.stringify({
162
+ id: 'prt_001',
163
+ type: 'text',
164
+ text: 'First text part'
165
+ })
166
+ );
167
+
168
+ writeFileSync(
169
+ join(partDir, 'prt_002.json'),
170
+ JSON.stringify({
171
+ id: 'prt_002',
172
+ type: 'tool',
173
+ callID: 'toolu_123',
174
+ tool: 'bash'
175
+ })
176
+ );
177
+
178
+ writeFileSync(
179
+ join(partDir, 'prt_003.json'),
180
+ JSON.stringify({
181
+ id: 'prt_003',
182
+ type: 'step-start',
183
+ snapshot: '618a2220'
184
+ })
185
+ );
186
+
187
+ writeFileSync(
188
+ join(partDir, 'prt_004.json'),
189
+ JSON.stringify({
190
+ id: 'prt_004',
191
+ type: 'text',
192
+ text: 'Second text part'
193
+ })
194
+ );
195
+
196
+ const result = parseParts(messageId, tmpDir);
197
+
198
+ expect(result).toBe('First text part\nSecond text part');
199
+ });
200
+
201
+ it('skips synthetic parts', () => {
202
+ const messageId = 'msg_002';
203
+ const partDir = join(tmpDir, 'part', messageId);
204
+ mkdirSync(partDir, { recursive: true });
205
+
206
+ writeFileSync(
207
+ join(partDir, 'prt_001.json'),
208
+ JSON.stringify({
209
+ id: 'prt_001',
210
+ type: 'text',
211
+ text: 'Real text'
212
+ })
213
+ );
214
+
215
+ writeFileSync(
216
+ join(partDir, 'prt_002.json'),
217
+ JSON.stringify({
218
+ id: 'prt_002',
219
+ type: 'text',
220
+ synthetic: true,
221
+ text: 'Synthetic text'
222
+ })
223
+ );
224
+
225
+ const result = parseParts(messageId, tmpDir);
226
+
227
+ expect(result).toBe('Real text');
228
+ expect(result).not.toContain('Synthetic text');
229
+ });
230
+
231
+ it('returns empty string for missing directory', () => {
232
+ const result = parseParts('msg_nonexistent', tmpDir);
233
+ expect(result).toBe('');
234
+ });
235
+
236
+ it('handles malformed JSON gracefully', () => {
237
+ const messageId = 'msg_003';
238
+ const partDir = join(tmpDir, 'part', messageId);
239
+ mkdirSync(partDir, { recursive: true });
240
+
241
+ writeFileSync(join(partDir, 'prt_001.json'), 'invalid json{');
242
+
243
+ const result = parseParts(messageId, tmpDir);
244
+ expect(result).toBe('');
245
+ });
246
+ });
247
+
248
+ describe('parseSession', () => {
249
+ let tmpDir: string;
250
+
251
+ beforeEach(() => {
252
+ tmpDir = join(tmpdir(), `harvester-test-${Date.now()}`);
253
+ mkdirSync(tmpDir, { recursive: true });
254
+ });
255
+
256
+ afterEach(() => {
257
+ if (existsSync(tmpDir)) {
258
+ rmSync(tmpDir, { recursive: true, force: true });
259
+ }
260
+ });
261
+
262
+ it('parses valid session JSON', () => {
263
+ const sessionPath = join(tmpDir, 'ses_test1.json');
264
+ writeFileSync(
265
+ sessionPath,
266
+ JSON.stringify({
267
+ id: 'ses_test1',
268
+ slug: 'test-session',
269
+ title: 'Test Session',
270
+ projectID: 'abc123',
271
+ directory: '/path/to/project',
272
+ time: {
273
+ created: 1770106366269,
274
+ updated: 1770223889563
275
+ }
276
+ })
277
+ );
278
+
279
+ const result = parseSession(sessionPath);
280
+
281
+ expect(result).not.toBeNull();
282
+ expect(result?.id).toBe('ses_test1');
283
+ expect(result?.slug).toBe('test-session');
284
+ expect(result?.title).toBe('Test Session');
285
+ expect(result?.projectID).toBe('abc123');
286
+ expect(result?.directory).toBe('/path/to/project');
287
+ expect(result?.created).toBe(1770106366269);
288
+ });
289
+
290
+ it('returns null for missing file', () => {
291
+ const result = parseSession(join(tmpDir, 'nonexistent.json'));
292
+ expect(result).toBeNull();
293
+ });
294
+
295
+ it('returns null for malformed JSON', () => {
296
+ const sessionPath = join(tmpDir, 'ses_bad.json');
297
+ writeFileSync(sessionPath, 'invalid json{');
298
+
299
+ const result = parseSession(sessionPath);
300
+ expect(result).toBeNull();
301
+ });
302
+
303
+ it('handles missing title field', () => {
304
+ const sessionPath = join(tmpDir, 'ses_notitle.json');
305
+ writeFileSync(
306
+ sessionPath,
307
+ JSON.stringify({
308
+ id: 'ses_notitle',
309
+ slug: 'no-title',
310
+ projectID: 'abc123',
311
+ directory: '/path',
312
+ time: { created: 1770106366269 }
313
+ })
314
+ );
315
+
316
+ const result = parseSession(sessionPath);
317
+
318
+ expect(result).not.toBeNull();
319
+ expect(result?.title).toBe('');
320
+ });
321
+ });
322
+
323
+ describe('parseMessages', () => {
324
+ let tmpDir: string;
325
+
326
+ beforeEach(() => {
327
+ tmpDir = join(tmpdir(), `harvester-test-${Date.now()}`);
328
+ mkdirSync(tmpDir, { recursive: true });
329
+ });
330
+
331
+ afterEach(() => {
332
+ if (existsSync(tmpDir)) {
333
+ rmSync(tmpDir, { recursive: true, force: true });
334
+ }
335
+ });
336
+
337
+ it('parses messages and sorts by creation time', () => {
338
+ const sessionId = 'ses_test1';
339
+ const messageDir = join(tmpDir, 'message', sessionId);
340
+ mkdirSync(messageDir, { recursive: true });
341
+
342
+ writeFileSync(
343
+ join(messageDir, 'msg_002.json'),
344
+ JSON.stringify({
345
+ id: 'msg_002',
346
+ sessionID: sessionId,
347
+ role: 'assistant',
348
+ agent: 'sisyphus',
349
+ time: { created: 1770106366300 }
350
+ })
351
+ );
352
+
353
+ writeFileSync(
354
+ join(messageDir, 'msg_001.json'),
355
+ JSON.stringify({
356
+ id: 'msg_001',
357
+ sessionID: sessionId,
358
+ role: 'user',
359
+ time: { created: 1770106366200 }
360
+ })
361
+ );
362
+
363
+ const result = parseMessages(sessionId, tmpDir);
364
+
365
+ expect(result).toHaveLength(2);
366
+ expect(result[0].id).toBe('msg_001');
367
+ expect(result[0].role).toBe('user');
368
+ expect(result[1].id).toBe('msg_002');
369
+ expect(result[1].role).toBe('assistant');
370
+ expect(result[1].agent).toBe('sisyphus');
371
+ });
372
+
373
+ it('returns empty array for missing directory', () => {
374
+ const result = parseMessages('ses_nonexistent', tmpDir);
375
+ expect(result).toEqual([]);
376
+ });
377
+
378
+ it('handles malformed JSON gracefully', () => {
379
+ const sessionId = 'ses_test2';
380
+ const messageDir = join(tmpDir, 'message', sessionId);
381
+ mkdirSync(messageDir, { recursive: true });
382
+
383
+ writeFileSync(join(messageDir, 'msg_001.json'), 'invalid json{');
384
+
385
+ const result = parseMessages(sessionId, tmpDir);
386
+ expect(result).toEqual([]);
387
+ });
388
+ });
389
+
390
+ describe('loadHarvestState and saveHarvestState', () => {
391
+ let tmpDir: string;
392
+
393
+ beforeEach(() => {
394
+ tmpDir = join(tmpdir(), `harvester-test-${Date.now()}`);
395
+ mkdirSync(tmpDir, { recursive: true });
396
+ });
397
+
398
+ afterEach(() => {
399
+ if (existsSync(tmpDir)) {
400
+ rmSync(tmpDir, { recursive: true, force: true });
401
+ }
402
+ });
403
+
404
+ it('round-trip test', () => {
405
+ const stateFile = join(tmpDir, 'state.json');
406
+ const state = {
407
+ 'ses_abc123': 1770106366269,
408
+ 'ses_xyz789': 1770223889563
409
+ };
410
+
411
+ saveHarvestState(stateFile, state);
412
+
413
+ expect(existsSync(stateFile)).toBe(true);
414
+
415
+ const loaded = loadHarvestState(stateFile);
416
+
417
+ expect(loaded).toEqual(state);
418
+ });
419
+
420
+ it('creates parent directories if needed', () => {
421
+ const stateFile = join(tmpDir, 'nested', 'dir', 'state.json');
422
+ const state = { 'ses_test': 123456 };
423
+
424
+ saveHarvestState(stateFile, state);
425
+
426
+ expect(existsSync(stateFile)).toBe(true);
427
+ const loaded = loadHarvestState(stateFile);
428
+ expect(loaded).toEqual(state);
429
+ });
430
+
431
+ it('returns empty object for missing file', () => {
432
+ const result = loadHarvestState(join(tmpDir, 'nonexistent.json'));
433
+ expect(result).toEqual({});
434
+ });
435
+
436
+ it('returns empty object for malformed JSON', () => {
437
+ const stateFile = join(tmpDir, 'bad.json');
438
+ writeFileSync(stateFile, 'invalid json{');
439
+
440
+ const result = loadHarvestState(stateFile);
441
+ expect(result).toEqual({});
442
+ });
443
+ });
444
+
445
+ describe('harvestSessions', () => {
446
+ let tmpDir: string;
447
+ let outputDir: string;
448
+
449
+ beforeEach(() => {
450
+ tmpDir = join(tmpdir(), `harvester-test-${Date.now()}`);
451
+ outputDir = join(tmpDir, 'output');
452
+ mkdirSync(tmpDir, { recursive: true });
453
+ mkdirSync(outputDir, { recursive: true });
454
+ });
455
+
456
+ afterEach(() => {
457
+ if (existsSync(tmpDir)) {
458
+ rmSync(tmpDir, { recursive: true, force: true });
459
+ }
460
+ });
461
+
462
+ it('end-to-end test with fixture data', async () => {
463
+ const projectHash = 'abc123';
464
+ const sessionId = 'ses_test1';
465
+ const messageId1 = 'msg_001';
466
+ const messageId2 = 'msg_002';
467
+
468
+ const projectDir = join(tmpDir, 'project');
469
+ mkdirSync(projectDir, { recursive: true });
470
+ writeFileSync(
471
+ join(projectDir, `${projectHash}.json`),
472
+ JSON.stringify({
473
+ id: projectHash,
474
+ worktree: '/path/to/project',
475
+ vcs: 'git',
476
+ time: { created: 1770106366269, updated: 1770223889563 }
477
+ })
478
+ );
479
+
480
+ const sessionDir = join(tmpDir, 'session', projectHash);
481
+ mkdirSync(sessionDir, { recursive: true });
482
+ writeFileSync(
483
+ join(sessionDir, `${sessionId}.json`),
484
+ JSON.stringify({
485
+ id: sessionId,
486
+ slug: 'test-session',
487
+ title: 'Test Session',
488
+ projectID: projectHash,
489
+ directory: '/path/to/project',
490
+ time: { created: 1770106366269, updated: 1770223889563 }
491
+ })
492
+ );
493
+
494
+ const messageDir = join(tmpDir, 'message', sessionId);
495
+ mkdirSync(messageDir, { recursive: true });
496
+ writeFileSync(
497
+ join(messageDir, `${messageId1}.json`),
498
+ JSON.stringify({
499
+ id: messageId1,
500
+ sessionID: sessionId,
501
+ role: 'user',
502
+ time: { created: 1770106366200 }
503
+ })
504
+ );
505
+ writeFileSync(
506
+ join(messageDir, `${messageId2}.json`),
507
+ JSON.stringify({
508
+ id: messageId2,
509
+ sessionID: sessionId,
510
+ role: 'assistant',
511
+ agent: 'sisyphus',
512
+ time: { created: 1770106366300 }
513
+ })
514
+ );
515
+
516
+ const partDir1 = join(tmpDir, 'part', messageId1);
517
+ mkdirSync(partDir1, { recursive: true });
518
+ writeFileSync(
519
+ join(partDir1, 'prt_001.json'),
520
+ JSON.stringify({
521
+ id: 'prt_001',
522
+ type: 'text',
523
+ text: 'Hello, how can I help?'
524
+ })
525
+ );
526
+
527
+ const partDir2 = join(tmpDir, 'part', messageId2);
528
+ mkdirSync(partDir2, { recursive: true });
529
+ writeFileSync(
530
+ join(partDir2, 'prt_002.json'),
531
+ JSON.stringify({
532
+ id: 'prt_002',
533
+ type: 'text',
534
+ text: 'I can help you with that.'
535
+ })
536
+ );
537
+ writeFileSync(
538
+ join(partDir2, 'prt_003.json'),
539
+ JSON.stringify({
540
+ id: 'prt_003',
541
+ type: 'tool',
542
+ callID: 'toolu_123',
543
+ tool: 'bash'
544
+ })
545
+ );
546
+ writeFileSync(
547
+ join(partDir2, 'prt_004.json'),
548
+ JSON.stringify({
549
+ id: 'prt_004',
550
+ type: 'step-start',
551
+ snapshot: '618a2220'
552
+ })
553
+ );
554
+
555
+ const result = await harvestSessions({
556
+ sessionDir: tmpDir,
557
+ outputDir
558
+ });
559
+
560
+ expect(result).toHaveLength(1);
561
+ expect(result[0].sessionId).toBe(sessionId);
562
+ expect(result[0].slug).toBe('test-session');
563
+ expect(result[0].title).toBe('Test Session');
564
+ expect(result[0].agent).toBe('sisyphus');
565
+ expect(result[0].project).toBe('/path/to/project');
566
+ expect(result[0].messages).toHaveLength(2);
567
+ expect(result[0].messages[0].role).toBe('user');
568
+ expect(result[0].messages[0].text).toBe('Hello, how can I help?');
569
+ expect(result[0].messages[1].role).toBe('assistant');
570
+ expect(result[0].messages[1].agent).toBe('sisyphus');
571
+ expect(result[0].messages[1].text).toBe('I can help you with that.');
572
+
573
+ const outputPath = getOutputPath(outputDir, '/path/to/project', result[0].date, 'test-session');
574
+ expect(existsSync(outputPath)).toBe(true);
575
+
576
+ const markdown = readFileSync(outputPath, 'utf-8');
577
+ expect(markdown).toContain('session: ses_test1');
578
+ expect(markdown).toContain('title: "Test Session"');
579
+ expect(markdown).toContain('## User');
580
+ expect(markdown).toContain('Hello, how can I help?');
581
+ expect(markdown).toContain('## Assistant (sisyphus)');
582
+ expect(markdown).toContain('I can help you with that.');
583
+ });
584
+
585
+ it('returns empty array for missing session directory', async () => {
586
+ const result = await harvestSessions({
587
+ sessionDir: join(tmpDir, 'nonexistent'),
588
+ outputDir
589
+ });
590
+
591
+ expect(result).toEqual([]);
592
+ });
593
+
594
+ it('handles multiple sessions in multiple projects', async () => {
595
+ const projectHash1 = 'proj1';
596
+ const projectHash2 = 'proj2';
597
+ const sessionId1 = 'ses_001';
598
+ const sessionId2 = 'ses_002';
599
+
600
+ const sessionDir1 = join(tmpDir, 'session', projectHash1);
601
+ mkdirSync(sessionDir1, { recursive: true });
602
+ writeFileSync(
603
+ join(sessionDir1, `${sessionId1}.json`),
604
+ JSON.stringify({
605
+ id: sessionId1,
606
+ slug: 'session-one',
607
+ title: 'Session One',
608
+ projectID: projectHash1,
609
+ directory: '/project1',
610
+ time: { created: 1770106366269 }
611
+ })
612
+ );
613
+
614
+ const sessionDir2 = join(tmpDir, 'session', projectHash2);
615
+ mkdirSync(sessionDir2, { recursive: true });
616
+ writeFileSync(
617
+ join(sessionDir2, `${sessionId2}.json`),
618
+ JSON.stringify({
619
+ id: sessionId2,
620
+ slug: 'session-two',
621
+ title: 'Session Two',
622
+ projectID: projectHash2,
623
+ directory: '/project2',
624
+ time: { created: 1770106366300 }
625
+ })
626
+ );
627
+
628
+ const result = await harvestSessions({
629
+ sessionDir: tmpDir,
630
+ outputDir
631
+ });
632
+
633
+ expect(result).toHaveLength(2);
634
+ expect(result.map(s => s.sessionId).sort()).toEqual([sessionId1, sessionId2].sort());
635
+ });
636
+ });