escribano 0.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -0
  3. package/dist/0_types.js +279 -0
  4. package/dist/actions/classify-session.js +77 -0
  5. package/dist/actions/create-contexts.js +44 -0
  6. package/dist/actions/create-topic-blocks.js +68 -0
  7. package/dist/actions/extract-metadata.js +24 -0
  8. package/dist/actions/generate-artifact-v3.js +296 -0
  9. package/dist/actions/generate-artifact.js +61 -0
  10. package/dist/actions/generate-summary-v3.js +260 -0
  11. package/dist/actions/outline-index.js +204 -0
  12. package/dist/actions/process-recording-v2.js +494 -0
  13. package/dist/actions/process-recording-v3.js +412 -0
  14. package/dist/actions/process-session.js +183 -0
  15. package/dist/actions/publish-summary-v3.js +303 -0
  16. package/dist/actions/sync-to-outline.js +196 -0
  17. package/dist/adapters/audio.silero.adapter.js +69 -0
  18. package/dist/adapters/cap.adapter.js +94 -0
  19. package/dist/adapters/capture.cap.adapter.js +107 -0
  20. package/dist/adapters/capture.filesystem.adapter.js +124 -0
  21. package/dist/adapters/embedding.ollama.adapter.js +141 -0
  22. package/dist/adapters/intelligence.adapter.js +202 -0
  23. package/dist/adapters/intelligence.mlx.adapter.js +395 -0
  24. package/dist/adapters/intelligence.ollama.adapter.js +741 -0
  25. package/dist/adapters/publishing.outline.adapter.js +75 -0
  26. package/dist/adapters/storage.adapter.js +81 -0
  27. package/dist/adapters/storage.fs.adapter.js +83 -0
  28. package/dist/adapters/transcription.whisper.adapter.js +206 -0
  29. package/dist/adapters/video.ffmpeg.adapter.js +405 -0
  30. package/dist/adapters/whisper.adapter.js +168 -0
  31. package/dist/batch-context.js +329 -0
  32. package/dist/db/helpers.js +50 -0
  33. package/dist/db/index.js +95 -0
  34. package/dist/db/migrate.js +80 -0
  35. package/dist/db/repositories/artifact.sqlite.js +77 -0
  36. package/dist/db/repositories/cluster.sqlite.js +92 -0
  37. package/dist/db/repositories/context.sqlite.js +75 -0
  38. package/dist/db/repositories/index.js +10 -0
  39. package/dist/db/repositories/observation.sqlite.js +70 -0
  40. package/dist/db/repositories/recording.sqlite.js +56 -0
  41. package/dist/db/repositories/subject.sqlite.js +64 -0
  42. package/dist/db/repositories/topic-block.sqlite.js +45 -0
  43. package/dist/db/types.js +4 -0
  44. package/dist/domain/classification.js +60 -0
  45. package/dist/domain/context.js +97 -0
  46. package/dist/domain/index.js +2 -0
  47. package/dist/domain/observation.js +17 -0
  48. package/dist/domain/recording.js +41 -0
  49. package/dist/domain/segment.js +93 -0
  50. package/dist/domain/session.js +93 -0
  51. package/dist/domain/time-range.js +38 -0
  52. package/dist/domain/transcript.js +79 -0
  53. package/dist/index.js +173 -0
  54. package/dist/pipeline/context.js +162 -0
  55. package/dist/pipeline/events.js +2 -0
  56. package/dist/prerequisites.js +226 -0
  57. package/dist/scripts/rebuild-index.js +53 -0
  58. package/dist/scripts/seed-fixtures.js +290 -0
  59. package/dist/services/activity-segmentation.js +333 -0
  60. package/dist/services/activity-segmentation.test.js +191 -0
  61. package/dist/services/app-normalization.js +212 -0
  62. package/dist/services/cluster-merge.js +69 -0
  63. package/dist/services/clustering.js +237 -0
  64. package/dist/services/debug.js +58 -0
  65. package/dist/services/frame-sampling.js +318 -0
  66. package/dist/services/signal-extraction.js +106 -0
  67. package/dist/services/subject-grouping.js +342 -0
  68. package/dist/services/temporal-alignment.js +99 -0
  69. package/dist/services/vlm-enrichment.js +84 -0
  70. package/dist/services/vlm-service.js +130 -0
  71. package/dist/stats/index.js +3 -0
  72. package/dist/stats/observer.js +65 -0
  73. package/dist/stats/repository.js +36 -0
  74. package/dist/stats/resource-tracker.js +86 -0
  75. package/dist/stats/types.js +1 -0
  76. package/dist/test-classification-prompts.js +181 -0
  77. package/dist/tests/cap.adapter.test.js +75 -0
  78. package/dist/tests/capture.cap.adapter.test.js +69 -0
  79. package/dist/tests/classify-session.test.js +140 -0
  80. package/dist/tests/db/repositories.test.js +243 -0
  81. package/dist/tests/domain/time-range.test.js +31 -0
  82. package/dist/tests/integration.test.js +84 -0
  83. package/dist/tests/intelligence.adapter.test.js +102 -0
  84. package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
  85. package/dist/tests/process-v2.test.js +90 -0
  86. package/dist/tests/services/clustering.test.js +112 -0
  87. package/dist/tests/services/frame-sampling.test.js +152 -0
  88. package/dist/tests/utils/ocr.test.js +76 -0
  89. package/dist/tests/utils/parallel.test.js +57 -0
  90. package/dist/tests/visual-observer.test.js +175 -0
  91. package/dist/utils/id-normalization.js +15 -0
  92. package/dist/utils/index.js +9 -0
  93. package/dist/utils/model-detector.js +154 -0
  94. package/dist/utils/ocr.js +80 -0
  95. package/dist/utils/parallel.js +32 -0
  96. package/migrations/001_initial.sql +109 -0
  97. package/migrations/002_clusters.sql +41 -0
  98. package/migrations/003_observations_vlm_fields.sql +14 -0
  99. package/migrations/004_observations_unique.sql +18 -0
  100. package/migrations/005_processing_stats.sql +29 -0
  101. package/migrations/006_vlm_raw_response.sql +6 -0
  102. package/migrations/007_subjects.sql +23 -0
  103. package/migrations/008_artifacts_recording.sql +6 -0
  104. package/migrations/009_artifact_subjects.sql +10 -0
  105. package/package.json +82 -0
  106. package/prompts/action-items.md +55 -0
  107. package/prompts/blog-draft.md +54 -0
  108. package/prompts/blog-research.md +87 -0
  109. package/prompts/card.md +54 -0
  110. package/prompts/classify-segment.md +38 -0
  111. package/prompts/classify.md +37 -0
  112. package/prompts/code-snippets.md +163 -0
  113. package/prompts/extract-metadata.md +149 -0
  114. package/prompts/notes.md +83 -0
  115. package/prompts/runbook.md +123 -0
  116. package/prompts/standup.md +50 -0
  117. package/prompts/step-by-step.md +125 -0
  118. package/prompts/subject-grouping.md +31 -0
  119. package/prompts/summary-v3.md +89 -0
  120. package/prompts/summary.md +77 -0
  121. package/prompts/topic-classifier.md +24 -0
  122. package/prompts/topic-extract.md +13 -0
  123. package/prompts/vlm-batch.md +21 -0
  124. package/prompts/vlm-single.md +19 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Repository Interface Tests
