@kikedealba/recap 0.0.0-stage → 0.9.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 +388 -2
- package/Resources/ask-prompt.md +49 -0
- package/Resources/detect-prompt.md +71 -0
- package/Resources/proposals-prompt.md +44 -0
- package/Resources/summary-prompt.md +37 -0
- package/Resources/wrapup-prompt.md +32 -0
- package/commands/recap-ask.md +28 -0
- package/commands/recap-list.md +21 -0
- package/commands/recap-proposals.md +32 -0
- package/commands/recap-start.md +47 -0
- package/commands/recap-status.md +14 -0
- package/commands/recap-stop.md +69 -0
- package/commands/recap-summarize.md +31 -0
- package/dist/bin/recap.js +4 -0
- package/dist/bita/client.js +155 -0
- package/dist/bita/hook.js +81 -0
- package/dist/bita/proposals.js +419 -0
- package/dist/bita/wrapup.js +254 -0
- package/dist/capture/capture.js +101 -0
- package/dist/capture/import.js +80 -0
- package/dist/capture/recording.js +125 -0
- package/dist/cli/args.js +83 -0
- package/dist/cli/commands/ask.js +127 -0
- package/dist/cli/commands/config.js +40 -0
- package/dist/cli/commands/hook.js +94 -0
- package/dist/cli/commands/live.js +23 -0
- package/dist/cli/commands/media.js +53 -0
- package/dist/cli/commands/proposals.js +76 -0
- package/dist/cli/commands/query.js +172 -0
- package/dist/cli/commands/recording.js +102 -0
- package/dist/cli/commands/setup.js +132 -0
- package/dist/cli/output.js +24 -0
- package/dist/cli/router.js +94 -0
- package/dist/core/active.js +46 -0
- package/dist/core/config.js +77 -0
- package/dist/core/dates.js +30 -0
- package/dist/core/fsutil.js +44 -0
- package/dist/core/json.js +70 -0
- package/dist/core/live-settings.js +140 -0
- package/dist/core/lock.js +114 -0
- package/dist/core/meeting.js +201 -0
- package/dist/core/paths.js +55 -0
- package/dist/core/proc.js +129 -0
- package/dist/core/record.js +46 -0
- package/dist/core/self.js +18 -0
- package/dist/core/text.js +89 -0
- package/dist/core/tools.js +91 -0
- package/dist/errors.js +36 -0
- package/dist/live/ask.js +77 -0
- package/dist/live/asking.js +185 -0
- package/dist/live/context.js +90 -0
- package/dist/live/dedupe.js +21 -0
- package/dist/live/detector.js +498 -0
- package/dist/live/files.js +48 -0
- package/dist/live/merger.js +100 -0
- package/dist/live/origin.js +91 -0
- package/dist/live/stream.js +390 -0
- package/dist/live/worker.js +191 -0
- package/dist/media/media.js +83 -0
- package/dist/media/operations.js +131 -0
- package/dist/media/probe.js +50 -0
- package/dist/pipeline/claude.js +73 -0
- package/dist/pipeline/pipeline.js +275 -0
- package/dist/pipeline/resources.js +16 -0
- package/dist/pipeline/stages.js +27 -0
- package/dist/pipeline/summary.js +56 -0
- package/dist/pipeline/transcript.js +116 -0
- package/dist/setup/deps.js +119 -0
- package/dist/setup/integration.js +108 -0
- package/dist/setup/legacy.js +28 -0
- package/dist/version.js +6 -0
- package/package.json +57 -4
- package/skills/recap/SKILL.md +182 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { exists } from "../core/fsutil.js";
|
|
4
|
+
import { recordingPath } from "../core/meeting.js";
|
|
5
|
+
import { LIVE_DIR, liveFiles } from "../live/files.js";
|
|
6
|
+
export const VIDEO_FILE = 'recording.mov';
|
|
7
|
+
export const AUDIO_FILE = 'recording.m4a';
|
|
8
|
+
export const INTERMEDIATE_FILES = ['mic.wav', 'system.wav', 'transcript-mic.json', 'transcript-system.json'];
|
|
9
|
+
export const FRAMES_DIR = 'frames';
|
|
10
|
+
export function hasVideo(dir, mode) {
|
|
11
|
+
const file = recordingPath(dir, mode);
|
|
12
|
+
return path.basename(file) === VIDEO_FILE && exists(file);
|
|
13
|
+
}
|
|
14
|
+
export function pathSize(file) {
|
|
15
|
+
let info;
|
|
16
|
+
try {
|
|
17
|
+
info = lstatSync(file);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (info.isSymbolicLink())
|
|
23
|
+
return 0;
|
|
24
|
+
if (!info.isDirectory())
|
|
25
|
+
return info.size;
|
|
26
|
+
let total = 0;
|
|
27
|
+
let children = [];
|
|
28
|
+
try {
|
|
29
|
+
children = readdirSync(file);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
for (const child of children)
|
|
35
|
+
total += pathSize(path.join(file, child));
|
|
36
|
+
return total;
|
|
37
|
+
}
|
|
38
|
+
export function measureStorage(dir, mode) {
|
|
39
|
+
const storage = { recordingBytes: 0, intermediateBytes: 0, framesBytes: 0, otherBytes: 0, totalBytes: 0 };
|
|
40
|
+
const recording = path.basename(recordingPath(dir, mode));
|
|
41
|
+
let entries = [];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(dir);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return storage;
|
|
47
|
+
}
|
|
48
|
+
for (const name of entries) {
|
|
49
|
+
const bytes = pathSize(path.join(dir, name));
|
|
50
|
+
storage.totalBytes += bytes;
|
|
51
|
+
if (name === recording)
|
|
52
|
+
storage.recordingBytes += bytes;
|
|
53
|
+
else if (INTERMEDIATE_FILES.includes(name))
|
|
54
|
+
storage.intermediateBytes += bytes;
|
|
55
|
+
else if (name === FRAMES_DIR)
|
|
56
|
+
storage.framesBytes += bytes;
|
|
57
|
+
else if (name === LIVE_DIR) {
|
|
58
|
+
const chunks = pathSize(liveFiles.chunks(dir));
|
|
59
|
+
storage.intermediateBytes += chunks;
|
|
60
|
+
storage.otherBytes += bytes - chunks;
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
storage.otherBytes += bytes;
|
|
64
|
+
}
|
|
65
|
+
return storage;
|
|
66
|
+
}
|
|
67
|
+
export const VIDEO_PRESETS = ['light', 'medium', 'max'];
|
|
68
|
+
const PRESETS = {
|
|
69
|
+
light: { width: 1280, frameRate: '2', videoBitrate: '250k' },
|
|
70
|
+
medium: { width: 960, frameRate: '1', videoBitrate: '140k' },
|
|
71
|
+
max: { width: 720, frameRate: '0.5', videoBitrate: '70k' },
|
|
72
|
+
};
|
|
73
|
+
export function presetParameters(preset) {
|
|
74
|
+
const base = PRESETS[preset];
|
|
75
|
+
return { ...base, durationTolerance: 1 + 1 / (Number(base.frameRate) || 1) };
|
|
76
|
+
}
|
|
77
|
+
export function hevcEncoder(platform = process.platform) {
|
|
78
|
+
return platform === 'darwin' ? ['-c:v', 'hevc_videotoolbox'] : ['-c:v', 'libx265', '-preset', 'medium'];
|
|
79
|
+
}
|
|
80
|
+
export function presetVideoArguments(preset, platform = process.platform) {
|
|
81
|
+
const p = PRESETS[preset];
|
|
82
|
+
return [...hevcEncoder(platform), '-tag:v', 'hvc1', '-vf', `fps=${p.frameRate},scale='min(${p.width},iw)':-2`, '-b:v', p.videoBitrate];
|
|
83
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isoNow } from "../core/dates.js";
|
|
4
|
+
import { ProcessLock, lockOwner } from "../core/lock.js";
|
|
5
|
+
import { isActiveStatus, loadMeeting, updateMeeting } from "../core/meeting.js";
|
|
6
|
+
import { exec, isAlive } from "../core/proc.js";
|
|
7
|
+
import { formatBytes, suffix, trimmed } from "../core/text.js";
|
|
8
|
+
import { requireTool } from "../core/tools.js";
|
|
9
|
+
import { RecapError } from "../errors.js";
|
|
10
|
+
import { liveFiles } from "../live/files.js";
|
|
11
|
+
import { AUDIO_FILE, INTERMEDIATE_FILES, VIDEO_FILE, hasVideo, pathSize, presetParameters, presetVideoArguments } from "./media.js";
|
|
12
|
+
import { inspectMedia, verifyMedia } from "./probe.js";
|
|
13
|
+
export function requireIdle(meeting) {
|
|
14
|
+
if (isActiveStatus(meeting.status))
|
|
15
|
+
throw new RecapError('MEETING_ACTIVE', `"${meeting.title}" is still being recorded`);
|
|
16
|
+
}
|
|
17
|
+
export function removeIntermediates(dir) {
|
|
18
|
+
for (const name of INTERMEDIATE_FILES)
|
|
19
|
+
rmSync(path.join(dir, name), { force: true });
|
|
20
|
+
rmSync(liveFiles.chunks(dir), { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
export function enableAudio(info) {
|
|
23
|
+
return Array.from({ length: info.audioTracks }, (_, index) => [`-disposition:a:${index}`, 'default']).flat();
|
|
24
|
+
}
|
|
25
|
+
function requireVideo(meeting, dir) {
|
|
26
|
+
if (meeting.mode !== 'remote')
|
|
27
|
+
throw new RecapError('NOT_REMOTE', `"${meeting.title}" is an in-person meeting and has no video`);
|
|
28
|
+
requireIdle(meeting);
|
|
29
|
+
if (!hasVideo(dir, meeting.mode))
|
|
30
|
+
throw new RecapError('NO_VIDEO', `"${meeting.title}" has no ${VIDEO_FILE}`);
|
|
31
|
+
}
|
|
32
|
+
function replace(temporary, target) {
|
|
33
|
+
try {
|
|
34
|
+
renameSync(temporary, target);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
rmSync(temporary, { force: true });
|
|
38
|
+
throw new RecapError('RENAME_FAILED', `Cannot move ${path.basename(temporary)} to ${path.basename(target)}: ${error.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function transcode(config, source, temporary, original, expectVideo, tolerance, args) {
|
|
42
|
+
const ffmpeg = await requireTool('ffmpeg', config);
|
|
43
|
+
rmSync(temporary, { force: true });
|
|
44
|
+
try {
|
|
45
|
+
const result = await exec(ffmpeg, ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', source, ...args, '-movflags', '+faststart', temporary]);
|
|
46
|
+
if (result.status !== 0)
|
|
47
|
+
throw new RecapError('FFMPEG_FAILED', suffix(trimmed(result.stderr), 500));
|
|
48
|
+
verifyMedia(original, await inspectMedia(temporary, config), expectVideo, tolerance);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
rmSync(temporary, { force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function stripVideo(config, meeting, dir, pruneIntermediates) {
|
|
56
|
+
requireVideo(meeting, dir);
|
|
57
|
+
const lock = new ProcessLock(path.join(dir, 'process.lock'));
|
|
58
|
+
try {
|
|
59
|
+
const source = path.join(dir, VIDEO_FILE);
|
|
60
|
+
const target = path.join(dir, AUDIO_FILE);
|
|
61
|
+
const temporary = path.join(dir, '.recording-strip.m4a');
|
|
62
|
+
const original = await inspectMedia(source, config);
|
|
63
|
+
await transcode(config, source, temporary, original, false, 1, ['-map', '0:a', '-c', 'copy', ...enableAudio(original), '-f', 'mp4']);
|
|
64
|
+
replace(temporary, target);
|
|
65
|
+
rmSync(source, { force: true });
|
|
66
|
+
if (pruneIntermediates)
|
|
67
|
+
removeIntermediates(dir);
|
|
68
|
+
return updateMeeting(dir, (current) => {
|
|
69
|
+
current.videoRemovedAt = isoNow();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
lock.release();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function compressVideo(config, meeting, dir, preset, pruneIntermediates) {
|
|
77
|
+
requireVideo(meeting, dir);
|
|
78
|
+
const lock = new ProcessLock(path.join(dir, 'process.lock'));
|
|
79
|
+
try {
|
|
80
|
+
const source = path.join(dir, VIDEO_FILE);
|
|
81
|
+
const temporary = path.join(dir, '.recording-compress.mov');
|
|
82
|
+
const original = await inspectMedia(source, config);
|
|
83
|
+
const originalBytes = pathSize(source);
|
|
84
|
+
await transcode(config, source, temporary, original, true, presetParameters(preset).durationTolerance, [
|
|
85
|
+
'-map',
|
|
86
|
+
'0:v:0',
|
|
87
|
+
'-map',
|
|
88
|
+
'0:a',
|
|
89
|
+
...presetVideoArguments(preset),
|
|
90
|
+
'-c:a',
|
|
91
|
+
'copy',
|
|
92
|
+
...enableAudio(original),
|
|
93
|
+
'-f',
|
|
94
|
+
'mov',
|
|
95
|
+
]);
|
|
96
|
+
const compressedBytes = pathSize(temporary);
|
|
97
|
+
if (compressedBytes >= originalBytes) {
|
|
98
|
+
rmSync(temporary, { force: true });
|
|
99
|
+
throw new RecapError('NOT_SMALLER', `The ${preset} preset produced ${formatBytes(compressedBytes)}, not smaller than ${formatBytes(originalBytes)}; the original was kept`);
|
|
100
|
+
}
|
|
101
|
+
replace(temporary, source);
|
|
102
|
+
if (pruneIntermediates)
|
|
103
|
+
removeIntermediates(dir);
|
|
104
|
+
return updateMeeting(dir, (current) => {
|
|
105
|
+
current.video = { compressedAt: isoNow(), preset, originalBytes: current.video?.originalBytes ?? originalBytes };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
lock.release();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function pruneMeeting(meeting, dir) {
|
|
113
|
+
requireIdle(meeting);
|
|
114
|
+
const lock = new ProcessLock(path.join(dir, 'process.lock'));
|
|
115
|
+
try {
|
|
116
|
+
removeIntermediates(dir);
|
|
117
|
+
return loadMeeting(dir);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
lock.release();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function deleteMeeting(meeting, dir) {
|
|
124
|
+
requireIdle(meeting);
|
|
125
|
+
const pid = lockOwner(path.join(dir, 'process.lock'));
|
|
126
|
+
if (pid !== null && pid !== process.pid && isAlive(pid))
|
|
127
|
+
throw new RecapError('MEETING_ACTIVE', `"${meeting.title}" is being processed (pid ${pid})`);
|
|
128
|
+
const freed = pathSize(dir);
|
|
129
|
+
rmSync(dir, { recursive: true, force: true });
|
|
130
|
+
return { id: meeting.id, dir, freedBytes: freed };
|
|
131
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isRecord, parseJsonSafe } from "../core/json.js";
|
|
3
|
+
import { exec } from "../core/proc.js";
|
|
4
|
+
import { requireTool } from "../core/tools.js";
|
|
5
|
+
import { RecapError } from "../errors.js";
|
|
6
|
+
export function parseProbe(text) {
|
|
7
|
+
const value = parseJsonSafe(text);
|
|
8
|
+
if (!isRecord(value))
|
|
9
|
+
throw new Error('ffprobe returned no JSON');
|
|
10
|
+
const streams = Array.isArray(value['streams']) ? value['streams'].filter(isRecord) : [];
|
|
11
|
+
const format = isRecord(value['format']) ? value['format'] : {};
|
|
12
|
+
const audio = streams.filter((stream) => stream['codec_type'] === 'audio');
|
|
13
|
+
const video = streams.filter((stream) => stream['codec_type'] === 'video' && !(isRecord(stream['disposition']) && stream['disposition']['attached_pic'] === 1));
|
|
14
|
+
const enabled = audio.filter((stream) => !isRecord(stream['disposition']) || stream['disposition']['default'] !== 0).length;
|
|
15
|
+
const tags = isRecord(format['tags']) ? format['tags'] : {};
|
|
16
|
+
const creation = typeof tags['creation_time'] === 'string' ? tags['creation_time'] : undefined;
|
|
17
|
+
return {
|
|
18
|
+
duration: Number(format['duration'] ?? Number.NaN),
|
|
19
|
+
audioTracks: audio.length,
|
|
20
|
+
enabledAudioTracks: enabled,
|
|
21
|
+
videoTracks: video.length,
|
|
22
|
+
creationTime: creation,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function inspectMedia(file, config) {
|
|
26
|
+
const ffprobe = await requireTool('ffprobe', config);
|
|
27
|
+
const result = await exec(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);
|
|
28
|
+
if (result.status !== 0)
|
|
29
|
+
throw new RecapError('MEDIA_UNREADABLE', `Cannot read ${path.basename(file)}: ${result.stderr.trim()}`);
|
|
30
|
+
try {
|
|
31
|
+
return parseProbe(result.stdout);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
throw new RecapError('MEDIA_UNREADABLE', `Cannot read ${path.basename(file)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function verifyMedia(original, output, expectVideo, tolerance = 1) {
|
|
38
|
+
if (!Number.isFinite(output.duration) || Math.abs(output.duration - original.duration) > tolerance) {
|
|
39
|
+
throw new RecapError('VERIFY_FAILED', `The new file lasts ${output.duration.toFixed(1)}s instead of ${original.duration.toFixed(1)}s`);
|
|
40
|
+
}
|
|
41
|
+
if (output.audioTracks !== original.audioTracks) {
|
|
42
|
+
throw new RecapError('VERIFY_FAILED', `The new file has ${output.audioTracks} audio tracks instead of ${original.audioTracks}`);
|
|
43
|
+
}
|
|
44
|
+
if (output.enabledAudioTracks !== output.audioTracks) {
|
|
45
|
+
throw new RecapError('VERIFY_FAILED', `Only ${output.enabledAudioTracks} of ${output.audioTracks} audio tracks are enabled in the new file`);
|
|
46
|
+
}
|
|
47
|
+
if (output.videoTracks > 0 !== expectVideo) {
|
|
48
|
+
throw new RecapError('VERIFY_FAILED', expectVideo ? 'The new file has no video track' : 'The new file still has a video track');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isRecord, parseJsonSafe } from "../core/json.js";
|
|
2
|
+
import { exec } from "../core/proc.js";
|
|
3
|
+
import { prefix, trimmed } from "../core/text.js";
|
|
4
|
+
import { requireTool, toolEnvironment } from "../core/tools.js";
|
|
5
|
+
import { RecapError } from "../errors.js";
|
|
6
|
+
export const ISOLATION = ['--setting-sources', 'project', '--strict-mcp-config', '--no-session-persistence', '--disable-slash-commands'];
|
|
7
|
+
export function claudeOptions(tools, addDirs, model, restrictTools) {
|
|
8
|
+
const args = [...ISOLATION];
|
|
9
|
+
if (restrictTools)
|
|
10
|
+
args.push('--tools', restrictTools.join(','));
|
|
11
|
+
const allowed = trimmed(tools.join(' '));
|
|
12
|
+
if (allowed.length > 0)
|
|
13
|
+
args.push('--allowedTools', allowed);
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (const dir of addDirs) {
|
|
16
|
+
if (seen.has(dir))
|
|
17
|
+
continue;
|
|
18
|
+
seen.add(dir);
|
|
19
|
+
args.push('--add-dir', dir);
|
|
20
|
+
}
|
|
21
|
+
if (model)
|
|
22
|
+
args.push('--model', model);
|
|
23
|
+
return args;
|
|
24
|
+
}
|
|
25
|
+
export function streamArguments(tools, addDirs, model, restrictTools) {
|
|
26
|
+
return ['-p', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ...claudeOptions(tools, addDirs, model, restrictTools)];
|
|
27
|
+
}
|
|
28
|
+
export async function runClaude(call) {
|
|
29
|
+
const claude = await requireTool('claude', call.config);
|
|
30
|
+
const args = ['-p', '--output-format', 'json', ...claudeOptions(call.tools, call.addDirs, call.model, call.restrictTools)];
|
|
31
|
+
const result = await exec(claude, args, { input: call.prompt, env: toolEnvironment(claude, call.config), cwd: call.cwd });
|
|
32
|
+
if (result.status !== 0)
|
|
33
|
+
throw new RecapError('CLAUDE_FAILED', trimmed(result.stderr.length === 0 ? result.stdout : result.stderr));
|
|
34
|
+
return result.stdout;
|
|
35
|
+
}
|
|
36
|
+
export function runSummaryClaude(prompt, dir, config) {
|
|
37
|
+
return runClaude({ prompt, cwd: dir, config, tools: ['Read'], addDirs: [dir], model: config.summaryModel });
|
|
38
|
+
}
|
|
39
|
+
export async function streamClaude(prompt, cwd, config, args, onLine, onSpawn) {
|
|
40
|
+
const claude = await requireTool('claude', config);
|
|
41
|
+
let buffer = '';
|
|
42
|
+
const result = await exec(claude, args, {
|
|
43
|
+
input: prompt,
|
|
44
|
+
env: toolEnvironment(claude, config),
|
|
45
|
+
cwd,
|
|
46
|
+
detached: process.platform !== 'win32',
|
|
47
|
+
...(onSpawn ? { onSpawn } : {}),
|
|
48
|
+
onStdout: (chunk) => {
|
|
49
|
+
buffer += chunk;
|
|
50
|
+
let newline = buffer.indexOf('\n');
|
|
51
|
+
while (newline !== -1) {
|
|
52
|
+
const line = buffer.slice(0, newline);
|
|
53
|
+
buffer = buffer.slice(newline + 1);
|
|
54
|
+
if (line.length > 0)
|
|
55
|
+
onLine(line);
|
|
56
|
+
newline = buffer.indexOf('\n');
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
if (buffer.length > 0)
|
|
61
|
+
onLine(buffer);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
export function resultText(output) {
|
|
65
|
+
const parsed = parseJsonSafe(output);
|
|
66
|
+
if (!isRecord(parsed))
|
|
67
|
+
throw new RecapError('CLAUDE_OUTPUT', `Unexpected output from claude: ${prefix(output, 300)}`);
|
|
68
|
+
const result = typeof parsed['result'] === 'string' ? parsed['result'] : undefined;
|
|
69
|
+
if (parsed['is_error'] === true || result === undefined || trimmed(result).length === 0) {
|
|
70
|
+
throw new RecapError('CLAUDE_FAILED', result ?? 'claude returned an empty answer');
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
|
+
import { cpus } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { settingsOf, transcriptionLanguage, vadModelPath, whisperModelPath } from "../core/config.js";
|
|
5
|
+
import { clock, isoNow } from "../core/dates.js";
|
|
6
|
+
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
7
|
+
import { encodeJson, swiftDefault } from "../core/json.js";
|
|
8
|
+
import { ProcessLock } from "../core/lock.js";
|
|
9
|
+
import { isActiveStatus, loadMeeting, recordingPath, updateMeeting } from "../core/meeting.js";
|
|
10
|
+
import { exec } from "../core/proc.js";
|
|
11
|
+
import { suffix, trimmed } from "../core/text.js";
|
|
12
|
+
import { requireTool } from "../core/tools.js";
|
|
13
|
+
import { RecapError, errorMessage } from "../errors.js";
|
|
14
|
+
import { docsClient, meetingTarget, requireBita } from "../bita/client.js";
|
|
15
|
+
import { ProposalGenerator } from "../bita/proposals.js";
|
|
16
|
+
import { runWrapup } from "../bita/wrapup.js";
|
|
17
|
+
import { runSummaryClaude } from "./claude.js";
|
|
18
|
+
import { STAGES, allStagesSatisfied, stageApplies, stageComplete, stageIsOptional, stageSkipped } from "./stages.js";
|
|
19
|
+
import { extractSummary, saveSummary, summaryPromptFor } from "./summary.js";
|
|
20
|
+
import { mergeTranscripts, orderedSegment, parseWhisper, transcriptMarkdown } from "./transcript.js";
|
|
21
|
+
export const MAX_FRAMES = 40;
|
|
22
|
+
export function whisperThreads() {
|
|
23
|
+
return Math.max(2, Math.min(8, cpus().length - 2));
|
|
24
|
+
}
|
|
25
|
+
export function showinfoTimes(log) {
|
|
26
|
+
const times = [];
|
|
27
|
+
for (const line of log.split('\n')) {
|
|
28
|
+
if (!line.includes('Parsed_showinfo'))
|
|
29
|
+
continue;
|
|
30
|
+
const index = line.indexOf('pts_time:');
|
|
31
|
+
if (index === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const value = Number(line.slice(index + 'pts_time:'.length).split(/\s/)[0]);
|
|
34
|
+
if (Number.isFinite(value))
|
|
35
|
+
times.push(value);
|
|
36
|
+
}
|
|
37
|
+
return times;
|
|
38
|
+
}
|
|
39
|
+
export function evenlySpaced(count, limit) {
|
|
40
|
+
if (count <= limit || limit <= 1)
|
|
41
|
+
return Array.from({ length: count }, (_, index) => index);
|
|
42
|
+
return Array.from({ length: limit }, (_, index) => Math.round((index * (count - 1)) / (limit - 1)));
|
|
43
|
+
}
|
|
44
|
+
export function channelsOf(meeting) {
|
|
45
|
+
return meeting.mode === 'remote'
|
|
46
|
+
? [
|
|
47
|
+
['mic', 0],
|
|
48
|
+
['system', 1],
|
|
49
|
+
]
|
|
50
|
+
: [['mic', 0]];
|
|
51
|
+
}
|
|
52
|
+
export function whisperArguments(config, model, outputBase, wav, threads, previous) {
|
|
53
|
+
const args = ['-m', model, '-l', transcriptionLanguage(config), '-t', String(threads), '-oj', '-of', outputBase, '-np', '-sns'];
|
|
54
|
+
const prompt = [];
|
|
55
|
+
if (config.vocabulary && config.vocabulary.length > 0)
|
|
56
|
+
prompt.push(`Glosario: ${config.vocabulary.join(', ')}.`);
|
|
57
|
+
if (previous && trimmed(previous).length > 0)
|
|
58
|
+
prompt.push(trimmed(previous));
|
|
59
|
+
if (prompt.length > 0)
|
|
60
|
+
args.push('--prompt', prompt.join(' '));
|
|
61
|
+
if (existsSync(vadModelPath()))
|
|
62
|
+
args.push('--vad', '-vm', vadModelPath());
|
|
63
|
+
args.push(wav);
|
|
64
|
+
return args;
|
|
65
|
+
}
|
|
66
|
+
export class Pipeline {
|
|
67
|
+
dir;
|
|
68
|
+
config;
|
|
69
|
+
log;
|
|
70
|
+
constructor(dir, config, log) {
|
|
71
|
+
this.dir = dir;
|
|
72
|
+
this.config = config;
|
|
73
|
+
this.log = log;
|
|
74
|
+
}
|
|
75
|
+
file(name) {
|
|
76
|
+
return path.join(this.dir, name);
|
|
77
|
+
}
|
|
78
|
+
recording(meeting) {
|
|
79
|
+
return recordingPath(this.dir, meeting.mode);
|
|
80
|
+
}
|
|
81
|
+
setStage(stage, status, error, extra) {
|
|
82
|
+
return updateMeeting(this.dir, (current) => {
|
|
83
|
+
current.stages[stage] = { status, updatedAt: isoNow(), ...(error !== undefined ? { error } : {}) };
|
|
84
|
+
extra?.(current);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async run(from, only) {
|
|
88
|
+
let meeting = loadMeeting(this.dir);
|
|
89
|
+
if (isActiveStatus(meeting.status))
|
|
90
|
+
throw new RecapError('STILL_RECORDING', `"${meeting.title}" is still being recorded`);
|
|
91
|
+
if (!existsSync(this.recording(meeting)))
|
|
92
|
+
throw new RecapError('NO_RECORDING', `"${meeting.title}" has no recording`);
|
|
93
|
+
const lock = new ProcessLock(this.file('process.lock'));
|
|
94
|
+
try {
|
|
95
|
+
meeting = updateMeeting(this.dir, (current) => {
|
|
96
|
+
current.status = 'processing';
|
|
97
|
+
});
|
|
98
|
+
let forced = false;
|
|
99
|
+
for (const stage of STAGES) {
|
|
100
|
+
if (!stageApplies(stage, meeting))
|
|
101
|
+
continue;
|
|
102
|
+
if (only !== undefined && stage !== only)
|
|
103
|
+
continue;
|
|
104
|
+
if (stage === from)
|
|
105
|
+
forced = true;
|
|
106
|
+
if (stage === 'proposals' && !settingsOf(this.config).proposals) {
|
|
107
|
+
meeting = this.setStage(stage, 'skipped');
|
|
108
|
+
this.log(`${stage}: skipped, live.proposals is off`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (stageSkipped(stage, meeting, this.dir)) {
|
|
112
|
+
if (stageComplete(meeting.stages[stage])) {
|
|
113
|
+
this.log(`${stage}: already done`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
meeting = this.setStage(stage, 'skipped');
|
|
117
|
+
this.log(`${stage}: skipped, the recording has no video`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const done = meeting.stages[stage]?.status === 'done';
|
|
121
|
+
if (done && !forced && only === undefined) {
|
|
122
|
+
this.log(`${stage}: already done`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
this.log(`${stage}: running`);
|
|
126
|
+
const began = Date.now();
|
|
127
|
+
try {
|
|
128
|
+
await this.execute(stage, meeting);
|
|
129
|
+
meeting = this.setStage(stage, 'done');
|
|
130
|
+
this.log(`${stage}: done in ${Math.trunc((Date.now() - began) / 1000)}s`);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const message = error instanceof RecapError ? error.message : errorMessage(error);
|
|
134
|
+
if (stageIsOptional(stage)) {
|
|
135
|
+
meeting = this.setStage(stage, 'failed', message);
|
|
136
|
+
this.log(`${stage}: failed, continuing: ${message}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
meeting = this.setStage(stage, 'failed', message, (current) => {
|
|
140
|
+
current.status = 'recorded';
|
|
141
|
+
});
|
|
142
|
+
this.log(`${stage}: failed: ${message}`);
|
|
143
|
+
throw new RecapError('STAGE_FAILED', `${stage} failed: ${message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const complete = allStagesSatisfied(meeting);
|
|
147
|
+
return updateMeeting(this.dir, (current) => {
|
|
148
|
+
current.status = complete ? 'processed' : 'recorded';
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
lock.release();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async execute(stage, meeting) {
|
|
156
|
+
switch (stage) {
|
|
157
|
+
case 'audio':
|
|
158
|
+
return this.extractAudio(meeting);
|
|
159
|
+
case 'transcribe':
|
|
160
|
+
return this.transcribe(meeting);
|
|
161
|
+
case 'frames':
|
|
162
|
+
return this.extractFrames(meeting);
|
|
163
|
+
case 'summarize':
|
|
164
|
+
return this.summarize(meeting);
|
|
165
|
+
case 'proposals':
|
|
166
|
+
return this.proposals(meeting);
|
|
167
|
+
case 'wrapup':
|
|
168
|
+
return runWrapup(meeting, this.dir, this.config);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async extractAudio(meeting) {
|
|
172
|
+
const ffmpeg = await requireTool('ffmpeg', this.config);
|
|
173
|
+
for (const [channel, index] of channelsOf(meeting)) {
|
|
174
|
+
const result = await exec(ffmpeg, [
|
|
175
|
+
'-hide_banner',
|
|
176
|
+
'-loglevel',
|
|
177
|
+
'error',
|
|
178
|
+
'-y',
|
|
179
|
+
'-i',
|
|
180
|
+
this.recording(meeting),
|
|
181
|
+
'-map',
|
|
182
|
+
`0:a:${index}`,
|
|
183
|
+
'-ac',
|
|
184
|
+
'1',
|
|
185
|
+
'-ar',
|
|
186
|
+
'16000',
|
|
187
|
+
'-c:a',
|
|
188
|
+
'pcm_s16le',
|
|
189
|
+
this.file(`${channel}.wav`),
|
|
190
|
+
]);
|
|
191
|
+
if (result.status !== 0)
|
|
192
|
+
throw new RecapError('FFMPEG_FAILED', trimmed(result.stderr));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async transcribe(meeting) {
|
|
196
|
+
const whisper = await requireTool('whisper-cli', this.config);
|
|
197
|
+
const model = whisperModelPath(this.config);
|
|
198
|
+
if (!existsSync(model))
|
|
199
|
+
throw new RecapError('MODEL_MISSING', `Whisper model not found at ${model}. Run \`recap setup\`.`);
|
|
200
|
+
if (channelsOf(meeting).some(([channel]) => !existsSync(this.file(`${channel}.wav`))))
|
|
201
|
+
await this.extractAudio(meeting);
|
|
202
|
+
const byChannel = {};
|
|
203
|
+
for (const [channel] of channelsOf(meeting)) {
|
|
204
|
+
const base = this.file(`transcript-${channel}`);
|
|
205
|
+
const result = await exec(whisper, whisperArguments(this.config, model, base, this.file(`${channel}.wav`), whisperThreads()));
|
|
206
|
+
if (result.status !== 0)
|
|
207
|
+
throw new RecapError('WHISPER_FAILED', trimmed(result.stderr));
|
|
208
|
+
const text = readText(`${base}.json`);
|
|
209
|
+
if (text === null)
|
|
210
|
+
throw new RecapError('WHISPER_FAILED', `whisper-cli did not write ${base}.json`);
|
|
211
|
+
try {
|
|
212
|
+
byChannel[channel] = parseWhisper(text, channel);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
throw new RecapError('WHISPER_FAILED', `Cannot read ${base}.json: ${errorMessage(error)}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const segments = mergeTranscripts(byChannel.mic ?? [], byChannel.system ?? []);
|
|
219
|
+
writeAtomic(this.file('transcript.json'), encodeJson(segments.map(orderedSegment), { pretty: true }));
|
|
220
|
+
writeAtomic(this.file('transcript.md'), transcriptMarkdown(meeting, segments));
|
|
221
|
+
}
|
|
222
|
+
async extractFrames(meeting) {
|
|
223
|
+
const ffmpeg = await requireTool('ffmpeg', this.config);
|
|
224
|
+
const framesDir = this.file('frames');
|
|
225
|
+
rmSync(framesDir, { recursive: true, force: true });
|
|
226
|
+
mkdirSync(framesDir, { recursive: true });
|
|
227
|
+
const select = "select='isnan(prev_selected_t)+gt(scene\\,0.15)*gte(t-prev_selected_t\\,20)+gte(t-prev_selected_t\\,300)',showinfo";
|
|
228
|
+
const result = await exec(ffmpeg, [
|
|
229
|
+
'-hide_banner',
|
|
230
|
+
'-y',
|
|
231
|
+
'-i',
|
|
232
|
+
this.recording(meeting),
|
|
233
|
+
'-map',
|
|
234
|
+
'0:v:0',
|
|
235
|
+
'-vf',
|
|
236
|
+
select,
|
|
237
|
+
'-fps_mode',
|
|
238
|
+
'vfr',
|
|
239
|
+
'-q:v',
|
|
240
|
+
'4',
|
|
241
|
+
path.join(framesDir, 'raw-%04d.jpg'),
|
|
242
|
+
]);
|
|
243
|
+
if (result.status !== 0)
|
|
244
|
+
throw new RecapError('FFMPEG_FAILED', suffix(trimmed(result.stderr), 500));
|
|
245
|
+
const times = showinfoTimes(result.stderr);
|
|
246
|
+
const raw = readdirSync(framesDir)
|
|
247
|
+
.filter((name) => name.startsWith('raw-'))
|
|
248
|
+
.sort();
|
|
249
|
+
const keep = new Set(evenlySpaced(raw.length, MAX_FRAMES));
|
|
250
|
+
const frames = [];
|
|
251
|
+
raw.forEach((name, index) => {
|
|
252
|
+
const source = path.join(framesDir, name);
|
|
253
|
+
const time = times[index];
|
|
254
|
+
if (!keep.has(index) || time === undefined) {
|
|
255
|
+
rmSync(source, { force: true });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const target = `${clock(Math.trunc(time * 1000)).replace(/:/g, '-')}.jpg`;
|
|
259
|
+
rmSync(path.join(framesDir, target), { force: true });
|
|
260
|
+
renameSync(source, path.join(framesDir, target));
|
|
261
|
+
frames.push({ file: `frames/${target}`, timeSeconds: time });
|
|
262
|
+
});
|
|
263
|
+
writeAtomic(this.file('frames.json'), swiftDefault(frames.map((frame) => ({ file: frame.file, timeSeconds: frame.timeSeconds }))));
|
|
264
|
+
}
|
|
265
|
+
async summarize(meeting) {
|
|
266
|
+
const prompt = summaryPromptFor(meeting, this.dir);
|
|
267
|
+
const output = await runSummaryClaude(prompt, this.dir, this.config);
|
|
268
|
+
saveSummary(extractSummary(output), meeting, this.dir);
|
|
269
|
+
}
|
|
270
|
+
async proposals(meeting) {
|
|
271
|
+
const target = meetingTarget(meeting);
|
|
272
|
+
const docs = (await docsClient(this.config, target)) ?? (await requireBita(this.config, target));
|
|
273
|
+
await new ProposalGenerator(this.config, docs, this.log).run(meeting, this.dir);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readText } from "../core/fsutil.js";
|
|
3
|
+
import { configDir } from "../core/paths.js";
|
|
4
|
+
import { RecapError } from "../errors.js";
|
|
5
|
+
import { PACKAGE_ROOT } from "../version.js";
|
|
6
|
+
export function resourcePath(name) {
|
|
7
|
+
return path.join(PACKAGE_ROOT, 'Resources', name);
|
|
8
|
+
}
|
|
9
|
+
export function loadResource(name) {
|
|
10
|
+
for (const candidate of [path.join(configDir(), name), resourcePath(name)]) {
|
|
11
|
+
const text = readText(candidate);
|
|
12
|
+
if (text !== null)
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
throw new RecapError('PROMPT_MISSING', `${name} not found; reinstall recap`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { hasVideo } from "../media/media.js";
|
|
2
|
+
export const STAGES = ['audio', 'transcribe', 'frames', 'summarize', 'proposals', 'wrapup'];
|
|
3
|
+
export function isStage(value) {
|
|
4
|
+
return STAGES.includes(value);
|
|
5
|
+
}
|
|
6
|
+
export function stageApplies(stage, meeting) {
|
|
7
|
+
if (stage === 'frames')
|
|
8
|
+
return meeting.mode === 'remote';
|
|
9
|
+
if (stage === 'wrapup' || stage === 'proposals')
|
|
10
|
+
return meeting.bitaEntryId !== undefined;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
export function stageSkipped(stage, meeting, dir) {
|
|
14
|
+
return stage === 'frames' && !hasVideo(dir, meeting.mode);
|
|
15
|
+
}
|
|
16
|
+
export function stageIsOptional(stage) {
|
|
17
|
+
return stage === 'proposals';
|
|
18
|
+
}
|
|
19
|
+
export function stageComplete(state) {
|
|
20
|
+
return state?.status === 'done' || state?.status === 'skipped';
|
|
21
|
+
}
|
|
22
|
+
export function stageSatisfied(stage, state) {
|
|
23
|
+
return stageComplete(state) || (stageIsOptional(stage) && state?.status === 'failed');
|
|
24
|
+
}
|
|
25
|
+
export function allStagesSatisfied(meeting) {
|
|
26
|
+
return STAGES.filter((stage) => stageApplies(stage, meeting)).every((stage) => stageSatisfied(stage, meeting.stages[stage]));
|
|
27
|
+
}
|