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,93 @@
1
+ /**
2
+ * Escribano - Segment Value Object
3
+ */
4
+ import { TimeRange } from './time-range.js';
5
+ import { Transcript } from './transcript.js';
6
+ // Minimal Context logic previously in context.ts
7
+ const Context = {
8
+ extractFromOCR: (text) => {
9
+ // This is a stub to keep V1 compiling.
10
+ // V2 uses signal-extraction.ts instead.
11
+ return [];
12
+ },
13
+ unique: (contexts) => contexts,
14
+ };
15
+ export const Segment = {
16
+ /**
17
+ * Factory: Create segments from visual clusters
18
+ */
19
+ fromVisualClusters: (clusters, frames, transcripts) => {
20
+ if (clusters.length === 0)
21
+ return [];
22
+ // Sort clusters chronologically just in case
23
+ const sortedClusters = [...clusters].sort((a, b) => a.timeRange[0] - b.timeRange[0]);
24
+ const segments = [];
25
+ // Group adjacent clusters with the same heuristic label
26
+ let currentGroup = [sortedClusters[0]];
27
+ for (let i = 1; i < sortedClusters.length; i++) {
28
+ const prev = currentGroup[currentGroup.length - 1];
29
+ const curr = sortedClusters[i];
30
+ // Primitives for merging:
31
+ // 1. Same heuristic label
32
+ // 2. Overlapping or very close in time (< 5s gap)
33
+ const sameLabel = curr.heuristicLabel === prev.heuristicLabel;
34
+ const smallGap = curr.timeRange[0] - prev.timeRange[1] < 5;
35
+ if (sameLabel && smallGap) {
36
+ currentGroup.push(curr);
37
+ }
38
+ else {
39
+ segments.push(Segment.createFromClusterGroup(currentGroup, frames, transcripts));
40
+ currentGroup = [curr];
41
+ }
42
+ }
43
+ // Add last group
44
+ if (currentGroup.length > 0) {
45
+ segments.push(Segment.createFromClusterGroup(currentGroup, frames, transcripts));
46
+ }
47
+ return segments;
48
+ },
49
+ /**
50
+ * Helper: Create a segment from a group of clusters
51
+ */
52
+ createFromClusterGroup: (group, frames, transcripts) => {
53
+ const startTime = Math.min(...group.map((c) => c.timeRange[0]));
54
+ const endTime = Math.max(...group.map((c) => c.timeRange[1]));
55
+ const timeRange = [startTime, endTime];
56
+ const clusterIds = group.map((c) => c.id);
57
+ // Collect OCR text from all frames belonging to these clusters
58
+ const segmentFrames = frames.filter((f) => clusterIds.includes(f.clusterId));
59
+ const allOcrText = segmentFrames.map((f) => f.ocrText).join('\n');
60
+ // Extract contexts
61
+ const contexts = Context.unique(Context.extractFromOCR(allOcrText));
62
+ // Slice transcripts
63
+ const transcriptSlice = Transcript.sliceTagged(transcripts, timeRange);
64
+ const segment = {
65
+ id: `seg-${startTime.toFixed(0)}`,
66
+ timeRange,
67
+ visualClusterIds: clusterIds,
68
+ contexts,
69
+ transcriptSlice: transcriptSlice.length > 0 ? transcriptSlice[0] : null, // Simplify to primary for now
70
+ classification: null,
71
+ isNoise: false, // Will be set by isNoise() check
72
+ };
73
+ segment.isNoise = Segment.isNoise(segment);
74
+ return segment;
75
+ },
76
+ duration: (segment) => {
77
+ return TimeRange.duration(segment.timeRange);
78
+ },
79
+ hasAudio: (segment) => {
80
+ return (segment.transcriptSlice !== null &&
81
+ !Transcript.isEmpty(segment.transcriptSlice.transcript));
82
+ },
83
+ isNoise: (segment) => {
84
+ // 1. Check contexts for noise apps
85
+ const noiseApps = ['Spotify', 'YouTube Music'];
86
+ if (segment.contexts.some((c) => c.type === 'app' && noiseApps.includes(c.value))) {
87
+ return true;
88
+ }
89
+ // 2. Check for "idle" indicator in heuristic label
90
+ // Note: Python script might label things as "idle" (future)
91
+ return false;
92
+ },
93
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Escribano - Session Entity Module
3
+ */
4
+ import { Classification } from './classification.js';
5
+ import { Segment } from './segment.js';
6
+ export const Session = {
7
+ /**
8
+ * Factory: Create a new session from a recording
9
+ */
10
+ create: (recording) => {
11
+ const now = new Date();
12
+ return {
13
+ id: recording.id,
14
+ recording,
15
+ transcripts: [],
16
+ visualLogs: [],
17
+ segments: [],
18
+ status: 'raw',
19
+ classification: null,
20
+ metadata: null,
21
+ artifacts: [],
22
+ createdAt: now,
23
+ updatedAt: now,
24
+ };
25
+ },
26
+ /**
27
+ * Transformation: Add transcripts to session
28
+ */
29
+ withTranscripts: (session, transcripts) => ({
30
+ ...session,
31
+ transcripts,
32
+ status: 'transcribed',
33
+ updatedAt: new Date(),
34
+ }),
35
+ /**
36
+ * Transformation: Add visual index and generate segments
37
+ */
38
+ withVisualIndex: (session, visualIndex) => {
39
+ const segments = Segment.fromVisualClusters(visualIndex.clusters, visualIndex.frames, session.transcripts);
40
+ return {
41
+ ...session,
42
+ segments,
43
+ status: 'visual-logged',
44
+ updatedAt: new Date(),
45
+ };
46
+ },
47
+ /**
48
+ * Query: Get aggregated activity breakdown from segments
49
+ */
50
+ getActivityBreakdown: (session) => {
51
+ if (session.segments.length === 0)
52
+ return {};
53
+ const classifications = session.segments
54
+ .filter((s) => s.classification !== null)
55
+ .map((s) => ({
56
+ classification: s.classification,
57
+ weight: Segment.duration(s),
58
+ }));
59
+ if (classifications.length === 0)
60
+ return {};
61
+ return Classification.aggregate(classifications);
62
+ },
63
+ /**
64
+ * Query: Get recommended artifacts based on aggregated classification
65
+ */
66
+ getRecommendedArtifacts: (session) => {
67
+ const breakdown = Session.getActivityBreakdown(session);
68
+ if (!breakdown || Object.keys(breakdown).length === 0)
69
+ return ['summary'];
70
+ const recommendations = ['summary'];
71
+ if ((breakdown.meeting || 0) > 50)
72
+ recommendations.push('action-items');
73
+ if ((breakdown.debugging || 0) > 50)
74
+ recommendations.push('runbook');
75
+ if ((breakdown.tutorial || 0) > 50)
76
+ recommendations.push('step-by-step');
77
+ if ((breakdown.learning || 0) > 50)
78
+ recommendations.push('notes', 'blog-research');
79
+ if ((breakdown.working || 0) > 50)
80
+ recommendations.push('code-snippets');
81
+ return [...new Set(recommendations)]; // Unique
82
+ },
83
+ /**
84
+ * Query: Get segments needing VLM description
85
+ */
86
+ getSegmentsNeedingVLM: (session) => {
87
+ return session.segments.filter((s) => {
88
+ // Logic: No audio overlap AND (low OCR density OR specific media indicators)
89
+ // For now, simple rule: No audio = needs VLM
90
+ return !Segment.hasAudio(s) && !s.isNoise;
91
+ });
92
+ },
93
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Escribano - TimeRange Value Object
3
+ */
4
+ import { z } from 'zod';
5
+ export const timeRangeSchema = z.tuple([z.number(), z.number()]);
6
+ export const TimeRange = {
7
+ create: (start, end) => {
8
+ if (start < 0 || end < 0) {
9
+ throw new Error(`Invalid time range: [${start}, ${end}]. Values must be non-negative.`);
10
+ }
11
+ if (end < start) {
12
+ throw new Error(`Invalid time range: [${start}, ${end}]. End must be greater than or equal to start.`);
13
+ }
14
+ return [start, end];
15
+ },
16
+ duration: (range) => range[1] - range[0],
17
+ overlaps: (a, b) => {
18
+ return a[0] < b[1] && b[0] < a[1];
19
+ },
20
+ overlapDuration: (a, b) => {
21
+ if (!TimeRange.overlaps(a, b))
22
+ return 0;
23
+ const start = Math.max(a[0], b[0]);
24
+ const end = Math.min(a[1], b[1]);
25
+ return end - start;
26
+ },
27
+ format: (range) => {
28
+ const fmt = (s) => {
29
+ const mins = Math.floor(s / 60);
30
+ const secs = Math.floor(s % 60);
31
+ return `${mins}:${secs.toString().padStart(2, '0')}`;
32
+ };
33
+ return `${fmt(range[0])} → ${fmt(range[1])}`;
34
+ },
35
+ contains: (range, timestamp) => {
36
+ return timestamp >= range[0] && timestamp <= range[1];
37
+ },
38
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Escribano - Transcript Domain Module
3
+ */
4
+ export const Transcript = {
5
+ isEmpty: (transcript) => {
6
+ return !transcript.fullText.trim() || transcript.segments.length === 0;
7
+ },
8
+ /**
9
+ * Slice a transcript by a time range
10
+ */
11
+ sliceByTime: (transcript, timeRange) => {
12
+ const [start, end] = timeRange;
13
+ const filteredSegments = transcript.segments.filter((seg) => seg.start < end && seg.end > start);
14
+ if (filteredSegments.length === 0) {
15
+ return {
16
+ fullText: '',
17
+ segments: [],
18
+ language: transcript.language,
19
+ duration: 0,
20
+ };
21
+ }
22
+ const fullText = filteredSegments.map((s) => s.text).join(' ');
23
+ const duration = filteredSegments.length > 0
24
+ ? Math.max(0, filteredSegments[filteredSegments.length - 1].end -
25
+ filteredSegments[0].start)
26
+ : 0;
27
+ return {
28
+ fullText,
29
+ segments: filteredSegments,
30
+ language: transcript.language,
31
+ duration,
32
+ };
33
+ },
34
+ /**
35
+ * Slice tagged transcripts
36
+ */
37
+ sliceTagged: (tagged, timeRange) => {
38
+ return tagged
39
+ .map((t) => ({
40
+ source: t.source,
41
+ transcript: Transcript.sliceByTime(t.transcript, timeRange),
42
+ }))
43
+ .filter((t) => !Transcript.isEmpty(t.transcript));
44
+ },
45
+ /**
46
+ * Interleave multiple transcripts by timestamp for better LLM understanding
47
+ */
48
+ interleave: (transcripts) => {
49
+ const formatTime = (seconds) => {
50
+ const mins = Math.floor(seconds / 60);
51
+ const secs = Math.floor(seconds % 60);
52
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
53
+ };
54
+ // Collect all segments with source tags
55
+ const allSegments = transcripts.flatMap(({ source, transcript }) => transcript.segments.map((seg) => ({
56
+ ...seg,
57
+ source: source.toUpperCase(),
58
+ })));
59
+ // Sort by timestamp
60
+ allSegments.sort((a, b) => a.start - b.start);
61
+ // Create interleaved transcript
62
+ const interleavedSegments = allSegments.map((seg, index) => ({
63
+ id: `seg-${index}`,
64
+ start: seg.start,
65
+ end: seg.end,
66
+ text: `[${formatTime(seg.start)} ${seg.source}] ${seg.text}`,
67
+ speaker: seg.speaker,
68
+ }));
69
+ const fullText = interleavedSegments.map((seg) => seg.text).join('\n');
70
+ // Use the maximum duration from all transcripts
71
+ const duration = Math.max(...transcripts.map((t) => t.transcript.duration), 0);
72
+ return {
73
+ fullText,
74
+ segments: interleavedSegments,
75
+ language: transcripts[0]?.transcript.language || 'en',
76
+ duration,
77
+ };
78
+ },
79
+ };
package/dist/index.js ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Escribano CLI Entry Point
4
+ *
5
+ * Single command: process latest recording and generate summary
6
+ * Refactored to use batch-context for shared initialization logic
7
+ */
8
+ import { homedir } from 'node:os';
9
+ import path from 'node:path';
10
+ import { createCapCaptureSource } from './adapters/capture.cap.adapter.js';
11
+ import { createFilesystemCaptureSource } from './adapters/capture.filesystem.adapter.js';
12
+ import { cleanupMlxBridge, initializeSystem, processVideo, } from './batch-context.js';
13
+ import { getDbPath } from './db/index.js';
14
+ import { checkPrerequisites, hasMissingPrerequisites, printDoctorResults, } from './prerequisites.js';
15
+ const MODELS_DIR = path.join(homedir(), '.escribano', 'models');
16
+ const MODEL_FILE = 'ggml-large-v3.bin';
17
+ const MODEL_PATH = path.join(MODELS_DIR, MODEL_FILE);
18
+ function main() {
19
+ const args = parseArgs(process.argv.slice(2));
20
+ if (args.help) {
21
+ showHelp();
22
+ process.exit(0);
23
+ }
24
+ if (args.doctor) {
25
+ runDoctor().catch((error) => {
26
+ console.error('Error:', error.message);
27
+ process.exit(1);
28
+ });
29
+ return;
30
+ }
31
+ run(args).catch((error) => {
32
+ console.error('Error:', error.message);
33
+ console.error(error.stack);
34
+ process.exit(1);
35
+ });
36
+ }
37
+ async function runDoctor() {
38
+ const results = checkPrerequisites();
39
+ printDoctorResults(results);
40
+ if (hasMissingPrerequisites(results)) {
41
+ process.exit(1);
42
+ }
43
+ }
44
+ function parseArgs(argsArray) {
45
+ const fileIndex = argsArray.indexOf('--file');
46
+ const filePath = fileIndex !== -1 ? argsArray[fileIndex + 1] || null : null;
47
+ const micIndex = argsArray.indexOf('--mic-audio');
48
+ const micAudio = micIndex !== -1 ? argsArray[micIndex + 1] || null : null;
49
+ const sysIndex = argsArray.indexOf('--system-audio');
50
+ const systemAudio = sysIndex !== -1 ? argsArray[sysIndex + 1] || null : null;
51
+ const formatIndex = argsArray.indexOf('--format');
52
+ const formatValue = formatIndex !== -1 ? argsArray[formatIndex + 1] : 'card';
53
+ return {
54
+ force: argsArray.includes('--force'),
55
+ help: argsArray.includes('--help') || argsArray.includes('-h'),
56
+ doctor: argsArray[0] === 'doctor',
57
+ file: filePath,
58
+ skipSummary: argsArray.includes('--skip-summary'),
59
+ micAudio,
60
+ systemAudio,
61
+ format: formatValue === 'standup' || formatValue === 'narrative'
62
+ ? formatValue
63
+ : 'card',
64
+ includePersonal: argsArray.includes('--include-personal'),
65
+ copyToClipboard: argsArray.includes('--copy'),
66
+ printToStdout: argsArray.includes('--stdout'),
67
+ };
68
+ }
69
+ function showHelp() {
70
+ console.log(`
71
+ Escribano - Session Intelligence Tool
72
+
73
+ Usage:
74
+ npx escribano Process latest Cap recording
75
+ npx escribano doctor Check prerequisites
76
+ npx escribano --file <path> Process video from filesystem
77
+ npx escribano --file <path> --mic-audio <wav> Use external mic audio
78
+ npx escribano --file <path> --system-audio <wav> Provide system audio
79
+ npx escribano --force Reprocess from scratch
80
+ npx escribano --skip-summary Process only (no summary generation)
81
+ npx escribano --format <format> Artifact format: card (default), standup, narrative
82
+ npx escribano --include-personal Include personal time in artifact
83
+ npx escribano --copy Copy artifact to clipboard
84
+ npx escribano --stdout Print artifact to stdout
85
+ npx escribano --help Show this help
86
+
87
+ Examples:
88
+ npx escribano --file "~/Desktop/Screen Recording.mov"
89
+ npx escribano --file "/path/to/video.mp4" --mic-audio "/path/to/mic.wav"
90
+ npx escribano --file "/path/to/video.mp4" --system-audio "/path/to/system.wav"
91
+ npx escribano --format standup --stdout
92
+ npx escribano --format narrative --include-personal
93
+
94
+ Output: Markdown summary saved to ~/.escribano/artifacts/
95
+ `);
96
+ }
97
+ async function run(args) {
98
+ const { force, file: filePath, skipSummary, micAudio, systemAudio, format, includePersonal, copyToClipboard, printToStdout, } = args;
99
+ // Initialize system (reuses batch-context for consistency)
100
+ console.log('Initializing database...');
101
+ const ctx = await initializeSystem();
102
+ const { repos } = ctx;
103
+ console.log(`Database ready: ${getDbPath()}`);
104
+ console.log('');
105
+ // SIGINT handler for graceful cancellation
106
+ const sigintHandler = () => {
107
+ console.log('\nāš ļø Run cancelled.');
108
+ cleanupMlxBridge();
109
+ process.exit(130);
110
+ };
111
+ process.on('SIGINT', sigintHandler);
112
+ // Create appropriate capture source
113
+ let captureSource;
114
+ if (filePath) {
115
+ console.log(`Using filesystem source: ${filePath}`);
116
+ if (micAudio)
117
+ console.log(` Mic audio: ${micAudio}`);
118
+ if (systemAudio)
119
+ console.log(` System audio: ${systemAudio}`);
120
+ captureSource = createFilesystemCaptureSource({
121
+ videoPath: filePath,
122
+ micAudioPath: micAudio ?? undefined,
123
+ systemAudioPath: systemAudio ?? undefined,
124
+ }, ctx.adapters.video);
125
+ }
126
+ else {
127
+ console.log('Using Cap recordings source');
128
+ captureSource = createCapCaptureSource({}, ctx.adapters.video);
129
+ }
130
+ // Get recording
131
+ const recording = await captureSource.getLatestRecording();
132
+ if (!recording) {
133
+ if (filePath) {
134
+ console.log(`Failed to load video file: ${filePath}`);
135
+ }
136
+ else {
137
+ console.log('No Cap recordings found.');
138
+ }
139
+ cleanupMlxBridge();
140
+ return;
141
+ }
142
+ console.log(`Processing: ${recording.id}`);
143
+ console.log(`Duration: ${Math.round(recording.duration / 60)} minutes`);
144
+ console.log('');
145
+ // Use shared processVideo function
146
+ if (!recording.videoPath) {
147
+ console.error('Recording has no video path');
148
+ cleanupMlxBridge();
149
+ process.exit(1);
150
+ }
151
+ const result = await processVideo(recording.videoPath, ctx, {
152
+ force,
153
+ skipSummary,
154
+ micAudioPath: micAudio ?? undefined,
155
+ systemAudioPath: systemAudio ?? undefined,
156
+ format,
157
+ includePersonal,
158
+ copyToClipboard,
159
+ printToStdout,
160
+ });
161
+ // Cleanup
162
+ cleanupMlxBridge();
163
+ // Exit with appropriate code
164
+ if (!result.success) {
165
+ console.error(`\nProcessing failed: ${result.error}`);
166
+ process.exit(1);
167
+ }
168
+ console.log('\nāœ“ All done!');
169
+ if (result.outlineUrl) {
170
+ console.log(`Outline: ${result.outlineUrl}`);
171
+ }
172
+ }
173
+ main();
@@ -0,0 +1,162 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { performance } from 'node:perf_hooks';
3
+ import { generateId } from '../db/helpers.js';
4
+ import { pipelineEvents } from './events.js';
5
+ const storage = new AsyncLocalStorage();
6
+ let resourceTracker = null;
7
+ export function setResourceTracker(tracker) {
8
+ resourceTracker = tracker;
9
+ }
10
+ export function getResourceTracker() {
11
+ return resourceTracker;
12
+ }
13
+ export function withPipeline(recordingId, runType, metadata, fn) {
14
+ const verbose = process.env.ESCRIBANO_VERBOSE === 'true';
15
+ const runId = generateId();
16
+ const startTime = performance.now();
17
+ const state = {
18
+ recordingId,
19
+ runId,
20
+ runType,
21
+ steps: [],
22
+ verbose,
23
+ startTime,
24
+ };
25
+ console.log(`\nšŸš€ Pipeline: [${recordingId}] (${runType})`);
26
+ pipelineEvents.emit('run:start', {
27
+ runId,
28
+ recordingId,
29
+ runType,
30
+ timestamp: Date.now(),
31
+ metadata,
32
+ });
33
+ return storage.run(state, async () => {
34
+ try {
35
+ const result = await fn();
36
+ const durationMs = performance.now() - startTime;
37
+ pipelineEvents.emit('run:end', {
38
+ runId,
39
+ status: 'completed',
40
+ timestamp: Date.now(),
41
+ durationMs,
42
+ });
43
+ printSummary(state);
44
+ return result;
45
+ }
46
+ catch (error) {
47
+ const durationMs = performance.now() - startTime;
48
+ const errorMessage = error.message;
49
+ pipelineEvents.emit('run:end', {
50
+ runId,
51
+ status: 'failed',
52
+ timestamp: Date.now(),
53
+ durationMs,
54
+ error: errorMessage,
55
+ });
56
+ printSummary(state);
57
+ throw error;
58
+ }
59
+ });
60
+ }
61
+ export async function step(name, fn, options) {
62
+ const state = storage.getStore();
63
+ if (!state)
64
+ return fn();
65
+ const phaseId = generateId();
66
+ const start = performance.now();
67
+ if (state.verbose) {
68
+ console.log(` ā–¶ ${name}`);
69
+ }
70
+ pipelineEvents.emit('phase:start', {
71
+ runId: state.runId,
72
+ phaseId,
73
+ phase: name,
74
+ timestamp: Date.now(),
75
+ itemsTotal: options?.itemsTotal,
76
+ });
77
+ // Start resource tracking for this phase
78
+ await resourceTracker?.start();
79
+ try {
80
+ const result = await fn();
81
+ const durationMs = performance.now() - start;
82
+ state.steps.push({ name, durationMs, status: 'success' });
83
+ let itemsProcessed = result?.itemsProcessed;
84
+ if (itemsProcessed === undefined && Array.isArray(result)) {
85
+ itemsProcessed = result.length;
86
+ }
87
+ // Stop resource tracking and get stats
88
+ const resourceStats = resourceTracker?.stop();
89
+ pipelineEvents.emit('phase:end', {
90
+ runId: state.runId,
91
+ phaseId,
92
+ status: 'success',
93
+ timestamp: Date.now(),
94
+ durationMs,
95
+ itemsProcessed,
96
+ metadata: resourceStats ? { resources: resourceStats } : undefined,
97
+ });
98
+ const itemsInfo = options?.itemsTotal
99
+ ? ` (${itemsProcessed ?? options.itemsTotal}/${options.itemsTotal})`
100
+ : itemsProcessed
101
+ ? ` (${itemsProcessed})`
102
+ : '';
103
+ if (!state.verbose) {
104
+ console.log(` āœ… ${name.padEnd(30, '.')} ${(durationMs / 1000).toFixed(1)}s${itemsInfo}`);
105
+ }
106
+ else {
107
+ console.log(` āœ… ${name} completed in ${(durationMs / 1000).toFixed(1)}s${itemsInfo}`);
108
+ }
109
+ return result;
110
+ }
111
+ catch (error) {
112
+ const durationMs = performance.now() - start;
113
+ const errorMessage = error.message;
114
+ state.steps.push({
115
+ name,
116
+ durationMs,
117
+ status: 'error',
118
+ error: errorMessage,
119
+ });
120
+ // Stop resource tracking and get stats (even on error)
121
+ const resourceStats = resourceTracker?.stop();
122
+ pipelineEvents.emit('phase:end', {
123
+ runId: state.runId,
124
+ phaseId,
125
+ status: 'failed',
126
+ timestamp: Date.now(),
127
+ durationMs,
128
+ metadata: resourceStats ? { resources: resourceStats } : undefined,
129
+ });
130
+ console.error(` āŒ ${name} failed after ${(durationMs / 1000).toFixed(1)}s: ${errorMessage}`);
131
+ throw error;
132
+ }
133
+ }
134
+ export function log(level, message) {
135
+ const state = storage.getStore();
136
+ if (!state) {
137
+ if (level === 'error')
138
+ console.error(message);
139
+ else if (level === 'warn')
140
+ console.warn(message);
141
+ else
142
+ console.log(message);
143
+ return;
144
+ }
145
+ if (level === 'debug' && !state.verbose)
146
+ return;
147
+ const prefix = level === 'info' ? ' ' : ` [${level}] `;
148
+ console.log(`${prefix}${message}`);
149
+ }
150
+ function printSummary(state) {
151
+ const totalDuration = (performance.now() - state.startTime) / 1000;
152
+ console.log('─'.repeat(50));
153
+ console.log(`šŸ Pipeline Finished: [${state.recordingId}]`);
154
+ console.log(`šŸ“Š Total Duration: ${totalDuration.toFixed(1)}s`);
155
+ console.log('─'.repeat(50));
156
+ }
157
+ export function getCurrentPipeline() {
158
+ return storage.getStore();
159
+ }
160
+ export function getCurrentRunId() {
161
+ return storage.getStore()?.runId;
162
+ }
@@ -0,0 +1,2 @@
1
+ import { EventEmitter } from 'node:events';
2
+ export const pipelineEvents = new EventEmitter();