@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,23 @@
|
|
|
1
|
+
import { loadConfig } from "../../core/config.js";
|
|
2
|
+
import { isoNow } from "../../core/dates.js";
|
|
3
|
+
import { isDirectory } from "../../core/fsutil.js";
|
|
4
|
+
import { MeetingStore } from "../../core/meeting.js";
|
|
5
|
+
import { describeError } from "../../errors.js";
|
|
6
|
+
import { LiveWorker, makeLiveDetector } from "../../live/worker.js";
|
|
7
|
+
export async function liveWorkerCommand(argv) {
|
|
8
|
+
const log = (message) => process.stderr.write(`${isoNow()} ${message}\n`);
|
|
9
|
+
try {
|
|
10
|
+
const reference = argv.find((arg) => !arg.startsWith('--'));
|
|
11
|
+
if (!reference)
|
|
12
|
+
throw new Error('Usage: recap live-worker <meeting dir or id>');
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const dir = isDirectory(reference) ? reference : new MeetingStore(config).resolve(reference).dir;
|
|
15
|
+
const worker = new LiveWorker(dir, config, log, { detectorFactory: (meetingDir, meeting) => makeLiveDetector(meetingDir, meeting, config, log) });
|
|
16
|
+
await worker.run();
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
log(`live worker failed: ${describeError(error)}`);
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { loadConfig } from "../../core/config.js";
|
|
2
|
+
import { MeetingStore, resolveTarget } from "../../core/meeting.js";
|
|
3
|
+
import { meetingRecord } from "../../core/record.js";
|
|
4
|
+
import { formatBytes } from "../../core/text.js";
|
|
5
|
+
import { usageError } from "../../errors.js";
|
|
6
|
+
import { VIDEO_PRESETS, pathSize } from "../../media/media.js";
|
|
7
|
+
import { compressVideo, deleteMeeting, pruneMeeting, stripVideo } from "../../media/operations.js";
|
|
8
|
+
import { parseArgs } from "../args.js";
|
|
9
|
+
import { output } from "../output.js";
|
|
10
|
+
export async function stripVideoCommand(argv) {
|
|
11
|
+
return output('strip-video', argv.includes('--json'), async () => {
|
|
12
|
+
const args = parseArgs(argv, { booleans: ['prune-intermediates'], values: ['bita-entry'], maxPositionals: 1 });
|
|
13
|
+
const config = loadConfig();
|
|
14
|
+
const { meeting, dir } = resolveTarget(new MeetingStore(config), args.positionals[0], args.int('bita-entry'));
|
|
15
|
+
const before = pathSize(dir);
|
|
16
|
+
const updated = await stripVideo(config, meeting, dir, args.has('prune-intermediates'));
|
|
17
|
+
return { data: meetingRecord(updated, dir), text: `Removed the video from "${updated.title}": ${formatBytes(before)} -> ${formatBytes(pathSize(dir))}` };
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function compressVideoCommand(argv) {
|
|
21
|
+
return output('compress-video', argv.includes('--json'), async () => {
|
|
22
|
+
const args = parseArgs(argv, { booleans: ['prune-intermediates'], values: ['bita-entry', 'preset'], maxPositionals: 1 });
|
|
23
|
+
const preset = args.value('preset');
|
|
24
|
+
if (!preset || !VIDEO_PRESETS.includes(preset))
|
|
25
|
+
throw usageError(`--preset takes one of ${VIDEO_PRESETS.join(', ')}`);
|
|
26
|
+
const config = loadConfig();
|
|
27
|
+
const { meeting, dir } = resolveTarget(new MeetingStore(config), args.positionals[0], args.int('bita-entry'));
|
|
28
|
+
const before = pathSize(dir);
|
|
29
|
+
const updated = await compressVideo(config, meeting, dir, preset, args.has('prune-intermediates'));
|
|
30
|
+
return { data: meetingRecord(updated, dir), text: `Compressed the video of "${updated.title}" (${preset}): ${formatBytes(before)} -> ${formatBytes(pathSize(dir))}` };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export async function pruneCommand(argv) {
|
|
34
|
+
return output('prune', argv.includes('--json'), () => {
|
|
35
|
+
const args = parseArgs(argv, { booleans: ['intermediates'], values: ['bita-entry'], maxPositionals: 1 });
|
|
36
|
+
if (!args.has('intermediates'))
|
|
37
|
+
throw usageError('Choose what to prune: --intermediates');
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const { meeting, dir } = resolveTarget(new MeetingStore(config), args.positionals[0], args.int('bita-entry'));
|
|
40
|
+
const before = pathSize(dir);
|
|
41
|
+
const updated = pruneMeeting(meeting, dir);
|
|
42
|
+
return { data: meetingRecord(updated, dir), text: `Pruned "${updated.title}": ${formatBytes(before)} -> ${formatBytes(pathSize(dir))}` };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export async function deleteCommand(argv) {
|
|
46
|
+
return output('delete', argv.includes('--json'), () => {
|
|
47
|
+
const args = parseArgs(argv, { values: ['bita-entry'], maxPositionals: 1 });
|
|
48
|
+
const config = loadConfig();
|
|
49
|
+
const { meeting, dir } = resolveTarget(new MeetingStore(config), args.positionals[0], args.int('bita-entry'));
|
|
50
|
+
const deleted = deleteMeeting(meeting, dir);
|
|
51
|
+
return { data: deleted, text: `Deleted "${meeting.title}", freed ${formatBytes(deleted.freedBytes)}` };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { loadConfig } from "../../core/config.js";
|
|
2
|
+
import { expandTilde } from "../../core/paths.js";
|
|
3
|
+
import { readText } from "../../core/fsutil.js";
|
|
4
|
+
import { MeetingStore, resolveTarget } from "../../core/meeting.js";
|
|
5
|
+
import { RecapError, usageError } from "../../errors.js";
|
|
6
|
+
import { docsClient, meetingTarget } from "../../bita/client.js";
|
|
7
|
+
import { ProposalReview, loadProposals, proposalJson } from "../../bita/proposals.js";
|
|
8
|
+
import { parseArgs } from "../args.js";
|
|
9
|
+
import { output } from "../output.js";
|
|
10
|
+
function line(proposal) {
|
|
11
|
+
const section = proposal.section !== null ? ` › ${proposal.section}` : '';
|
|
12
|
+
return `${proposal.n}. [${proposal.status}] ${proposal.pageTitle}${section}: ${proposal.title}`;
|
|
13
|
+
}
|
|
14
|
+
function meeting(args, config) {
|
|
15
|
+
const bitaEntry = args.int('bita-entry');
|
|
16
|
+
return resolveTarget(new MeetingStore(config), bitaEntry === undefined ? args.positionals[0] : undefined, bitaEntry);
|
|
17
|
+
}
|
|
18
|
+
function number(args) {
|
|
19
|
+
const raw = args.value('bita-entry') === undefined ? args.positionals[1] : args.positionals[0];
|
|
20
|
+
if (raw === undefined || !/^\d+$/.test(raw))
|
|
21
|
+
throw new RecapError('PROPOSAL_REQUIRED', 'Pass the proposal number');
|
|
22
|
+
return Number(raw);
|
|
23
|
+
}
|
|
24
|
+
const noBita = { invoke: async () => ({ ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: 'bita not found' }) };
|
|
25
|
+
async function review(args, config, required) {
|
|
26
|
+
const found = meeting(args, config);
|
|
27
|
+
const docs = await docsClient(config, meetingTarget(found.meeting));
|
|
28
|
+
if (!docs && required)
|
|
29
|
+
throw new RecapError('DEPENDENCY_MISSING', 'bita not found. Install it with `npm install -g @kikedealba/bita`');
|
|
30
|
+
return new ProposalReview(found.dir, docs ?? noBita);
|
|
31
|
+
}
|
|
32
|
+
export async function proposalsCommand(argv) {
|
|
33
|
+
const [sub, ...rest] = argv;
|
|
34
|
+
const json = rest.includes('--json');
|
|
35
|
+
const spec = { values: ['bita-entry', ...(sub === 'accept' ? ['md'] : [])], maxPositionals: 2 };
|
|
36
|
+
switch (sub) {
|
|
37
|
+
case 'ls':
|
|
38
|
+
return output('proposals ls', json, () => {
|
|
39
|
+
const args = parseArgs(rest, spec);
|
|
40
|
+
const proposals = loadProposals(meeting(args, loadConfig()).dir)?.proposals ?? [];
|
|
41
|
+
return { data: { proposals: proposals.map(proposalJson) }, text: proposals.length === 0 ? 'No proposals' : proposals.map(line).join('\n') };
|
|
42
|
+
});
|
|
43
|
+
case 'show':
|
|
44
|
+
return output('proposals show', json, async () => {
|
|
45
|
+
const args = parseArgs(rest, spec);
|
|
46
|
+
const reviewer = await review(args, loadConfig(), false);
|
|
47
|
+
const proposal = reviewer.proposal(number(args));
|
|
48
|
+
const markdown = readText(proposal.file);
|
|
49
|
+
const diff = proposal.status === 'pending' || proposal.status === 'stale' ? await reviewer.diff(proposal) : null;
|
|
50
|
+
let text = `${line(proposal)}\n\n${proposal.rationale}`;
|
|
51
|
+
if (markdown !== null)
|
|
52
|
+
text += `\n\n${markdown}`;
|
|
53
|
+
return { data: { proposal: proposalJson(proposal), markdown: markdown ?? undefined, diff: diff ?? undefined }, text };
|
|
54
|
+
});
|
|
55
|
+
case 'accept':
|
|
56
|
+
return output('proposals accept', json, async () => {
|
|
57
|
+
const args = parseArgs(rest, spec);
|
|
58
|
+
const reviewer = await review(args, loadConfig(), true);
|
|
59
|
+
const md = args.value('md');
|
|
60
|
+
const proposal = await reviewer.accept(number(args), md === undefined ? undefined : expandTilde(md));
|
|
61
|
+
const text = proposal.status === 'stale' ? `Proposal ${proposal.n} conflicts with the current page; it is now stale` : `Applied proposal ${proposal.n} to ${proposal.pageTitle}`;
|
|
62
|
+
return { data: { proposal: proposalJson(proposal) }, text };
|
|
63
|
+
});
|
|
64
|
+
case 'reject':
|
|
65
|
+
return output('proposals reject', json, async () => {
|
|
66
|
+
const args = parseArgs(rest, spec);
|
|
67
|
+
const reviewer = await review(args, loadConfig(), true);
|
|
68
|
+
const proposal = await reviewer.reject(number(args));
|
|
69
|
+
return { data: { proposal: proposalJson(proposal) }, text: `Rejected proposal ${proposal.n}` };
|
|
70
|
+
});
|
|
71
|
+
default:
|
|
72
|
+
return output('proposals', argv.includes('--json'), () => {
|
|
73
|
+
throw usageError('Usage: recap proposals ls|show|accept|reject <meeting> [n] [--bita-entry id]');
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig } from "../../core/config.js";
|
|
4
|
+
import { isoNow } from "../../core/dates.js";
|
|
5
|
+
import { readText } from "../../core/fsutil.js";
|
|
6
|
+
import { MeetingStore, durationSeconds, loadMeeting, reconcile, updateMeeting } from "../../core/meeting.js";
|
|
7
|
+
import { waitUntil } from "../../core/proc.js";
|
|
8
|
+
import { meetingRecord } from "../../core/record.js";
|
|
9
|
+
import { processInBackground } from "../../core/self.js";
|
|
10
|
+
import { formatDuration, padEnd } from "../../core/text.js";
|
|
11
|
+
import { RecapError, usageError } from "../../errors.js";
|
|
12
|
+
import { saveMinutes } from "../../bita/wrapup.js";
|
|
13
|
+
import { Pipeline } from "../../pipeline/pipeline.js";
|
|
14
|
+
import { STAGES, allStagesSatisfied, isStage } from "../../pipeline/stages.js";
|
|
15
|
+
import { saveSummary, summaryPromptFor } from "../../pipeline/summary.js";
|
|
16
|
+
import { parseArgs } from "../args.js";
|
|
17
|
+
import { output, stderrLine } from "../output.js";
|
|
18
|
+
export async function listCommand(argv) {
|
|
19
|
+
return output('list', argv.includes('--json'), () => {
|
|
20
|
+
const args = parseArgs(argv, { values: ['limit'], maxPositionals: 0 });
|
|
21
|
+
const limit = args.int('limit') ?? 20;
|
|
22
|
+
const store = new MeetingStore(loadConfig());
|
|
23
|
+
const all = store.all();
|
|
24
|
+
const records = (limit > 0 ? all.slice(0, limit) : all).map((item) => ({ item, record: meetingRecord(item.meeting, item.dir) }));
|
|
25
|
+
const lines = records.map(({ item }) => {
|
|
26
|
+
const m = item.meeting;
|
|
27
|
+
return `${m.id} ${padEnd(m.mode, 9)} ${padEnd(m.status, 10)} ${padEnd(formatDuration(durationSeconds(m)), 8)} ${m.title}`;
|
|
28
|
+
});
|
|
29
|
+
return { data: records.map(({ record }) => record), text: lines.length === 0 ? `No meetings in ${store.root}` : lines.join('\n') };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function locate(store, reference, bitaEntry) {
|
|
33
|
+
if (bitaEntry !== undefined) {
|
|
34
|
+
const found = store.find(bitaEntry);
|
|
35
|
+
if (!found)
|
|
36
|
+
throw new RecapError('MEETING_NOT_FOUND', `No meeting is linked to bita entry ${bitaEntry}`);
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
return store.resolve(reference ?? 'last');
|
|
40
|
+
}
|
|
41
|
+
export async function showCommand(argv) {
|
|
42
|
+
return output('show', argv.includes('--json'), () => {
|
|
43
|
+
const args = parseArgs(argv, { booleans: ['path'], values: ['bita-entry'], maxPositionals: 1 });
|
|
44
|
+
const { meeting, dir } = locate(new MeetingStore(loadConfig()), args.positionals[0], args.int('bita-entry'));
|
|
45
|
+
const record = meetingRecord(meeting, dir, true);
|
|
46
|
+
if (args.has('path'))
|
|
47
|
+
return { data: record, text: dir };
|
|
48
|
+
const summary = readText(path.join(dir, 'summary.md'));
|
|
49
|
+
if (summary !== null)
|
|
50
|
+
return { data: record, text: summary };
|
|
51
|
+
let text = `${meeting.title}\n${meeting.id} ${meeting.mode} ${meeting.status} ${formatDuration(durationSeconds(meeting))}\n${dir}`;
|
|
52
|
+
if (meeting.error)
|
|
53
|
+
text += `\nError: ${meeting.error}`;
|
|
54
|
+
return { data: record, text };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function stageOption(value, name) {
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!isStage(value))
|
|
61
|
+
throw usageError(`--${name} takes one of ${STAGES.join(', ')}`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
export async function processCommand(argv) {
|
|
65
|
+
const json = argv.includes('--json');
|
|
66
|
+
return output('process', json, async () => {
|
|
67
|
+
const args = parseArgs(argv, { booleans: ['background'], values: ['from', 'only'], maxPositionals: 1 });
|
|
68
|
+
const from = stageOption(args.value('from'), 'from');
|
|
69
|
+
const only = stageOption(args.value('only'), 'only');
|
|
70
|
+
const config = loadConfig();
|
|
71
|
+
const { meeting, dir } = new MeetingStore(config).resolve(args.positionals[0] ?? 'last');
|
|
72
|
+
if (args.has('background')) {
|
|
73
|
+
processInBackground(meeting.id, dir, [...(from ? ['--from', from] : []), ...(only ? ['--only', only] : [])]);
|
|
74
|
+
return { data: meetingRecord(meeting, dir), text: `Processing "${meeting.title}" in the background\n${path.join(dir, 'process.log')}` };
|
|
75
|
+
}
|
|
76
|
+
const pipeline = new Pipeline(dir, config, (line) => {
|
|
77
|
+
if (!json)
|
|
78
|
+
stderrLine(line);
|
|
79
|
+
});
|
|
80
|
+
const result = await pipeline.run(from, only);
|
|
81
|
+
const summary = path.join(dir, 'summary.md');
|
|
82
|
+
const text = readText(summary) !== null ? `Processed "${result.title}"\n${summary}` : `Processed "${result.title}" [${result.status}]\n${dir}`;
|
|
83
|
+
return { data: meetingRecord(result, dir), text };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export async function promptCommand(argv) {
|
|
87
|
+
return output('prompt', false, () => {
|
|
88
|
+
const args = parseArgs(argv, { maxPositionals: 1 });
|
|
89
|
+
const { meeting, dir } = new MeetingStore(loadConfig()).resolve(args.positionals[0] ?? 'last');
|
|
90
|
+
return { data: '', text: `Directorio de la reunión: ${dir}\n\n${summaryPromptFor(meeting, dir)}` };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function saveSummaryCommand(argv) {
|
|
94
|
+
return output('save-summary', argv.includes('--json'), async () => {
|
|
95
|
+
const args = parseArgs(argv, { maxPositionals: 2 });
|
|
96
|
+
const [reference, file] = args.positionals;
|
|
97
|
+
if (!reference || !file)
|
|
98
|
+
throw usageError('Usage: recap save-summary <meeting> <file|->');
|
|
99
|
+
const config = loadConfig();
|
|
100
|
+
const { meeting, dir } = new MeetingStore(config).resolve(reference);
|
|
101
|
+
let body;
|
|
102
|
+
try {
|
|
103
|
+
body = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
throw new RecapError('FILE_UNREADABLE', `Cannot read ${file}: ${error.message}`);
|
|
107
|
+
}
|
|
108
|
+
saveSummary(body, meeting, dir);
|
|
109
|
+
if (meeting.bitaEntryId !== undefined)
|
|
110
|
+
await saveMinutes(meeting, dir, config);
|
|
111
|
+
const updated = updateMeeting(dir, (current) => {
|
|
112
|
+
current.stages['summarize'] = { status: 'done', updatedAt: isoNow() };
|
|
113
|
+
if (allStagesSatisfied({ ...meeting, stages: current.stages }))
|
|
114
|
+
current.status = 'processed';
|
|
115
|
+
});
|
|
116
|
+
return { data: meetingRecord(updated, dir), text: path.join(dir, 'summary.md') };
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export function failedStage(meeting) {
|
|
120
|
+
return Object.entries(meeting.stages).find(([, state]) => state.status === 'failed') ?? null;
|
|
121
|
+
}
|
|
122
|
+
export function isSettled(meeting) {
|
|
123
|
+
if (meeting.status === 'processed' || meeting.status === 'failed')
|
|
124
|
+
return true;
|
|
125
|
+
if (meeting.status === 'recorded')
|
|
126
|
+
return failedStage(meeting) !== null;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
export function describeWait(meeting, dir) {
|
|
130
|
+
const lines = [`${meeting.title} [${meeting.status}] ${formatDuration(durationSeconds(meeting))}`];
|
|
131
|
+
const failed = failedStage(meeting);
|
|
132
|
+
if (failed)
|
|
133
|
+
lines.push(`Failed at ${failed[0]}: ${failed[1].error ?? 'no reason given'}`);
|
|
134
|
+
const wrapup = meeting.wrapup;
|
|
135
|
+
if (wrapup) {
|
|
136
|
+
lines.push(`Title : ${wrapup.title ?? meeting.title}`);
|
|
137
|
+
lines.push(`Project : ${wrapup.project ?? '(none)'}${wrapup.projectResolved ? '' : ' — not clear from the meeting'}`);
|
|
138
|
+
if (wrapup.pageId !== undefined)
|
|
139
|
+
lines.push(`Page : #${wrapup.pageId}${wrapup.pageCreated ? ' (new)' : ''}`);
|
|
140
|
+
lines.push(`Backlog : ${Object.keys(wrapup.backlogKeys).length} items`);
|
|
141
|
+
}
|
|
142
|
+
lines.push(path.join(dir, 'summary.md'));
|
|
143
|
+
return lines.join('\n');
|
|
144
|
+
}
|
|
145
|
+
export async function waitCommand(argv) {
|
|
146
|
+
return output('wait', argv.includes('--json'), async () => {
|
|
147
|
+
const args = parseArgs(argv, { values: ['bita-entry', 'timeout'], maxPositionals: 1 });
|
|
148
|
+
const bitaEntry = args.int('bita-entry');
|
|
149
|
+
const timeout = args.number('timeout') ?? 900;
|
|
150
|
+
const store = new MeetingStore(loadConfig());
|
|
151
|
+
let found = null;
|
|
152
|
+
const settled = await waitUntil(timeout, 2, () => {
|
|
153
|
+
try {
|
|
154
|
+
if (found)
|
|
155
|
+
found = { meeting: reconcile(loadMeeting(found.dir), found.dir), dir: found.dir };
|
|
156
|
+
else
|
|
157
|
+
found = bitaEntry !== undefined ? store.find(bitaEntry) : store.resolve(args.positionals[0]);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
if (!found)
|
|
161
|
+
found = null;
|
|
162
|
+
}
|
|
163
|
+
return found !== null && isSettled(found.meeting);
|
|
164
|
+
});
|
|
165
|
+
const result = found;
|
|
166
|
+
if (!result)
|
|
167
|
+
throw new RecapError('MEETING_NOT_FOUND', bitaEntry !== undefined ? `No meeting is linked to bita entry ${bitaEntry}` : 'No meeting found');
|
|
168
|
+
if (!settled)
|
|
169
|
+
throw new RecapError('WAIT_TIMEOUT', `"${result.meeting.title}" is still ${result.meeting.status} after ${Math.trunc(timeout)}s`);
|
|
170
|
+
return { data: meetingRecord(result.meeting, result.dir), text: describeWait(result.meeting, result.dir) };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { currentRecording } from "../../core/active.js";
|
|
3
|
+
import { loadConfig } from "../../core/config.js";
|
|
4
|
+
import { MeetingStore, durationSeconds } from "../../core/meeting.js";
|
|
5
|
+
import { meetingRecord } from "../../core/record.js";
|
|
6
|
+
import { processInBackground } from "../../core/self.js";
|
|
7
|
+
import { formatDuration } from "../../core/text.js";
|
|
8
|
+
import { RecapError, usageError } from "../../errors.js";
|
|
9
|
+
import { discardRecording, startRecording, stopRecording } from "../../capture/recording.js";
|
|
10
|
+
import { importRecording } from "../../capture/import.js";
|
|
11
|
+
import { Pipeline } from "../../pipeline/pipeline.js";
|
|
12
|
+
import { bitaLinked } from "../../setup/integration.js";
|
|
13
|
+
import { parseArgs } from "../args.js";
|
|
14
|
+
import { output, stderrLine } from "../output.js";
|
|
15
|
+
function modeFrom(remote, inPerson, required) {
|
|
16
|
+
if (remote && inPerson)
|
|
17
|
+
throw usageError('Choose only one of --remote or --in-person');
|
|
18
|
+
if (remote)
|
|
19
|
+
return 'remote';
|
|
20
|
+
if (inPerson)
|
|
21
|
+
return 'in-person';
|
|
22
|
+
if (required)
|
|
23
|
+
throw new RecapError('MODE_REQUIRED', 'Choose --remote or --in-person');
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export async function startCommand(argv) {
|
|
27
|
+
return output('start', argv.includes('--json'), async () => {
|
|
28
|
+
const args = parseArgs(argv, { booleans: ['remote', 'in-person'], values: ['display', 'bita-entry', 'timeout'] });
|
|
29
|
+
const mode = modeFrom(args.has('remote'), args.has('in-person'), true);
|
|
30
|
+
const located = await startRecording({
|
|
31
|
+
title: args.positionals.join(' '),
|
|
32
|
+
mode,
|
|
33
|
+
display: args.int('display'),
|
|
34
|
+
bitaEntryId: args.int('bita-entry'),
|
|
35
|
+
timeout: args.number('timeout') ?? 60,
|
|
36
|
+
});
|
|
37
|
+
return { data: meetingRecord(located.meeting, located.dir), text: `Recording ${located.meeting.mode} meeting "${located.meeting.title}"\n${located.dir}` };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export async function stopCommand(argv) {
|
|
41
|
+
return output('stop', argv.includes('--json'), async () => {
|
|
42
|
+
const args = parseArgs(argv, { booleans: ['no-process'], values: ['timeout'], maxPositionals: 0 });
|
|
43
|
+
const { meeting, dir } = await stopRecording(args.number('timeout') ?? 60);
|
|
44
|
+
let text = `Stopped "${meeting.title}" after ${formatDuration(durationSeconds(meeting))}\n${dir}`;
|
|
45
|
+
if (!args.has('no-process') && meeting.status === 'recorded') {
|
|
46
|
+
processInBackground(meeting.id, dir);
|
|
47
|
+
text += `\nProcessing in the background; follow it with \`recap status\` or ${path.join(dir, 'process.log')}`;
|
|
48
|
+
}
|
|
49
|
+
return { data: meetingRecord(meeting, dir), text };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function discardCommand(argv) {
|
|
53
|
+
return output('discard', argv.includes('--json'), async () => {
|
|
54
|
+
const args = parseArgs(argv, { values: ['bita-entry'], maxPositionals: 1 });
|
|
55
|
+
const { meeting, dir } = await discardRecording(args.positionals[0], args.int('bita-entry'));
|
|
56
|
+
return { data: meetingRecord(meeting, dir), text: `Discarded "${meeting.title}"` };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
export async function statusCommand(argv) {
|
|
60
|
+
return output('status', argv.includes('--json'), async () => {
|
|
61
|
+
parseArgs(argv, { maxPositionals: 0 });
|
|
62
|
+
const config = loadConfig();
|
|
63
|
+
const linked = await bitaLinked(config);
|
|
64
|
+
const current = currentRecording();
|
|
65
|
+
if (current) {
|
|
66
|
+
const elapsed = durationSeconds(current.meeting);
|
|
67
|
+
return {
|
|
68
|
+
data: { recording: true, active: meetingRecord(current.meeting, current.active.dir), elapsedSeconds: elapsed, bitaLinked: linked },
|
|
69
|
+
text: `Recording ${current.meeting.mode} meeting "${current.meeting.title}" for ${formatDuration(elapsed)}\n${current.active.dir}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const latest = new MeetingStore(config).all()[0];
|
|
73
|
+
let text = 'Not recording';
|
|
74
|
+
if (latest)
|
|
75
|
+
text += `\nLatest: ${latest.meeting.id} [${latest.meeting.status}] "${latest.meeting.title}"`;
|
|
76
|
+
text += `\nbita: ${linked ? 'linked (meetings started from bita are recorded)' : 'not linked'}`;
|
|
77
|
+
return { data: { recording: false, latest: latest ? meetingRecord(latest.meeting, latest.dir) : undefined, bitaLinked: linked }, text };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function importCommand(argv) {
|
|
81
|
+
const json = argv.includes('--json');
|
|
82
|
+
return output('import', json, async () => {
|
|
83
|
+
const args = parseArgs(argv, { booleans: ['remote', 'in-person', 'no-process', 'background'], values: ['title'], maxPositionals: 1 });
|
|
84
|
+
const file = args.positionals[0];
|
|
85
|
+
if (!file)
|
|
86
|
+
throw usageError('Usage: recap import <audio or video file> [--title <title>] [--remote|--in-person] [--no-process|--background]');
|
|
87
|
+
const config = loadConfig();
|
|
88
|
+
const located = await importRecording(config, file, { title: args.value('title'), mode: modeFrom(args.has('remote'), args.has('in-person'), false) });
|
|
89
|
+
if (args.has('no-process'))
|
|
90
|
+
return { data: meetingRecord(located.meeting, located.dir), text: `Imported "${located.meeting.title}"\n${located.dir}` };
|
|
91
|
+
if (args.has('background')) {
|
|
92
|
+
processInBackground(located.meeting.id, located.dir);
|
|
93
|
+
return { data: meetingRecord(located.meeting, located.dir), text: `Imported "${located.meeting.title}"; processing in the background\n${path.join(located.dir, 'process.log')}` };
|
|
94
|
+
}
|
|
95
|
+
const pipeline = new Pipeline(located.dir, config, (line) => {
|
|
96
|
+
if (!json)
|
|
97
|
+
stderrLine(line);
|
|
98
|
+
});
|
|
99
|
+
const result = await pipeline.run();
|
|
100
|
+
return { data: meetingRecord(result, located.dir), text: `Imported and processed "${result.title}" [${result.status}]\n${located.dir}` };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { release } from 'node:os';
|
|
3
|
+
import { MODELS, loadConfig, rootDir, saveConfig, vadModelPath, whisperModelPath } from "../../core/config.js";
|
|
4
|
+
import { padEnd } from "../../core/text.js";
|
|
5
|
+
import { CHECKED_TOOLS, configuredPath, installHint, locateTool } from "../../core/tools.js";
|
|
6
|
+
import { errorMessage, usageError } from "../../errors.js";
|
|
7
|
+
import { BitaClient } from "../../bita/client.js";
|
|
8
|
+
import { capturePermissions, findCapture, findRecapApp } from "../../capture/capture.js";
|
|
9
|
+
import { download, installMissing, installRecapApp } from "../../setup/deps.js";
|
|
10
|
+
import { installAgents, parseAgents, register } from "../../setup/integration.js";
|
|
11
|
+
import { removeLegacyHook } from "../../setup/legacy.js";
|
|
12
|
+
import { parseArgs } from "../args.js";
|
|
13
|
+
import { output, stderrLine } from "../output.js";
|
|
14
|
+
export async function setupCommand(argv) {
|
|
15
|
+
const json = argv.includes('--json');
|
|
16
|
+
return output('setup', json, async () => {
|
|
17
|
+
const args = parseArgs(argv, {
|
|
18
|
+
booleans: ['skip-permissions', 'skip-models', 'skip-bita', 'install-deps', 'skip-app', 'skip-agents'],
|
|
19
|
+
values: ['agents'],
|
|
20
|
+
maxPositionals: 0,
|
|
21
|
+
});
|
|
22
|
+
let agentChoice;
|
|
23
|
+
try {
|
|
24
|
+
agentChoice = args.has('skip-agents') ? 'none' : parseAgents(args.value('agents'));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw usageError(errorMessage(error));
|
|
28
|
+
}
|
|
29
|
+
const progress = (line) => {
|
|
30
|
+
if (!json)
|
|
31
|
+
stderrLine(line);
|
|
32
|
+
};
|
|
33
|
+
const checks = [];
|
|
34
|
+
checks.push({ name: 'platform', ok: true, detail: `${process.platform} ${release()} ${process.arch}` });
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
if (args.has('install-deps')) {
|
|
37
|
+
const report = await installMissing(config, progress);
|
|
38
|
+
for (const item of report.manual)
|
|
39
|
+
checks.push({ name: 'install', ok: false, detail: item });
|
|
40
|
+
}
|
|
41
|
+
const tools = { ...(config.tools ?? {}) };
|
|
42
|
+
for (const tool of CHECKED_TOOLS) {
|
|
43
|
+
const found = await locateTool(tool, config);
|
|
44
|
+
const optional = tool === 'bita';
|
|
45
|
+
checks.push({ name: tool, ok: found !== null || optional, detail: found ?? `${optional ? 'optional, ' : ''}missing: ${installHint(tool)}` });
|
|
46
|
+
if (found && configuredPath(tool, config) === undefined)
|
|
47
|
+
tools[tool] = found;
|
|
48
|
+
}
|
|
49
|
+
if (JSON.stringify(tools) !== JSON.stringify(config.tools ?? {})) {
|
|
50
|
+
config.tools = tools;
|
|
51
|
+
saveConfig(config);
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'darwin' && !findRecapApp() && !args.has('skip-app')) {
|
|
54
|
+
try {
|
|
55
|
+
progress('Installing Recap.app (the recorder)...');
|
|
56
|
+
await installRecapApp(progress);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
checks.push({ name: 'app', ok: false, detail: `could not install Recap.app: ${errorMessage(error)}` });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const capture = await findCapture();
|
|
63
|
+
checks.push({
|
|
64
|
+
name: 'capture',
|
|
65
|
+
ok: capture !== null || process.platform !== 'darwin',
|
|
66
|
+
detail: capture
|
|
67
|
+
? capture.kind === 'app'
|
|
68
|
+
? capture.app
|
|
69
|
+
: capture.binary
|
|
70
|
+
: process.platform === 'darwin'
|
|
71
|
+
? 'Recap.app is missing; run `recap setup` without --skip-app'
|
|
72
|
+
: 'recording is not available on this system; use `recap import <file>`',
|
|
73
|
+
});
|
|
74
|
+
checks.push({ name: 'root', ok: true, detail: rootDir(config) });
|
|
75
|
+
for (const [name, model, target] of [
|
|
76
|
+
['whisper', MODELS.whisper, whisperModelPath(config)],
|
|
77
|
+
['vad', MODELS.vad, vadModelPath()],
|
|
78
|
+
]) {
|
|
79
|
+
if (!existsSync(target) && !args.has('skip-models')) {
|
|
80
|
+
try {
|
|
81
|
+
await download(model.url, target, progress);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
checks.push({ name: `download:${name}`, ok: false, detail: errorMessage(error) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const present = existsSync(target);
|
|
88
|
+
checks.push({ name: `model:${name}`, ok: present, detail: present ? target : 'missing: run `recap setup` without --skip-models' });
|
|
89
|
+
}
|
|
90
|
+
const bita = args.has('skip-bita') ? null : await BitaClient.create(config, {});
|
|
91
|
+
const subscribe = capture !== null && bita !== null;
|
|
92
|
+
try {
|
|
93
|
+
const file = await register(capture !== null, subscribe);
|
|
94
|
+
checks.push({ name: 'registry', ok: true, detail: file });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
checks.push({ name: 'registry', ok: false, detail: errorMessage(error) });
|
|
98
|
+
}
|
|
99
|
+
if (bita) {
|
|
100
|
+
const removed = await removeLegacyHook(bita);
|
|
101
|
+
checks.push({
|
|
102
|
+
name: 'bita-hook',
|
|
103
|
+
ok: subscribe,
|
|
104
|
+
detail: subscribe
|
|
105
|
+
? `subscribed to bita events through the kit registry${removed > 0 ? `; removed ${removed} old bita hook${removed === 1 ? '' : 's'}` : ''}`
|
|
106
|
+
: 'not subscribed: recording is not available here',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
for (const step of await installAgents(agentChoice)) {
|
|
110
|
+
checks.push({ name: `agent:${step.agent}`, ok: step.state !== 'failed', detail: `${step.item}: ${step.detail}` });
|
|
111
|
+
}
|
|
112
|
+
if (capture) {
|
|
113
|
+
try {
|
|
114
|
+
const report = await capturePermissions(capture, !args.has('skip-permissions'));
|
|
115
|
+
const rerun = args.has('skip-permissions') ? '; run `recap setup` in a terminal to grant it' : '';
|
|
116
|
+
checks.push({ name: 'microphone', ok: report.microphone === 'granted', detail: report.microphone === 'granted' ? 'granted' : `${report.microphone}${rerun}` });
|
|
117
|
+
checks.push({
|
|
118
|
+
name: 'screen',
|
|
119
|
+
ok: report.screen === 'granted',
|
|
120
|
+
detail: report.screen === 'granted'
|
|
121
|
+
? 'granted'
|
|
122
|
+
: `${report.screen}: enable Recap in System Settings > Privacy & Security > Screen & System Audio Recording (only needed for --remote)${rerun}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
checks.push({ name: 'permissions', ok: false, detail: errorMessage(error) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const text = checks.map((check) => `${check.ok ? 'ok ' : '!! '} ${padEnd(check.name, 14)} ${check.detail}`).join('\n');
|
|
130
|
+
return { data: checks, text };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { errorEnvelope, successEnvelope, writeJson } from '@kikedealba/kit/envelope';
|
|
2
|
+
import { asRecapError } from "../errors.js";
|
|
3
|
+
import { SCHEMA_VERSION } from "../version.js";
|
|
4
|
+
export async function output(command, json, body) {
|
|
5
|
+
try {
|
|
6
|
+
const { data, text } = await body();
|
|
7
|
+
if (json)
|
|
8
|
+
writeJson(successEnvelope(SCHEMA_VERSION, command, data));
|
|
9
|
+
else if (text.length > 0)
|
|
10
|
+
process.stdout.write(`${text}\n`);
|
|
11
|
+
return 0;
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
const failure = asRecapError(error);
|
|
15
|
+
if (json)
|
|
16
|
+
writeJson(errorEnvelope(SCHEMA_VERSION, command, { code: failure.code, message: failure.message, ...(failure.hint ? { hint: failure.hint } : {}) }));
|
|
17
|
+
else
|
|
18
|
+
process.stderr.write(`recap: ${failure.message} [${failure.code}]${failure.hint ? `\n${failure.hint}` : ''}\n`);
|
|
19
|
+
return failure.exitCode;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function stderrLine(text) {
|
|
23
|
+
process.stderr.write(`${text}\n`);
|
|
24
|
+
}
|