@kikedealba/recap 0.0.0-stage → 0.9.1
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 +109 -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 +22 -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 +112 -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,109 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { which } from '@kikedealba/kit/platform';
|
|
5
|
+
import { exists, readText } from "../core/fsutil.js";
|
|
6
|
+
import { isRecord } from "../core/json.js";
|
|
7
|
+
import { home } from "../core/paths.js";
|
|
8
|
+
import { exec, spawnDetached } from "../core/proc.js";
|
|
9
|
+
import { selfCommand, stableCommand } from "../core/self.js";
|
|
10
|
+
import { trimmed } from "../core/text.js";
|
|
11
|
+
import { RecapError } from "../errors.js";
|
|
12
|
+
import { parseEnvelope } from '@kikedealba/kit/envelope';
|
|
13
|
+
export function appCandidates() {
|
|
14
|
+
const override = process.env['RECAP_APP'];
|
|
15
|
+
return [...(override ? [override] : []), path.join(home(), 'Applications', 'Recap.app'), '/Applications/Recap.app'];
|
|
16
|
+
}
|
|
17
|
+
export function findRecapApp() {
|
|
18
|
+
if (process.platform !== 'darwin')
|
|
19
|
+
return null;
|
|
20
|
+
return appCandidates().find((app) => exists(path.join(app, 'Contents', 'MacOS', 'recap')) || exists(path.join(app, 'Contents', 'MacOS', 'recap-capture'))) ?? null;
|
|
21
|
+
}
|
|
22
|
+
export async function findCapture() {
|
|
23
|
+
const app = findRecapApp();
|
|
24
|
+
if (app)
|
|
25
|
+
return { kind: 'app', app };
|
|
26
|
+
const override = process.env['RECAP_CAPTURE'];
|
|
27
|
+
if (override && exists(override))
|
|
28
|
+
return { kind: 'binary', binary: override };
|
|
29
|
+
const binary = await which('recap-capture');
|
|
30
|
+
return binary ? { kind: 'binary', binary } : null;
|
|
31
|
+
}
|
|
32
|
+
export function captureUnavailable() {
|
|
33
|
+
const hint = process.platform === 'darwin'
|
|
34
|
+
? 'Install Recap.app with `recap setup` (it downloads it from the GitHub release), or set RECAP_APP.'
|
|
35
|
+
: 'Recording is only available on macOS for now. Record with any app and process the file with `recap import <file>`.';
|
|
36
|
+
return new RecapError('CAPTURE_UNAVAILABLE', 'The recorder (recap-capture) is not available on this machine', { hint });
|
|
37
|
+
}
|
|
38
|
+
export async function liveWorkerCommand(ctx) {
|
|
39
|
+
return [...(await stableCommand(ctx)), 'live-worker'];
|
|
40
|
+
}
|
|
41
|
+
export function workerPath(command, env = process.env) {
|
|
42
|
+
if (command[0] === selfCommand()[0])
|
|
43
|
+
return null;
|
|
44
|
+
return [path.dirname(process.execPath), ...(env['PATH'] ?? '').split(path.delimiter)].filter((dir, index, all) => dir.length > 0 && all.indexOf(dir) === index).join(path.delimiter);
|
|
45
|
+
}
|
|
46
|
+
export function hasCaptureBinary(app) {
|
|
47
|
+
return exists(path.join(app, 'Contents', 'MacOS', 'recap-capture'));
|
|
48
|
+
}
|
|
49
|
+
export async function launchArguments(app, dir, log, modern = hasCaptureBinary(app), ctx) {
|
|
50
|
+
if (!modern)
|
|
51
|
+
return ['-g', '-n', '-a', app, '--stdout', log, '--stderr', log, '--args', 'record', dir];
|
|
52
|
+
const worker = await liveWorkerCommand(ctx);
|
|
53
|
+
const searchPath = workerPath(worker, ctx?.env);
|
|
54
|
+
const env = ['--env', `RECAP_LIVE_WORKER=${JSON.stringify(worker)}`, ...(searchPath === null ? [] : ['--env', `PATH=${searchPath}`])];
|
|
55
|
+
return ['-g', '-n', '-a', app, ...env, '--stdout', log, '--stderr', log, '--args', 'capture', 'record', dir];
|
|
56
|
+
}
|
|
57
|
+
export async function launchRecorder(dir) {
|
|
58
|
+
const launcher = await findCapture();
|
|
59
|
+
if (!launcher)
|
|
60
|
+
throw captureUnavailable();
|
|
61
|
+
const log = path.join(dir, 'recorder.log');
|
|
62
|
+
if (launcher.kind === 'app') {
|
|
63
|
+
const result = await exec('/usr/bin/open', await launchArguments(launcher.app, dir, log));
|
|
64
|
+
if (result.status !== 0)
|
|
65
|
+
throw new RecapError('LAUNCH_FAILED', `Cannot launch ${path.basename(launcher.app)}: ${trimmed(result.stderr)}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
spawnDetached(launcher.binary, ['record', dir, '--live-worker', JSON.stringify(await liveWorkerCommand())], log);
|
|
69
|
+
}
|
|
70
|
+
function readReport(text) {
|
|
71
|
+
if (text === null)
|
|
72
|
+
return null;
|
|
73
|
+
const envelope = parseEnvelope(text);
|
|
74
|
+
const data = envelope?.data ?? (() => {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
if (!isRecord(data) || typeof data['microphone'] !== 'string' || typeof data['screen'] !== 'string')
|
|
83
|
+
return null;
|
|
84
|
+
return { microphone: data['microphone'], screen: data['screen'] };
|
|
85
|
+
}
|
|
86
|
+
export async function capturePermissions(launcher, request) {
|
|
87
|
+
const args = ['permissions', ...(request ? ['--request'] : []), '--json'];
|
|
88
|
+
if (launcher.kind === 'binary') {
|
|
89
|
+
const result = await exec(launcher.binary, args);
|
|
90
|
+
const report = readReport(result.stdout);
|
|
91
|
+
if (!report)
|
|
92
|
+
throw new RecapError('PERMISSIONS_UNKNOWN', `recap-capture did not report its permissions. ${trimmed(result.stderr)}`);
|
|
93
|
+
return report;
|
|
94
|
+
}
|
|
95
|
+
const work = mkdtempSync(path.join(tmpdir(), 'recap-permissions-'));
|
|
96
|
+
try {
|
|
97
|
+
const out = path.join(work, 'out.json');
|
|
98
|
+
const err = path.join(work, 'err.log');
|
|
99
|
+
const tail = hasCaptureBinary(launcher.app) ? ['--stdout', out, '--stderr', err, '--args', 'capture', ...args] : ['--stderr', err, '--args', 'permissions', '--out', out, ...(request ? [] : ['--check'])];
|
|
100
|
+
const result = await exec('/usr/bin/open', ['-W', '-g', '-n', '-a', launcher.app, ...tail]);
|
|
101
|
+
const report = readReport(readText(out));
|
|
102
|
+
if (!report)
|
|
103
|
+
throw new RecapError('PERMISSIONS_UNKNOWN', `Recap.app did not report its permissions. ${trimmed(result.stderr + (readText(err) ?? ''))}`);
|
|
104
|
+
return report;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
rmSync(work, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { rmSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isoDate } from "../core/dates.js";
|
|
4
|
+
import { MeetingStore, updateMeeting } from "../core/meeting.js";
|
|
5
|
+
import { exec } from "../core/proc.js";
|
|
6
|
+
import { suffix, trimmed } from "../core/text.js";
|
|
7
|
+
import { requireTool } from "../core/tools.js";
|
|
8
|
+
import { RecapError } from "../errors.js";
|
|
9
|
+
import { AUDIO_FILE, VIDEO_FILE } from "../media/media.js";
|
|
10
|
+
import { inspectMedia } from "../media/probe.js";
|
|
11
|
+
export function importMode(info, requested) {
|
|
12
|
+
if (requested === 'remote' && info.audioTracks < 2) {
|
|
13
|
+
throw new RecapError('IMPORT_UNSUITABLE', 'A remote meeting needs two audio tracks (microphone and call audio); import it as --in-person');
|
|
14
|
+
}
|
|
15
|
+
if (requested)
|
|
16
|
+
return requested;
|
|
17
|
+
return info.videoTracks > 0 && info.audioTracks >= 2 ? 'remote' : 'in-person';
|
|
18
|
+
}
|
|
19
|
+
export function importArguments(source, mode, target) {
|
|
20
|
+
const base = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', source];
|
|
21
|
+
if (mode === 'remote' && target.endsWith('.mov'))
|
|
22
|
+
return [...base, '-map', '0:v:0', '-map', '0:a', '-c', 'copy', '-f', 'mov', target];
|
|
23
|
+
if (mode === 'remote')
|
|
24
|
+
return [...base, '-vn', '-map', '0:a', '-c:a', 'aac', '-b:a', '128k', '-f', 'mp4', target];
|
|
25
|
+
return [...base, '-vn', '-map', '0:a:0', '-c:a', 'aac', '-b:a', '128k', '-f', 'mp4', target];
|
|
26
|
+
}
|
|
27
|
+
export function importTimes(info, file) {
|
|
28
|
+
const duration = Number.isFinite(info.duration) ? info.duration : 0;
|
|
29
|
+
const tagged = info.creationTime ? Date.parse(info.creationTime) : Number.NaN;
|
|
30
|
+
let started;
|
|
31
|
+
if (!Number.isNaN(tagged) && tagged > 0)
|
|
32
|
+
started = tagged;
|
|
33
|
+
else {
|
|
34
|
+
let modified = Date.now();
|
|
35
|
+
try {
|
|
36
|
+
modified = statSync(file).mtimeMs;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
modified = Date.now();
|
|
40
|
+
}
|
|
41
|
+
started = modified - duration * 1000;
|
|
42
|
+
}
|
|
43
|
+
const startedAt = new Date(Math.round(started / 1000) * 1000);
|
|
44
|
+
return { startedAt, endedAt: new Date(startedAt.getTime() + Math.round(duration) * 1000) };
|
|
45
|
+
}
|
|
46
|
+
export async function importRecording(config, file, options) {
|
|
47
|
+
const source = path.resolve(file);
|
|
48
|
+
try {
|
|
49
|
+
if (!statSync(source).isFile())
|
|
50
|
+
throw new Error('not a file');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new RecapError('FILE_NOT_FOUND', `${file} is not a readable file`);
|
|
54
|
+
}
|
|
55
|
+
const info = await inspectMedia(source, config);
|
|
56
|
+
if (info.audioTracks === 0)
|
|
57
|
+
throw new RecapError('IMPORT_UNSUITABLE', `${path.basename(source)} has no audio track`);
|
|
58
|
+
const mode = importMode(info, options.mode);
|
|
59
|
+
const ffmpeg = await requireTool('ffmpeg', config);
|
|
60
|
+
const { startedAt, endedAt } = importTimes(info, source);
|
|
61
|
+
const title = options.title && trimmed(options.title).length > 0 ? trimmed(options.title) : path.basename(source, path.extname(source));
|
|
62
|
+
const created = new MeetingStore(config).create({ title, mode, now: startedAt });
|
|
63
|
+
try {
|
|
64
|
+
const target = path.join(created.dir, mode === 'remote' && info.videoTracks > 0 ? VIDEO_FILE : AUDIO_FILE);
|
|
65
|
+
const result = await exec(ffmpeg, importArguments(source, mode, target));
|
|
66
|
+
if (result.status !== 0)
|
|
67
|
+
throw new RecapError('FFMPEG_FAILED', suffix(trimmed(result.stderr), 500));
|
|
68
|
+
const meeting = updateMeeting(created.dir, (item) => {
|
|
69
|
+
item.status = 'recorded';
|
|
70
|
+
item.startedAt = isoDate(startedAt);
|
|
71
|
+
item.endedAt = isoDate(endedAt);
|
|
72
|
+
item['importedFrom'] = source;
|
|
73
|
+
});
|
|
74
|
+
return { meeting, dir: created.dir };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
rmSync(created.dir, { recursive: true, force: true });
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { loadConfig } from "../core/config.js";
|
|
3
|
+
import { clearActive, currentRecording, loadActive, saveActive } from "../core/active.js";
|
|
4
|
+
import { isoNow } from "../core/dates.js";
|
|
5
|
+
import { exists } from "../core/fsutil.js";
|
|
6
|
+
import { MeetingStore, isActiveStatus, loadMeeting, recordingPath, updateMeeting } from "../core/meeting.js";
|
|
7
|
+
import { isAlive, signalProcess, waitUntil } from "../core/proc.js";
|
|
8
|
+
import { RecapError } from "../errors.js";
|
|
9
|
+
import { launchRecorder } from "./capture.js";
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
export function defaultTitle(mode) {
|
|
12
|
+
return mode === 'remote' ? 'Remote meeting' : 'In-person meeting';
|
|
13
|
+
}
|
|
14
|
+
export async function startRecording(options) {
|
|
15
|
+
const current = currentRecording();
|
|
16
|
+
if (current)
|
|
17
|
+
throw new RecapError('ALREADY_RECORDING', `"${current.meeting.title}" is already being recorded. Stop it first.`);
|
|
18
|
+
const config = options.config ?? loadConfig();
|
|
19
|
+
const store = new MeetingStore(config);
|
|
20
|
+
const { meeting, dir } = store.create({
|
|
21
|
+
title: options.title.length === 0 ? defaultTitle(options.mode) : options.title,
|
|
22
|
+
mode: options.mode,
|
|
23
|
+
display: options.display,
|
|
24
|
+
bitaEntryId: options.bitaEntryId,
|
|
25
|
+
bita: options.bita,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
await launchRecorder(dir);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
let latest = meeting;
|
|
35
|
+
const settled = await waitUntil(options.timeout, 0.25, () => {
|
|
36
|
+
try {
|
|
37
|
+
latest = loadMeeting(dir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return latest.status !== 'starting';
|
|
43
|
+
});
|
|
44
|
+
if (latest.status === 'failed') {
|
|
45
|
+
throw new RecapError('RECORDER_FAILED', latest.error ?? `The recorder failed to start. See ${path.join(dir, 'recorder.log')}`);
|
|
46
|
+
}
|
|
47
|
+
if (!settled || latest.status !== 'recording') {
|
|
48
|
+
if (latest.recorderPid !== undefined)
|
|
49
|
+
signalProcess(latest.recorderPid, 'SIGKILL');
|
|
50
|
+
try {
|
|
51
|
+
updateMeeting(dir, (item) => {
|
|
52
|
+
item.status = 'failed';
|
|
53
|
+
item.error = `The recorder did not confirm within ${Math.trunc(options.timeout)}s`;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
undefined;
|
|
58
|
+
}
|
|
59
|
+
throw new RecapError('RECORDER_TIMEOUT', `The recorder did not start within ${Math.trunc(options.timeout)}s. Check the permissions with \`recap setup\`.`);
|
|
60
|
+
}
|
|
61
|
+
saveActive({ meetingId: latest.id, dir, mode: options.mode, startedAt: latest.startedAt ?? isoNow() });
|
|
62
|
+
return { meeting: latest, dir };
|
|
63
|
+
}
|
|
64
|
+
async function stopRecorder(meeting, dir, timeout) {
|
|
65
|
+
const pid = meeting.recorderPid;
|
|
66
|
+
if (pid === undefined)
|
|
67
|
+
throw new RecapError('RECORDER_UNKNOWN', `The recorder process for "${meeting.title}" is unknown`);
|
|
68
|
+
signalProcess(pid, 'SIGINT');
|
|
69
|
+
let current = meeting;
|
|
70
|
+
const stopped = await waitUntil(timeout, 0.25, () => {
|
|
71
|
+
try {
|
|
72
|
+
current = loadMeeting(dir);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return !isAlive(pid);
|
|
76
|
+
}
|
|
77
|
+
return current.status === 'recorded' || current.status === 'failed' || !isAlive(pid);
|
|
78
|
+
});
|
|
79
|
+
if (!stopped)
|
|
80
|
+
signalProcess(pid, 'SIGKILL');
|
|
81
|
+
if (isActiveStatus(current.status)) {
|
|
82
|
+
current = updateMeeting(dir, (item) => {
|
|
83
|
+
item.status = exists(recordingPath(dir, item.mode)) ? 'recorded' : 'failed';
|
|
84
|
+
item.endedAt = isoNow();
|
|
85
|
+
delete item.recorderPid;
|
|
86
|
+
item.error = 'The recorder exited without closing the file cleanly';
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
export async function stopRecording(timeout) {
|
|
92
|
+
const current = currentRecording();
|
|
93
|
+
if (!current)
|
|
94
|
+
throw new RecapError('NOT_RECORDING', 'There is no active recording');
|
|
95
|
+
const meeting = await stopRecorder(current.meeting, current.active.dir, timeout);
|
|
96
|
+
clearActive();
|
|
97
|
+
return { meeting, dir: current.active.dir };
|
|
98
|
+
}
|
|
99
|
+
export async function discardRecording(reference, bitaEntryId) {
|
|
100
|
+
const store = new MeetingStore(loadConfig());
|
|
101
|
+
let target;
|
|
102
|
+
if (bitaEntryId !== undefined) {
|
|
103
|
+
const found = store.find(bitaEntryId);
|
|
104
|
+
if (!found)
|
|
105
|
+
throw new RecapError('MEETING_NOT_FOUND', `No meeting is linked to bita entry ${bitaEntryId}`);
|
|
106
|
+
target = found;
|
|
107
|
+
}
|
|
108
|
+
else if (reference !== undefined) {
|
|
109
|
+
target = store.resolve(reference);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const current = currentRecording();
|
|
113
|
+
if (!current)
|
|
114
|
+
throw new RecapError('NOT_RECORDING', 'There is no active recording. Pass the meeting to delete.');
|
|
115
|
+
target = { meeting: current.meeting, dir: current.active.dir };
|
|
116
|
+
}
|
|
117
|
+
const { meeting, dir } = target;
|
|
118
|
+
if (meeting.recorderPid !== undefined && isAlive(meeting.recorderPid)) {
|
|
119
|
+
await stopRecorder(meeting, dir, 15).catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
if (loadActive()?.meetingId === meeting.id)
|
|
122
|
+
clearActive();
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
return { meeting, dir };
|
|
125
|
+
}
|
package/dist/cli/args.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { usageError } from "../errors.js";
|
|
2
|
+
export class Args {
|
|
3
|
+
positionals;
|
|
4
|
+
flags;
|
|
5
|
+
booleans;
|
|
6
|
+
constructor(positionals, flags, booleans) {
|
|
7
|
+
this.positionals = positionals;
|
|
8
|
+
this.flags = flags;
|
|
9
|
+
this.booleans = booleans;
|
|
10
|
+
}
|
|
11
|
+
has(name) {
|
|
12
|
+
return this.booleans.has(name);
|
|
13
|
+
}
|
|
14
|
+
value(name) {
|
|
15
|
+
return this.flags.get(name);
|
|
16
|
+
}
|
|
17
|
+
int(name) {
|
|
18
|
+
const raw = this.flags.get(name);
|
|
19
|
+
if (raw === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (!/^[+-]?\d+$/.test(raw.trim()))
|
|
22
|
+
throw usageError(`--${name} expects a whole number, got "${raw}"`);
|
|
23
|
+
return Number(raw.trim());
|
|
24
|
+
}
|
|
25
|
+
number(name) {
|
|
26
|
+
const raw = this.flags.get(name);
|
|
27
|
+
if (raw === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
const value = Number(raw);
|
|
30
|
+
if (raw.trim().length === 0 || !Number.isFinite(value))
|
|
31
|
+
throw usageError(`--${name} expects a number, got "${raw}"`);
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
get json() {
|
|
35
|
+
return this.booleans.has('json');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const GLOBAL_BOOLEANS = ['json', 'help'];
|
|
39
|
+
export function parseArgs(argv, spec) {
|
|
40
|
+
const booleanNames = new Set([...GLOBAL_BOOLEANS, ...(spec.booleans ?? [])]);
|
|
41
|
+
const valueNames = new Set(spec.values ?? []);
|
|
42
|
+
const positionals = [];
|
|
43
|
+
const flags = new Map();
|
|
44
|
+
const booleans = new Set();
|
|
45
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
46
|
+
const arg = argv[index] ?? '';
|
|
47
|
+
if (arg === '--') {
|
|
48
|
+
positionals.push(...argv.slice(index + 1));
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if (arg === '-h') {
|
|
52
|
+
booleans.add('help');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (arg.startsWith('--') && arg.length > 2) {
|
|
56
|
+
const body = arg.slice(2);
|
|
57
|
+
const eq = body.indexOf('=');
|
|
58
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
59
|
+
if (booleanNames.has(name)) {
|
|
60
|
+
if (eq !== -1)
|
|
61
|
+
throw usageError(`--${name} does not take a value`);
|
|
62
|
+
booleans.add(name);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!valueNames.has(name))
|
|
66
|
+
throw usageError(`Unknown option --${name}`);
|
|
67
|
+
if (eq !== -1) {
|
|
68
|
+
flags.set(name, body.slice(eq + 1));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const next = argv[index + 1];
|
|
72
|
+
if (next === undefined)
|
|
73
|
+
throw usageError(`--${name} needs a value`);
|
|
74
|
+
flags.set(name, next);
|
|
75
|
+
index += 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
positionals.push(arg);
|
|
79
|
+
}
|
|
80
|
+
if (spec.maxPositionals !== undefined && positionals.length > spec.maxPositionals)
|
|
81
|
+
throw usageError(`Unexpected argument "${positionals[spec.maxPositionals]}"`);
|
|
82
|
+
return new Args(positionals, flags, booleans);
|
|
83
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { currentRecording } from "../../core/active.js";
|
|
2
|
+
import { loadConfig } from "../../core/config.js";
|
|
3
|
+
import { isoNow } from "../../core/dates.js";
|
|
4
|
+
import { MeetingStore, loadMeeting, resolveTarget } from "../../core/meeting.js";
|
|
5
|
+
import { signalNumber, signalTree } from "../../core/proc.js";
|
|
6
|
+
import { trimmed } from "../../core/text.js";
|
|
7
|
+
import { RecapError, asRecapError, usageError } from "../../errors.js";
|
|
8
|
+
import { BitaClient, docsClient, meetingTarget } from "../../bita/client.js";
|
|
9
|
+
import { runAsk } from "../../live/ask.js";
|
|
10
|
+
import { isValidAskId, makeAskId, performAsk, removeBoard } from "../../live/asking.js";
|
|
11
|
+
import { loadProjectContext, meetingProject, meetingProjectName } from "../../live/context.js";
|
|
12
|
+
import { jsonLine } from "../../live/files.js";
|
|
13
|
+
import { encodeAnswer, encodeAskEvent } from "../../live/stream.js";
|
|
14
|
+
import { parseArgs } from "../args.js";
|
|
15
|
+
import { output } from "../output.js";
|
|
16
|
+
function target(args, config) {
|
|
17
|
+
const dir = args.value('dir');
|
|
18
|
+
if (dir !== undefined)
|
|
19
|
+
return { meeting: loadMeeting(dir), dir };
|
|
20
|
+
if (args.has('active')) {
|
|
21
|
+
const current = currentRecording();
|
|
22
|
+
if (!current)
|
|
23
|
+
throw new RecapError('NOT_RECORDING', 'There is no active recording');
|
|
24
|
+
return { meeting: current.meeting, dir: current.active.dir };
|
|
25
|
+
}
|
|
26
|
+
return resolveTarget(new MeetingStore(config), args.value('meeting'), args.int('bita-entry'));
|
|
27
|
+
}
|
|
28
|
+
function validate(args) {
|
|
29
|
+
if (args.has('sources')) {
|
|
30
|
+
if (!args.value('project') && !args.has('active') && !args.value('meeting') && args.value('bita-entry') === undefined) {
|
|
31
|
+
throw usageError('--sources needs --project, --active, --meeting or --bita-entry');
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const targets = [args.has('active'), args.value('meeting') !== undefined, args.value('bita-entry') !== undefined, args.value('dir') !== undefined].filter(Boolean).length;
|
|
36
|
+
if (targets !== 1)
|
|
37
|
+
throw usageError('Choose one of --active, --meeting or --bita-entry');
|
|
38
|
+
const window = args.int('window');
|
|
39
|
+
if (window !== undefined && window <= 0)
|
|
40
|
+
throw usageError('--window must be positive');
|
|
41
|
+
if (args.has('auto') && trimmed(args.value('question') ?? '').length === 0)
|
|
42
|
+
throw usageError('--auto needs --question');
|
|
43
|
+
const questionMs = args.int('question-ms');
|
|
44
|
+
if (questionMs !== undefined && questionMs < 0)
|
|
45
|
+
throw usageError('--question-ms must not be negative');
|
|
46
|
+
const channel = args.value('channel');
|
|
47
|
+
if (channel !== undefined && channel !== 'mic' && channel !== 'system')
|
|
48
|
+
throw usageError('--channel takes mic or system');
|
|
49
|
+
const askId = args.value('ask-id');
|
|
50
|
+
if (askId !== undefined && !isValidAskId(askId))
|
|
51
|
+
throw usageError('--ask-id takes lowercase letters, digits and dashes');
|
|
52
|
+
}
|
|
53
|
+
const SPEC = {
|
|
54
|
+
booleans: ['active', 'auto', 'sources', 'json-stream'],
|
|
55
|
+
values: ['meeting', 'bita-entry', 'dir', 'question-ms', 'channel', 'ask-id', 'question', 'window', 'project'],
|
|
56
|
+
maxPositionals: 0,
|
|
57
|
+
};
|
|
58
|
+
function installCleanup(dir, id, child) {
|
|
59
|
+
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
|
|
60
|
+
process.on(signal, () => {
|
|
61
|
+
const pid = child();
|
|
62
|
+
if (pid !== null)
|
|
63
|
+
signalTree(pid, 'SIGKILL');
|
|
64
|
+
removeBoard(dir, id);
|
|
65
|
+
process.exit(128 + signalNumber(signal));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function answer(args, emit) {
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const found = target(args, config);
|
|
72
|
+
const id = args.value('ask-id') ?? makeAskId();
|
|
73
|
+
const now = isoNow();
|
|
74
|
+
const question = args.value('question');
|
|
75
|
+
const questionMs = args.int('question-ms');
|
|
76
|
+
const channel = args.value('channel');
|
|
77
|
+
let childPid = null;
|
|
78
|
+
installCleanup(found.dir, id, () => childPid);
|
|
79
|
+
return performAsk({ dir: found.dir, id, question, auto: args.has('auto'), now, questionMs, channel }, async () => {
|
|
80
|
+
const docs = await docsClient(config, meetingTarget(found.meeting));
|
|
81
|
+
const context = await loadProjectContext(await meetingProject(found.meeting, docs), found.meeting.bitaDocsRoot, docs);
|
|
82
|
+
return runAsk(config, { meeting: found.meeting, dir: found.dir, question, windowSeconds: args.int('window') ?? 180, now, auto: args.has('auto'), questionMs, channel, askId: id }, context, emit, (pid) => {
|
|
83
|
+
childPid = pid;
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function askCommand(argv) {
|
|
88
|
+
const json = argv.includes('--json');
|
|
89
|
+
if (argv.includes('--json-stream')) {
|
|
90
|
+
try {
|
|
91
|
+
const args = parseArgs(argv, SPEC);
|
|
92
|
+
validate(args);
|
|
93
|
+
await answer(args, (event) => process.stdout.write(`${jsonLine(encodeAskEvent(event))}\n`));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const failure = asRecapError(error);
|
|
98
|
+
process.stdout.write(`${jsonLine(encodeAskEvent({ type: 'error', code: failure.code, message: failure.message }))}\n`);
|
|
99
|
+
return failure.exitCode;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return output('ask', json, async () => {
|
|
103
|
+
const args = parseArgs(argv, SPEC);
|
|
104
|
+
validate(args);
|
|
105
|
+
if (args.has('sources')) {
|
|
106
|
+
const config = loadConfig();
|
|
107
|
+
const found = args.has('active') || args.value('meeting') !== undefined || args.value('bita-entry') !== undefined ? target(args, config) : null;
|
|
108
|
+
const docs = found ? await docsClient(config, meetingTarget(found.meeting)) : await docsClient(config, {});
|
|
109
|
+
const name = args.value('project') ?? (found ? meetingProjectName(found.meeting) : null);
|
|
110
|
+
const context = await loadProjectContext(name, found?.meeting.bitaDocsRoot, docs ?? (await BitaClient.create(config, {})));
|
|
111
|
+
const lines = [`Project: ${context.project ?? '(none)'}`, `Docs: ${context.docsRoot ?? '(unknown)'}`, ...context.repos.map((repo) => `Repo: ${repo.slug} ${repo.path}${repo.exists ? '' : ' (missing)'}`)];
|
|
112
|
+
return { data: { project: context.project ?? undefined, docsRoot: context.docsRoot ?? undefined, repos: context.repos }, text: lines.join('\n') };
|
|
113
|
+
}
|
|
114
|
+
const result = await answer(args, (event) => {
|
|
115
|
+
if (json)
|
|
116
|
+
return;
|
|
117
|
+
if (event.type === 'delta')
|
|
118
|
+
process.stdout.write(event.text);
|
|
119
|
+
else if (event.type === 'progress')
|
|
120
|
+
process.stderr.write(`· ${event.text}\n`);
|
|
121
|
+
});
|
|
122
|
+
let text = json ? '' : '\n';
|
|
123
|
+
if (result.sources.length > 0)
|
|
124
|
+
text += `\nFuentes:\n${result.sources.map((source) => `- ${source.label}`).join('\n')}`;
|
|
125
|
+
return { data: encodeAnswer(result), text: text.replace(/^\n+|\n+$/g, '').length === 0 ? '' : text };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { loadConfig, saveConfig } from "../../core/config.js";
|
|
2
|
+
import { CONFIG_KEYS, applyConfigValue, configSnapshot, configValue, configValueText, parseConfigKey } from "../../core/live-settings.js";
|
|
3
|
+
import { configFile } from "../../core/paths.js";
|
|
4
|
+
import { usageError } from "../../errors.js";
|
|
5
|
+
import { parseArgs } from "../args.js";
|
|
6
|
+
import { output } from "../output.js";
|
|
7
|
+
export async function configCommand(argv) {
|
|
8
|
+
const [sub, ...rest] = argv;
|
|
9
|
+
if (sub === 'get') {
|
|
10
|
+
return output('config get', rest.includes('--json'), () => {
|
|
11
|
+
const args = parseArgs(rest, { maxPositionals: 1 });
|
|
12
|
+
const config = loadConfig();
|
|
13
|
+
const settings = configSnapshot(config.live);
|
|
14
|
+
const raw = args.positionals[0];
|
|
15
|
+
if (raw !== undefined) {
|
|
16
|
+
const key = parseConfigKey(raw);
|
|
17
|
+
const value = configValue(key, config.live);
|
|
18
|
+
return { data: { path: configFile(), key, value, settings }, text: configValueText(value) };
|
|
19
|
+
}
|
|
20
|
+
return { data: { path: configFile(), settings }, text: CONFIG_KEYS.map((key) => `${key} = ${configValueText(settings[key] ?? null)}`).join('\n') };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
if (sub === 'set') {
|
|
24
|
+
return output('config set', rest.includes('--json'), () => {
|
|
25
|
+
const args = parseArgs(rest, { maxPositionals: 2 });
|
|
26
|
+
const [rawKey, raw] = args.positionals;
|
|
27
|
+
if (rawKey === undefined || raw === undefined)
|
|
28
|
+
throw usageError('Usage: recap config set <key> <value>');
|
|
29
|
+
const config = loadConfig();
|
|
30
|
+
const key = parseConfigKey(rawKey);
|
|
31
|
+
config.live = applyConfigValue(key, raw, config.live);
|
|
32
|
+
saveConfig(config);
|
|
33
|
+
const value = configValue(key, config.live);
|
|
34
|
+
return { data: { path: configFile(), key, value, settings: configSnapshot(config.live) }, text: `${key} = ${configValueText(value)}` };
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return output('config', argv.includes('--json'), () => {
|
|
38
|
+
throw usageError('Usage: recap config get [key] | recap config set <key> <value>', `Keys: ${CONFIG_KEYS.join(', ')}`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { currentRecording } from "../../core/active.js";
|
|
3
|
+
import { loadConfig } from "../../core/config.js";
|
|
4
|
+
import { isoNow } from "../../core/dates.js";
|
|
5
|
+
import { MeetingStore, updateMeeting } from "../../core/meeting.js";
|
|
6
|
+
import { processInBackground } from "../../core/self.js";
|
|
7
|
+
import { RecapError, describeError, errorMessage } from "../../errors.js";
|
|
8
|
+
import { decodeHookEvent, describeAction, eventSnapshot, eventTarget, planHook } from "../../bita/hook.js";
|
|
9
|
+
import { discardRecording, startRecording, stopRecording } from "../../capture/recording.js";
|
|
10
|
+
function log(message) {
|
|
11
|
+
process.stdout.write(`${isoNow()} recap bita-hook: ${message}\n`);
|
|
12
|
+
}
|
|
13
|
+
export async function handleHookEvent(event) {
|
|
14
|
+
const active = currentRecording();
|
|
15
|
+
const action = planHook(event, active?.meeting.bitaEntryId);
|
|
16
|
+
log(`${event.event} #${event.entry.id} kind=${event.entry.kind ?? '-'}: ${describeAction(action)}`);
|
|
17
|
+
try {
|
|
18
|
+
switch (action.type) {
|
|
19
|
+
case 'start': {
|
|
20
|
+
const record = await startRecording({ title: event.entry.description, mode: action.mode, bitaEntryId: event.entry.id, bita: eventTarget(event), timeout: 90 });
|
|
21
|
+
updateMeeting(record.dir, (meeting) => {
|
|
22
|
+
meeting.bitaEntry = eventSnapshot(event);
|
|
23
|
+
});
|
|
24
|
+
log(`recording ${record.dir}`);
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case 'stopAndProcess': {
|
|
28
|
+
const record = await stopRecording(60);
|
|
29
|
+
updateMeeting(record.dir, (meeting) => {
|
|
30
|
+
meeting.bitaEntry = eventSnapshot(event);
|
|
31
|
+
});
|
|
32
|
+
if (record.meeting.status === 'recorded') {
|
|
33
|
+
processInBackground(record.meeting.id, record.dir);
|
|
34
|
+
log(`processing ${record.dir}`);
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'processIfRecorded': {
|
|
39
|
+
const found = new MeetingStore(loadConfig()).find(event.entry.id);
|
|
40
|
+
if (!found) {
|
|
41
|
+
log(`nothing to do: no meeting for entry #${event.entry.id}`);
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (found.meeting.status !== 'recorded' || Object.keys(found.meeting.stages).length > 0) {
|
|
45
|
+
log(`nothing to do: ${found.meeting.id} is ${found.meeting.status}`);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
updateMeeting(found.dir, (meeting) => {
|
|
49
|
+
meeting.bitaEntry = eventSnapshot(event);
|
|
50
|
+
});
|
|
51
|
+
processInBackground(found.meeting.id, found.dir);
|
|
52
|
+
log(`processing ${found.dir}`);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case 'stopWithoutProcessing': {
|
|
56
|
+
const record = await stopRecording(60);
|
|
57
|
+
log(`stopped without processing ${record.dir}`);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
case 'discard': {
|
|
61
|
+
const record = await discardRecording(undefined, event.entry.id);
|
|
62
|
+
log(`discarded ${record.dir}`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case 'ignore':
|
|
66
|
+
log(`nothing to do: ${action.reason}`);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error instanceof RecapError && error.code === 'MEETING_NOT_FOUND') {
|
|
73
|
+
log(`nothing to do: ${error.message}`);
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
log(`failed: ${describeError(error)}`);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function bitaHookCommand(argv) {
|
|
81
|
+
if (argv.includes('--help')) {
|
|
82
|
+
process.stdout.write('recap bita-hook: handle a bita timer event read from stdin (registered through the kit tool registry by `recap setup`).\n');
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
let event;
|
|
86
|
+
try {
|
|
87
|
+
event = decodeHookEvent(readFileSync(0, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
log(`ignored: unreadable event (${errorMessage(error)})`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
return handleHookEvent(event);
|
|
94
|
+
}
|