3
+ *
4
+ * These tests run against the interface, not the implementation.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { generateId } from '../../db/helpers.js';
8
+ import { createTestRepositories } from '../../db/index.js';
9
+ // =============================================================================
10
+ // Interface Test Factories
11
+ // =============================================================================
12
+ function runRecordingRepositoryTests(name, createRepo) {
13
+ describe(`RecordingRepository: ${name}`, () => {
14
+ let repo;
15
+ let cleanup;
16
+ beforeEach(() => {
17
+ const result = createRepo();
18
+ repo = result.repo;
19
+ cleanup = result.cleanup;
20
+ });
21
+ afterEach(() => {
22
+ cleanup();
23
+ });
24
+ it('saves and retrieves a recording', () => {
25
+ const id = generateId();
26
+ repo.save({
27
+ id,
28
+ video_path: '/path/to/video.mp4',
29
+ audio_mic_path: '/path/to/mic.ogg',
30
+ audio_system_path: null,
31
+ duration: 120.5,
32
+ captured_at: '2026-01-20T10:00:00Z',
33
+ status: 'raw',
34
+ processing_step: null,
35
+ source_type: 'cap',
36
+ source_metadata: null,
37
+ error_message: null,
38
+ });
39
+ const found = repo.findById(id);
40
+ expect(found).not.toBeNull();
41
+ expect(found.id).toBe(id);
42
+ expect(found.duration).toBe(120.5);
43
+ expect(found.status).toBe('raw');
44
+ });
45
+ it('returns null for non-existent recording', () => {
46
+ const found = repo.findById('nonexistent');
47
+ expect(found).toBeNull();
48
+ });
49
+ it('updates status and processing step', () => {
50
+ const id = generateId();
51
+ repo.save({
52
+ id,
53
+ video_path: null,
54
+ audio_mic_path: '/path/to/mic.ogg',
55
+ audio_system_path: null,
56
+ duration: 60,
57
+ captured_at: '2026-01-20T10:00:00Z',
58
+ status: 'raw',
59
+ processing_step: null,
60
+ source_type: 'cap',
61
+ source_metadata: null,
62
+ error_message: null,
63
+ });
64
+ repo.updateStatus(id, 'processing', 'clustering');
65
+ const found = repo.findById(id);
66
+ expect(found.status).toBe('processing');
67
+ expect(found.processing_step).toBe('clustering');
68
+ });
69
+ it('finds pending recordings', () => {
70
+ const id1 = generateId();
71
+ const id2 = generateId();
72
+ repo.save({
73
+ id: id1,
74
+ video_path: null,
75
+ audio_mic_path: '/path/1.ogg',
76
+ audio_system_path: null,
77
+ duration: 60,
78
+ captured_at: '2026-01-20T10:00:00Z',
79
+ status: 'raw',
80
+ processing_step: null,
81
+ source_type: 'cap',
82
+ source_metadata: null,
83
+ error_message: null,
84
+ });
85
+ repo.save({
86
+ id: id2,
87
+ video_path: null,
88
+ audio_mic_path: '/path/2.ogg',
89
+ audio_system_path: null,
90
+ duration: 60,
91
+ captured_at: '2026-01-20T11:00:00Z',
92
+ status: 'processed',
93
+ processing_step: 'complete',
94
+ source_type: 'cap',
95
+ source_metadata: null,
96
+ error_message: null,
97
+ });
98
+ const pending = repo.findPending();
99
+ expect(pending.some((p) => p.id === id1)).toBe(true);
100
+ expect(pending.some((p) => p.id === id2)).toBe(false);
101
+ });
102
+ it('deletes a recording', () => {
103
+ const id = generateId();
104
+ repo.save({
105
+ id,
106
+ video_path: null,
107
+ audio_mic_path: '/path/to/mic.ogg',
108
+ audio_system_path: null,
109
+ duration: 60,
110
+ captured_at: '2026-01-20T10:00:00Z',
111
+ status: 'raw',
112
+ processing_step: null,
113
+ source_type: 'cap',
114
+ source_metadata: null,
115
+ error_message: null,
116
+ });
117
+ repo.delete(id);
118
+ const found = repo.findById(id);
119
+ expect(found).toBeNull();
120
+ });
121
+ });
122
+ }
123
+ function runContextRepositoryTests(name, createRepos) {
124
+ describe(`ContextRepository: ${name}`, () => {
125
+ let contextRepo;
126
+ let recordingRepo;
127
+ let observationRepo;
128
+ let cleanup;
129
+ beforeEach(() => {
130
+ const result = createRepos();
131
+ contextRepo = result.contextRepo;
132
+ recordingRepo = result.recordingRepo;
133
+ observationRepo = result.observationRepo;
134
+ cleanup = result.cleanup;
135
+ });
136
+ afterEach(() => {
137
+ cleanup();
138
+ });
139
+ it('saves and retrieves a context', () => {
140
+ const id = generateId();
141
+ contextRepo.save({
142
+ id,
143
+ type: 'project',
144
+ name: 'escribano',
145
+ metadata: JSON.stringify({ version: '1.0' }),
146
+ });
147
+ const found = contextRepo.findById(id);
148
+ expect(found).not.toBeNull();
149
+ expect(found.id).toBe(id);
150
+ expect(found.name).toBe('escribano');
151
+ });
152
+ it('finds by type and name', () => {
153
+ const id = generateId();
154
+ contextRepo.save({
155
+ id,
156
+ type: 'app',
157
+ name: 'vscode',
158
+ metadata: null,
159
+ });
160
+ const found = contextRepo.findByTypeAndName('app', 'vscode');
161
+ expect(found).not.toBeNull();
162
+ expect(found.id).toBe(id);
163
+ });
164
+ it('links and unlinks observations', () => {
165
+ const recordingId = generateId();
166
+ const contextId = generateId();
167
+ const observationId = generateId();
168
+ // 1. Save Recording (required for observation FK)
169
+ recordingRepo.save({
170
+ id: recordingId,
171
+ video_path: null,
172
+ audio_mic_path: null,
173
+ audio_system_path: null,
174
+ duration: 0,
175
+ captured_at: new Date().toISOString(),
176
+ status: 'raw',
177
+ processing_step: null,
178
+ source_type: 'raw',
179
+ source_metadata: null,
180
+ error_message: null,
181
+ });
182
+ // 2. Save Observation (required for link FK)
183
+ observationRepo.saveBatch([
184
+ {
185
+ id: observationId,
186
+ recording_id: recordingId,
187
+ type: 'visual',
188
+ timestamp: 0,
189
+ end_timestamp: null,
190
+ image_path: null,
191
+ ocr_text: 'test',
192
+ vlm_description: null,
193
+ vlm_raw_response: null,
194
+ activity_type: null,
195
+ apps: null,
196
+ topics: null,
197
+ text: null,
198
+ audio_source: null,
199
+ audio_type: null,
200
+ embedding: null,
201
+ },
202
+ ]);
203
+ // 3. Save Context
204
+ contextRepo.save({
205
+ id: contextId,
206
+ type: 'topic',
207
+ name: 'database',
208
+ metadata: null,
209
+ });
210
+ // 4. Link
211
+ contextRepo.linkObservation(observationId, contextId, 0.95);
212
+ const links = contextRepo.getObservationLinks(contextId);
213
+ expect(links).toHaveLength(1);
214
+ expect(links[0].observation_id).toBe(observationId);
215
+ expect(links[0].confidence).toBe(0.95);
216
+ // 5. Unlink
217
+ contextRepo.unlinkObservation(observationId, contextId);
218
+ const linksAfter = contextRepo.getObservationLinks(contextId);
219
+ expect(linksAfter).toHaveLength(0);
220
+ });
221
+ });
222
+ }
223
+ // =============================================================================
224
+ // Run Tests Against SQLite Implementation
225
+ // =============================================================================
226
+ runRecordingRepositoryTests('SQLite', () => {
227
+ // Create fresh test repositories for each test
228
+ const testRepos = createTestRepositories();
229
+ return {
230
+ repo: testRepos.recordings,
231
+ cleanup: testRepos.cleanup,
232
+ };
233
+ });
234
+ runContextRepositoryTests('SQLite', () => {
235
+ // Create fresh test repositories for each test
236
+ const testRepos = createTestRepositories();
237
+ return {
238
+ contextRepo: testRepos.contexts,
239
+ recordingRepo: testRepos.recordings,
240
+ observationRepo: testRepos.observations,
241
+ cleanup: testRepos.cleanup,
242
+ };
243
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TimeRange } from '../../domain/time-range.js';
3
+ describe('TimeRange Value Object', () => {
4
+ describe('overlapDuration', () => {
5
+ it('should return 0 when there is no overlap', () => {
6
+ const overlap = TimeRange.overlapDuration([0, 10], [20, 30]);
7
+ expect(overlap).toBe(0);
8
+ });
9
+ it('should calculate partial overlap correctly', () => {
10
+ // Overlap between [0, 10] and [5, 15] is [5, 10] = 5 seconds
11
+ const overlap = TimeRange.overlapDuration([0, 10], [5, 15]);
12
+ expect(overlap).toBe(5);
13
+ });
14
+ it('should handle range fully contained within another', () => {
15
+ const overlap = TimeRange.overlapDuration([0, 100], [10, 20]);
16
+ expect(overlap).toBe(10);
17
+ });
18
+ it('should handle segments fully contained within range', () => {
19
+ const overlap = TimeRange.overlapDuration([0, 10], [2, 8]);
20
+ expect(overlap).toBe(6);
21
+ });
22
+ });
23
+ describe('create', () => {
24
+ it('should throw for negative values', () => {
25
+ expect(() => TimeRange.create(-1, 10)).toThrow();
26
+ });
27
+ it('should throw for end < start', () => {
28
+ expect(() => TimeRange.create(10, 5)).toThrow();
29
+ });
30
+ });
31
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Integration Test - Simple E2E Pipeline Test
3
+ *
4
+ * Tests: Cap Adapter → Process Session → Output
5
+ */
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ describe('Integration: CLI Pipeline', () => {
8
+ it('should list recordings', async () => {
9
+ const mockRecording = {
10
+ id: 'test-recording-1',
11
+ source: {
12
+ type: 'cap',
13
+ originalPath: '~/test.cap',
14
+ metadata: {
15
+ platform: 'MacOS',
16
+ pretty_name: 'Test Recording',
17
+ segments: [
18
+ {
19
+ display: {
20
+ path: 'content/segments/segment-0/display.mp4',
21
+ fps: 37,
22
+ },
23
+ mic: {
24
+ path: 'content/segments/segment-0/audio-input.ogg',
25
+ start_time: 0,
26
+ },
27
+ cursor: 'content/segments/segment-0/cursor.json',
28
+ },
29
+ ],
30
+ },
31
+ },
32
+ videoPath: '~/test.cap/content/segments/segment-0/display.mp4',
33
+ audioMicPath: '~/test.cap/content/segments/segment-0/audio-input.ogg',
34
+ audioSystemPath: null,
35
+ duration: 0,
36
+ capturedAt: new Date('2025-01-08T12:00:00.000Z'),
37
+ };
38
+ const mockCapSource = {
39
+ getLatestRecording: vi.fn().mockResolvedValue(mockRecording),
40
+ listRecordings: vi.fn().mockResolvedValue([mockRecording]),
41
+ };
42
+ const recordings = await mockCapSource.listRecordings(10);
43
+ expect(recordings).toEqual([mockRecording]);
44
+ expect(mockCapSource.listRecordings).toHaveBeenCalledWith(10);
45
+ });
46
+ it('should get latest recording', async () => {
47
+ const mockRecording = {
48
+ id: 'latest-recording',
49
+ source: {
50
+ type: 'cap',
51
+ originalPath: '~/Library/Application Support/so.cap.desktop/recordings/test.cap',
52
+ metadata: {
53
+ platform: 'MacOS',
54
+ pretty_name: 'Latest Recording',
55
+ segments: [
56
+ {
57
+ display: {
58
+ path: 'content/segments/segment-0/display.mp4',
59
+ fps: 37,
60
+ },
61
+ mic: {
62
+ path: 'content/segments/segment-0/audio-input.ogg',
63
+ start_time: 0,
64
+ },
65
+ cursor: 'content/segments/segment-0/cursor.json',
66
+ },
67
+ ],
68
+ },
69
+ },
70
+ videoPath: '/tmp/video.mp4',
71
+ audioMicPath: '/tmp/audio.ogg',
72
+ audioSystemPath: null,
73
+ duration: 0,
74
+ capturedAt: new Date('2025-01-08T14:30:00.000Z'),
75
+ };
76
+ const mockCapSource = {
77
+ getLatestRecording: vi.fn().mockResolvedValue(mockRecording),
78
+ listRecordings: vi.fn().mockResolvedValue([mockRecording]),
79
+ };
80
+ const recording = await mockCapSource.getLatestRecording();
81
+ expect(recording).toEqual(mockRecording);
82
+ expect(mockCapSource.getLatestRecording).toHaveBeenCalled();
83
+ });
84
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Intelligence Adapter Tests
3
+ */
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { createIntelligenceService } from '../adapters/intelligence.adapter.js';
6
+ const mockConfig = {
7
+ provider: 'ollama',
8
+ endpoint: 'http://localhost:11434/v1/chat/completions',
9
+ model: 'qwen3:32b',
10
+ maxRetries: 3,
11
+ timeout: 30000,
12
+ };
13
+ const mockTranscript = {
14
+ fullText: 'This is a debugging session about authentication errors.',
15
+ segments: [
16
+ {
17
+ id: 'seg-0',
18
+ start: 0,
19
+ end: 5,
20
+ text: 'I fixed the authentication bug.',
21
+ },
22
+ {
23
+ id: 'seg-1',
24
+ start: 5,
25
+ end: 10,
26
+ text: 'Used JWT tokens for security.',
27
+ },
28
+ ],
29
+ language: 'en',
30
+ duration: 10,
31
+ };
32
+ describe('IntelligenceService', () => {
33
+ beforeEach(() => {
34
+ vi.resetAllMocks();
35
+ });
36
+ it('should create an intelligence service', () => {
37
+ const service = createIntelligenceService(mockConfig);
38
+ expect(service).toBeDefined();
39
+ expect(service.classify).toBeInstanceOf(Function);
40
+ expect(service.generate).toBeInstanceOf(Function);
41
+ });
42
+ it('should classify a debugging session', async () => {
43
+ const mockResponse = JSON.stringify({
44
+ message: {
45
+ content: JSON.stringify({
46
+ meeting: 10,
47
+ debugging: 90,
48
+ tutorial: 15,
49
+ learning: 20,
50
+ working: 5,
51
+ }),
52
+ },
53
+ });
54
+ global.fetch = vi.fn().mockResolvedValue({
55
+ ok: true,
56
+ json: async () => JSON.parse(mockResponse),
57
+ });
58
+ const service = createIntelligenceService(mockConfig);
59
+ const result = await service.classify(mockTranscript);
60
+ expect(result.debugging).toBe(90);
61
+ expect(result.meeting).toBe(10);
62
+ expect(result.tutorial).toBe(15);
63
+ expect(result.learning).toBe(20);
64
+ expect(result.working).toBe(5);
65
+ });
66
+ it('should retry on API failures', async () => {
67
+ let attempts = 0;
68
+ global.fetch = vi.fn().mockImplementation(async () => {
69
+ attempts++;
70
+ if (attempts < 3) {
71
+ throw new Error('API timeout');
72
+ }
73
+ return {
74
+ ok: true,
75
+ json: async () => ({
76
+ message: {
77
+ content: JSON.stringify({
78
+ meeting: 80,
79
+ debugging: 10,
80
+ tutorial: 5,
81
+ learning: 15,
82
+ working: 5,
83
+ }),
84
+ },
85
+ }),
86
+ };
87
+ });
88
+ const service = createIntelligenceService(mockConfig);
89
+ const result = await service.classify(mockTranscript);
90
+ expect(attempts).toBe(3);
91
+ expect(result.meeting).toBe(80);
92
+ });
93
+ it('should handle fetch errors correctly', async () => {
94
+ global.fetch = vi.fn().mockResolvedValue({
95
+ ok: false,
96
+ status: 500,
97
+ statusText: 'Internal Server Error',
98
+ });
99
+ const service = createIntelligenceService(mockConfig);
100
+ await expect(service.classify(mockTranscript)).rejects.toThrow('Classification failed after 3 retries');
101
+ });
102
+ });
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Intelligence Adapter Tests
3
+ */
4
+ import { fetch as undiciFetch } from 'undici';
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { createOllamaIntelligenceService } from '../adapters/intelligence.ollama.adapter.js';
7
+ const mockFetch = vi.mocked(undiciFetch);
8
+ // Mock node:fs
9
+ vi.mock('node:fs', () => ({
10
+ readFileSync: vi.fn((path) => {
11
+ if (path.includes('.jpg') || path.includes('.png')) {
12
+ return Buffer.from('fake-image-data');
13
+ }
14
+ if (path.includes('.md')) {
15
+ return '{{TRANSCRIPT_ALL}} {{TRANSCRIPT_SEGMENTS}} {{VISUAL_LOG}} {{CLASSIFICATION_SUMMARY}} {{METADATA}} {{LANGUAGE}} {{SPEAKERS}} {{KEY_MOMENTS}} {{ACTION_ITEMS}} {{TECHNICAL_TERMS}} {{CODE_SNIPPETS}}';
16
+ }
17
+ return '';
18
+ }),
19
+ }));
20
+ // Mock undici
21
+ vi.mock('undici', () => ({
22
+ Agent: vi.fn(),
23
+ fetch: vi.fn(),
24
+ }));
25
+ const mockConfig = {
26
+ provider: 'ollama',
27
+ endpoint: 'http://localhost:11434/v1/chat/completions',
28
+ model: 'qwen3:32b',
29
+ generationModel: 'qwen2.5:72b',
30
+ visionModel: 'minicpm-v:8b',
31
+ maxRetries: 3,
32
+ timeout: 30000,
33
+ keepAlive: '10m',
34
+ maxContextSize: 131072,
35
+ embedding: {
36
+ model: 'nomic-embed-text',
37
+ similarityThreshold: 0.75,
38
+ },
39
+ vlmBatchSize: 4,
40
+ vlmMaxTokens: 2000,
41
+ mlxSocketPath: '/tmp/escribano-mlx.sock',
42
+ };
43
+ const mockTranscript = {
44
+ fullText: 'This is a debugging session about authentication errors.',
45
+ segments: [
46
+ {
47
+ id: 'seg-0',
48
+ start: 0,
49
+ end: 5,
50
+ text: 'I fixed the authentication bug.',
51
+ },
52
+ {
53
+ id: 'seg-1',
54
+ start: 5,
55
+ end: 10,
56
+ text: 'Used JWT tokens for security.',
57
+ },
58
+ ],
59
+ language: 'en',
60
+ duration: 10,
61
+ };
62
+ describe('IntelligenceService', () => {
63
+ beforeEach(() => {
64
+ vi.resetAllMocks();
65
+ });
66
+ it('should create an intelligence service', () => {
67
+ const service = createOllamaIntelligenceService(mockConfig);
68
+ expect(service).toBeDefined();
69
+ expect(service.classify).toBeInstanceOf(Function);
70
+ expect(service.classifySegment).toBeInstanceOf(Function);
71
+ expect(service.generate).toBeInstanceOf(Function);
72
+ expect(service.describeImages).toBeInstanceOf(Function);
73
+ });
74
+ it('should describe images', async () => {
75
+ // Mock response for single image VLM calls (sequential processing)
76
+ // The API returns a pipe-delimited format: description | activity | apps | topics
77
+ // Apps and topics are comma-separated lists (not JSON arrays)
78
+ mockFetch
79
+ .mockResolvedValueOnce({
80
+ ok: true,
81
+ json: async () => ({
82
+ message: {
83
+ content: 'description: A cat sitting on a mat | activity: observing | apps: Photos | topics: animals, pets',
84
+ },
85
+ done: true,
86
+ done_reason: 'stop',
87
+ }),
88
+ })
89
+ .mockResolvedValueOnce({
90
+ ok: true,
91
+ json: async () => ({
92
+ message: {
93
+ content: 'description: A dog chasing a ball | activity: playing | apps: Camera | topics: animals, play',
94
+ },
95
+ done: true,
96
+ done_reason: 'stop',
97
+ }),
98
+ });
99
+ const service = createOllamaIntelligenceService(mockConfig);
100
+ const result = await service.describeImages([
101
+ { imagePath: '/path/to/cat.jpg', timestamp: 10 },
102
+ { imagePath: '/path/to/dog.jpg', timestamp: 20 },
103
+ ]);
104
+ expect(result).toHaveLength(2);
105
+ expect(result[0].description).toBe('A cat sitting on a mat');
106
+ expect(result[0].activity).toBe('observing');
107
+ expect(result[0].apps).toContain('Photos');
108
+ expect(result[0].topics).toContain('animals');
109
+ expect(result[1].description).toBe('A dog chasing a ball');
110
+ expect(result[1].activity).toBe('playing');
111
+ expect(result[1].apps).toContain('Camera');
112
+ expect(result[1].topics).toContain('play');
113
+ });
114
+ it('should classify a debugging session', async () => {
115
+ const mockResponse = JSON.stringify({
116
+ message: {
117
+ content: JSON.stringify({
118
+ meeting: 10,
119
+ debugging: 90,
120
+ tutorial: 15,
121
+ learning: 20,
122
+ working: 5,
123
+ }),
124
+ },
125
+ done: true,
126
+ done_reason: 'stop',
127
+ });
128
+ mockFetch.mockResolvedValue({
129
+ ok: true,
130
+ json: async () => JSON.parse(mockResponse),
131
+ });
132
+ const service = createOllamaIntelligenceService(mockConfig);
133
+ const result = await service.classify(mockTranscript);
134
+ expect(result.debugging).toBe(90);
135
+ expect(result.meeting).toBe(10);
136
+ expect(result.tutorial).toBe(15);
137
+ expect(result.learning).toBe(20);
138
+ expect(result.working).toBe(5);
139
+ });
140
+ it('should retry on API failures', async () => {
141
+ let attempts = 0;
142
+ mockFetch.mockImplementation(async () => {
143
+ attempts++;
144
+ if (attempts < 3) {
145
+ throw new Error('API timeout');
146
+ }
147
+ return {
148
+ ok: true,
149
+ json: async () => ({
150
+ message: {
151
+ content: JSON.stringify({
152
+ meeting: 80,
153
+ debugging: 10,
154
+ tutorial: 5,
155
+ learning: 15,
156
+ working: 5,
157
+ }),
158
+ },
159
+ done: true,
160
+ done_reason: 'stop',
161
+ }),
162
+ };
163
+ });
164
+ const service = createOllamaIntelligenceService(mockConfig);
165
+ const result = await service.classify(mockTranscript);
166
+ expect(attempts).toBe(3);
167
+ expect(result.meeting).toBe(80);
168
+ });
169
+ it('should handle fetch errors correctly', async () => {
170
+ mockFetch.mockResolvedValue({
171
+ ok: false,
172
+ status: 500,
173
+ statusText: 'Internal Server Error',
174
+ });
175
+ const service = createOllamaIntelligenceService(mockConfig);
176
+ await expect(service.classify(mockTranscript)).rejects.toThrow('Request failed after 3 retries');
177
+ });
178
+ });