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.
- package/LICENSE +21 -0
- package/README.md +297 -0
- package/dist/0_types.js +279 -0
- package/dist/actions/classify-session.js +77 -0
- package/dist/actions/create-contexts.js +44 -0
- package/dist/actions/create-topic-blocks.js +68 -0
- package/dist/actions/extract-metadata.js +24 -0
- package/dist/actions/generate-artifact-v3.js +296 -0
- package/dist/actions/generate-artifact.js +61 -0
- package/dist/actions/generate-summary-v3.js +260 -0
- package/dist/actions/outline-index.js +204 -0
- package/dist/actions/process-recording-v2.js +494 -0
- package/dist/actions/process-recording-v3.js +412 -0
- package/dist/actions/process-session.js +183 -0
- package/dist/actions/publish-summary-v3.js +303 -0
- package/dist/actions/sync-to-outline.js +196 -0
- package/dist/adapters/audio.silero.adapter.js +69 -0
- package/dist/adapters/cap.adapter.js +94 -0
- package/dist/adapters/capture.cap.adapter.js +107 -0
- package/dist/adapters/capture.filesystem.adapter.js +124 -0
- package/dist/adapters/embedding.ollama.adapter.js +141 -0
- package/dist/adapters/intelligence.adapter.js +202 -0
- package/dist/adapters/intelligence.mlx.adapter.js +395 -0
- package/dist/adapters/intelligence.ollama.adapter.js +741 -0
- package/dist/adapters/publishing.outline.adapter.js +75 -0
- package/dist/adapters/storage.adapter.js +81 -0
- package/dist/adapters/storage.fs.adapter.js +83 -0
- package/dist/adapters/transcription.whisper.adapter.js +206 -0
- package/dist/adapters/video.ffmpeg.adapter.js +405 -0
- package/dist/adapters/whisper.adapter.js +168 -0
- package/dist/batch-context.js +329 -0
- package/dist/db/helpers.js +50 -0
- package/dist/db/index.js +95 -0
- package/dist/db/migrate.js +80 -0
- package/dist/db/repositories/artifact.sqlite.js +77 -0
- package/dist/db/repositories/cluster.sqlite.js +92 -0
- package/dist/db/repositories/context.sqlite.js +75 -0
- package/dist/db/repositories/index.js +10 -0
- package/dist/db/repositories/observation.sqlite.js +70 -0
- package/dist/db/repositories/recording.sqlite.js +56 -0
- package/dist/db/repositories/subject.sqlite.js +64 -0
- package/dist/db/repositories/topic-block.sqlite.js +45 -0
- package/dist/db/types.js +4 -0
- package/dist/domain/classification.js +60 -0
- package/dist/domain/context.js +97 -0
- package/dist/domain/index.js +2 -0
- package/dist/domain/observation.js +17 -0
- package/dist/domain/recording.js +41 -0
- package/dist/domain/segment.js +93 -0
- package/dist/domain/session.js +93 -0
- package/dist/domain/time-range.js +38 -0
- package/dist/domain/transcript.js +79 -0
- package/dist/index.js +173 -0
- package/dist/pipeline/context.js +162 -0
- package/dist/pipeline/events.js +2 -0
- package/dist/prerequisites.js +226 -0
- package/dist/scripts/rebuild-index.js +53 -0
- package/dist/scripts/seed-fixtures.js +290 -0
- package/dist/services/activity-segmentation.js +333 -0
- package/dist/services/activity-segmentation.test.js +191 -0
- package/dist/services/app-normalization.js +212 -0
- package/dist/services/cluster-merge.js +69 -0
- package/dist/services/clustering.js +237 -0
- package/dist/services/debug.js +58 -0
- package/dist/services/frame-sampling.js +318 -0
- package/dist/services/signal-extraction.js +106 -0
- package/dist/services/subject-grouping.js +342 -0
- package/dist/services/temporal-alignment.js +99 -0
- package/dist/services/vlm-enrichment.js +84 -0
- package/dist/services/vlm-service.js +130 -0
- package/dist/stats/index.js +3 -0
- package/dist/stats/observer.js +65 -0
- package/dist/stats/repository.js +36 -0
- package/dist/stats/resource-tracker.js +86 -0
- package/dist/stats/types.js +1 -0
- package/dist/test-classification-prompts.js +181 -0
- package/dist/tests/cap.adapter.test.js +75 -0
- package/dist/tests/capture.cap.adapter.test.js +69 -0
- package/dist/tests/classify-session.test.js +140 -0
- package/dist/tests/db/repositories.test.js +243 -0
- package/dist/tests/domain/time-range.test.js +31 -0
- package/dist/tests/integration.test.js +84 -0
- package/dist/tests/intelligence.adapter.test.js +102 -0
- package/dist/tests/intelligence.ollama.adapter.test.js +178 -0
- package/dist/tests/process-v2.test.js +90 -0
- package/dist/tests/services/clustering.test.js +112 -0
- package/dist/tests/services/frame-sampling.test.js +152 -0
- package/dist/tests/utils/ocr.test.js +76 -0
- package/dist/tests/utils/parallel.test.js +57 -0
- package/dist/tests/visual-observer.test.js +175 -0
- package/dist/utils/id-normalization.js +15 -0
- package/dist/utils/index.js +9 -0
- package/dist/utils/model-detector.js +154 -0
- package/dist/utils/ocr.js +80 -0
- package/dist/utils/parallel.js +32 -0
- package/migrations/001_initial.sql +109 -0
- package/migrations/002_clusters.sql +41 -0
- package/migrations/003_observations_vlm_fields.sql +14 -0
- package/migrations/004_observations_unique.sql +18 -0
- package/migrations/005_processing_stats.sql +29 -0
- package/migrations/006_vlm_raw_response.sql +6 -0
- package/migrations/007_subjects.sql +23 -0
- package/migrations/008_artifacts_recording.sql +6 -0
- package/migrations/009_artifact_subjects.sql +10 -0
- package/package.json +82 -0
- package/prompts/action-items.md +55 -0
- package/prompts/blog-draft.md +54 -0
- package/prompts/blog-research.md +87 -0
- package/prompts/card.md +54 -0
- package/prompts/classify-segment.md +38 -0
- package/prompts/classify.md +37 -0
- package/prompts/code-snippets.md +163 -0
- package/prompts/extract-metadata.md +149 -0
- package/prompts/notes.md +83 -0
- package/prompts/runbook.md +123 -0
- package/prompts/standup.md +50 -0
- package/prompts/step-by-step.md +125 -0
- package/prompts/subject-grouping.md +31 -0
- package/prompts/summary-v3.md +89 -0
- package/prompts/summary.md +77 -0
- package/prompts/topic-classifier.md +24 -0
- package/prompts/topic-extract.md +13 -0
- package/prompts/vlm-batch.md +21 -0
- 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
|
+
}
|