@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,56 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { clock } from "../core/dates.js";
|
|
3
|
+
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
4
|
+
import { isRecord, parseJsonSafe } from "../core/json.js";
|
|
5
|
+
import { durationSeconds } from "../core/meeting.js";
|
|
6
|
+
import { formatDuration, replaceAll, trimmed } from "../core/text.js";
|
|
7
|
+
import { RecapError } from "../errors.js";
|
|
8
|
+
import { resultText } from "./claude.js";
|
|
9
|
+
import { loadResource } from "./resources.js";
|
|
10
|
+
import { meetingHeader } from "./transcript.js";
|
|
11
|
+
export function readFrames(dir) {
|
|
12
|
+
const value = parseJsonSafe(readText(path.join(dir, 'frames.json')) ?? '');
|
|
13
|
+
if (!Array.isArray(value))
|
|
14
|
+
return [];
|
|
15
|
+
return value.filter(isRecord).flatMap((item) => (typeof item['file'] === 'string' && typeof item['timeSeconds'] === 'number' ? [{ file: item['file'], timeSeconds: item['timeSeconds'] }] : []));
|
|
16
|
+
}
|
|
17
|
+
export function renderSummaryPrompt(meeting, transcript, frames, template = loadResource('summary-prompt.md')) {
|
|
18
|
+
const mode = meeting.mode === 'remote' ? 'remota (videollamada)' : 'presencial';
|
|
19
|
+
const speakers = meeting.mode === 'remote'
|
|
20
|
+
? '- Hablantes: «Sala» es el micrófono local (quien graba y quien esté en su sala); «Remotos» es el audio de la llamada. No hay separación por persona; identifica a cada quien por contexto cuando se presenten o se nombren.'
|
|
21
|
+
: '- Hablantes: un solo micrófono en la sala, sin separación por persona; identifica a cada quien por contexto cuando se presenten o se nombren.';
|
|
22
|
+
let framesText = '';
|
|
23
|
+
if (frames.length > 0) {
|
|
24
|
+
const list = frames.map((frame) => ` - ${frame.file} [${clock(Math.trunc(frame.timeSeconds * 1000))}]`);
|
|
25
|
+
framesText = `- Capturas de pantalla (en el directorio actual; ábrelas con Read cuando el tema lo amerite, en especial diapositivas, documentos o demos):\n${list.join('\n')}`;
|
|
26
|
+
}
|
|
27
|
+
let text = template;
|
|
28
|
+
text = replaceAll(text, '{{title}}', meeting.title);
|
|
29
|
+
text = replaceAll(text, '{{date}}', meetingHeader(meeting).split(' · ')[0] ?? '');
|
|
30
|
+
text = replaceAll(text, '{{duration}}', formatDuration(durationSeconds(meeting)));
|
|
31
|
+
text = replaceAll(text, '{{mode}}', mode);
|
|
32
|
+
text = replaceAll(text, '{{speakers}}', speakers);
|
|
33
|
+
text = replaceAll(text, '{{frames}}', framesText);
|
|
34
|
+
text = replaceAll(text, '{{transcript}}', transcript);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
export function summaryPromptFor(meeting, dir) {
|
|
38
|
+
const transcript = readText(path.join(dir, 'transcript.md'));
|
|
39
|
+
if (transcript === null)
|
|
40
|
+
throw new RecapError('NO_TRANSCRIPT', `"${meeting.title}" has no transcript yet; run \`recap process ${meeting.id}\``);
|
|
41
|
+
return renderSummaryPrompt(meeting, transcript, readFrames(dir));
|
|
42
|
+
}
|
|
43
|
+
export function saveSummary(body, meeting, dir) {
|
|
44
|
+
let content = trimmed(body);
|
|
45
|
+
const start = content.indexOf('## Resumen');
|
|
46
|
+
if (start !== -1)
|
|
47
|
+
content = content.slice(start);
|
|
48
|
+
if (content.length === 0)
|
|
49
|
+
throw new RecapError('EMPTY_SUMMARY', 'The summary is empty');
|
|
50
|
+
writeAtomic(path.join(dir, 'summary.md'), `# ${meeting.title}\n\n${meetingHeader(meeting)}\n\n${content}\n`);
|
|
51
|
+
}
|
|
52
|
+
export function extractSummary(output) {
|
|
53
|
+
const result = resultText(output);
|
|
54
|
+
const start = result.indexOf('## Resumen');
|
|
55
|
+
return start === -1 ? result : result.slice(start);
|
|
56
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { clock, esHeaderDate, parseDate } from "../core/dates.js";
|
|
2
|
+
import { durationSeconds } from "../core/meeting.js";
|
|
3
|
+
import { isRecord } from "../core/json.js";
|
|
4
|
+
import { fold, formatDuration, jaccard, trimmed } from "../core/text.js";
|
|
5
|
+
export function speakerLabel(channel) {
|
|
6
|
+
return channel === 'mic' ? 'Sala' : 'Remotos';
|
|
7
|
+
}
|
|
8
|
+
export function decodeSegment(value) {
|
|
9
|
+
if (!isRecord(value))
|
|
10
|
+
return null;
|
|
11
|
+
const { startMs, endMs, channel, text } = value;
|
|
12
|
+
if (typeof startMs !== 'number' || typeof endMs !== 'number' || typeof text !== 'string')
|
|
13
|
+
return null;
|
|
14
|
+
if (channel !== 'mic' && channel !== 'system')
|
|
15
|
+
return null;
|
|
16
|
+
return { startMs: Math.trunc(startMs), endMs: Math.trunc(endMs), channel, text };
|
|
17
|
+
}
|
|
18
|
+
export function orderedSegment(segment) {
|
|
19
|
+
return { startMs: segment.startMs, endMs: segment.endMs, channel: segment.channel, text: segment.text };
|
|
20
|
+
}
|
|
21
|
+
export const HALLUCINATIONS = [
|
|
22
|
+
'amara.org',
|
|
23
|
+
'subtitulos realizados por',
|
|
24
|
+
'subtitulos por la comunidad',
|
|
25
|
+
'gracias por ver el video',
|
|
26
|
+
'suscribete al canal',
|
|
27
|
+
'thanks for watching',
|
|
28
|
+
'[musica]',
|
|
29
|
+
'(musica)',
|
|
30
|
+
'[blank_audio]',
|
|
31
|
+
];
|
|
32
|
+
export function isHallucination(text) {
|
|
33
|
+
const normalized = fold(text);
|
|
34
|
+
if (normalized.length === 0)
|
|
35
|
+
return true;
|
|
36
|
+
return HALLUCINATIONS.some((pattern) => normalized.includes(pattern));
|
|
37
|
+
}
|
|
38
|
+
export function parseWhisper(text, channel) {
|
|
39
|
+
const value = JSON.parse(text);
|
|
40
|
+
if (!isRecord(value) || !Array.isArray(value['transcription']))
|
|
41
|
+
throw new Error('whisper output has no transcription');
|
|
42
|
+
const segments = [];
|
|
43
|
+
for (const item of value['transcription']) {
|
|
44
|
+
if (!isRecord(item) || !isRecord(item['offsets']) || typeof item['text'] !== 'string')
|
|
45
|
+
throw new Error('whisper output has an invalid segment');
|
|
46
|
+
const from = item['offsets']['from'];
|
|
47
|
+
const to = item['offsets']['to'];
|
|
48
|
+
if (typeof from !== 'number' || typeof to !== 'number')
|
|
49
|
+
throw new Error('whisper output has an invalid offset');
|
|
50
|
+
const content = trimmed(item['text']);
|
|
51
|
+
if (content.length === 0 || isHallucination(content))
|
|
52
|
+
continue;
|
|
53
|
+
segments.push({ startMs: from, endMs: to, channel, text: content });
|
|
54
|
+
}
|
|
55
|
+
return segments;
|
|
56
|
+
}
|
|
57
|
+
export const ECHO_WINDOW_MS = 2_500;
|
|
58
|
+
export const ECHO_SIMILARITY = 0.5;
|
|
59
|
+
export function isEcho(mic, system) {
|
|
60
|
+
const overlaps = mic.startMs <= system.endMs + ECHO_WINDOW_MS && system.startMs <= mic.endMs + ECHO_WINDOW_MS;
|
|
61
|
+
return overlaps && jaccard(mic.text, system.text) >= ECHO_SIMILARITY;
|
|
62
|
+
}
|
|
63
|
+
export function orderSegments(segments) {
|
|
64
|
+
return segments
|
|
65
|
+
.map((segment, index) => ({ segment, index }))
|
|
66
|
+
.sort((left, right) => {
|
|
67
|
+
const a = left.segment;
|
|
68
|
+
const b = right.segment;
|
|
69
|
+
if (a.startMs !== b.startMs)
|
|
70
|
+
return a.startMs - b.startMs;
|
|
71
|
+
if (a.channel !== b.channel)
|
|
72
|
+
return a.channel === 'system' ? -1 : 1;
|
|
73
|
+
return left.index - right.index;
|
|
74
|
+
})
|
|
75
|
+
.map((item) => item.segment);
|
|
76
|
+
}
|
|
77
|
+
export function mergeTranscripts(mic, system) {
|
|
78
|
+
const filtered = mic.filter((segment) => !system.some((other) => isEcho(segment, other)));
|
|
79
|
+
return orderSegments([...filtered, ...system]);
|
|
80
|
+
}
|
|
81
|
+
export const PARAGRAPH_GAP_MS = 4_000;
|
|
82
|
+
export const PARAGRAPH_SPAN_MS = 30_000;
|
|
83
|
+
export function paragraphs(segments) {
|
|
84
|
+
const result = [];
|
|
85
|
+
let lastEnd = Number.MIN_SAFE_INTEGER;
|
|
86
|
+
for (const segment of segments) {
|
|
87
|
+
const last = result[result.length - 1];
|
|
88
|
+
if (last && last.channel === segment.channel && segment.startMs - lastEnd <= PARAGRAPH_GAP_MS && segment.startMs - last.startMs < PARAGRAPH_SPAN_MS) {
|
|
89
|
+
last.text += ` ${segment.text}`;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
result.push({ startMs: segment.startMs, channel: segment.channel, text: segment.text });
|
|
93
|
+
}
|
|
94
|
+
lastEnd = Math.max(lastEnd, segment.endMs);
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
export function meetingHeader(meeting) {
|
|
99
|
+
const date = parseDate(meeting.startedAt) ?? parseDate(meeting.createdAt) ?? new Date();
|
|
100
|
+
const mode = meeting.mode === 'remote' ? 'remota' : 'presencial';
|
|
101
|
+
return `${esHeaderDate(date)} · ${formatDuration(durationSeconds(meeting))} · reunión ${mode}`;
|
|
102
|
+
}
|
|
103
|
+
export function transcriptMarkdown(meeting, segments) {
|
|
104
|
+
const lines = [`# Transcripción: ${meeting.title}`, '', meetingHeader(meeting), ''];
|
|
105
|
+
if (meeting.mode === 'remote') {
|
|
106
|
+
lines.push('> **Sala**: micrófono local (quien graba y quien esté en la misma sala). **Remotos**: audio de la llamada.', '');
|
|
107
|
+
}
|
|
108
|
+
const grouped = paragraphs(segments);
|
|
109
|
+
if (grouped.length === 0)
|
|
110
|
+
lines.push('_No se detectó voz en la grabación._');
|
|
111
|
+
for (const paragraph of grouped) {
|
|
112
|
+
const speaker = meeting.mode === 'remote' ? ` ${speakerLabel(paragraph.channel)}:` : '';
|
|
113
|
+
lines.push(`**[${clock(paragraph.startMs)}]${speaker}** ${paragraph.text}`, '');
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createWriteStream, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { home } from "../core/paths.js";
|
|
7
|
+
import { exec } from "../core/proc.js";
|
|
8
|
+
import { suffix, trimmed } from "../core/text.js";
|
|
9
|
+
import { installHint, locateTool } from "../core/tools.js";
|
|
10
|
+
import { RecapError } from "../errors.js";
|
|
11
|
+
import { which } from '@kikedealba/kit/platform';
|
|
12
|
+
export const BREW_CANDIDATES = ['/opt/homebrew/bin/brew', '/usr/local/bin/brew'];
|
|
13
|
+
const BREW_FORMULAE = { ffmpeg: 'ffmpeg', 'whisper-cli': 'whisper-cpp' };
|
|
14
|
+
const WINGET_IDS = { ffmpeg: 'Gyan.FFmpeg' };
|
|
15
|
+
export async function installMissing(config, progress, platform = process.platform) {
|
|
16
|
+
const missing = [];
|
|
17
|
+
for (const tool of ['ffmpeg', 'whisper-cli'])
|
|
18
|
+
if (!(await locateTool(tool, config)))
|
|
19
|
+
missing.push(tool);
|
|
20
|
+
const report = { installed: [], manual: [] };
|
|
21
|
+
if (missing.length === 0)
|
|
22
|
+
return report;
|
|
23
|
+
if (platform === 'darwin') {
|
|
24
|
+
const brew = BREW_CANDIDATES.find((candidate) => existsSync(candidate)) ?? (await which('brew'));
|
|
25
|
+
const formulae = missing.map((tool) => BREW_FORMULAE[tool] ?? tool);
|
|
26
|
+
if (!brew)
|
|
27
|
+
throw new RecapError('BREW_MISSING', `Install Homebrew, or install ${formulae.join(' and ')} by hand`);
|
|
28
|
+
progress(`Installing ${formulae.join(', ')} with Homebrew...`);
|
|
29
|
+
const result = await exec(brew, ['install', ...formulae], { env: { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1' } });
|
|
30
|
+
if (result.status !== 0)
|
|
31
|
+
throw new RecapError('BREW_FAILED', `brew install ${formulae.join(' ')} failed: ${suffix(trimmed(result.stderr), 400)}`);
|
|
32
|
+
report.installed.push(...formulae);
|
|
33
|
+
return report;
|
|
34
|
+
}
|
|
35
|
+
if (platform === 'win32') {
|
|
36
|
+
const winget = await which('winget');
|
|
37
|
+
for (const tool of missing) {
|
|
38
|
+
const id = WINGET_IDS[tool];
|
|
39
|
+
if (!id || !winget) {
|
|
40
|
+
report.manual.push(`${tool}: ${installHint(tool, platform)}`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
progress(`Installing ${id} with winget...`);
|
|
44
|
+
const result = await exec(winget, ['install', '--id', id, '-e', '--accept-source-agreements', '--accept-package-agreements']);
|
|
45
|
+
if (result.status === 0)
|
|
46
|
+
report.installed.push(id);
|
|
47
|
+
else
|
|
48
|
+
report.manual.push(`${tool}: winget install ${id} failed (${suffix(trimmed(result.stdout + result.stderr), 200)})`);
|
|
49
|
+
}
|
|
50
|
+
return report;
|
|
51
|
+
}
|
|
52
|
+
for (const tool of missing)
|
|
53
|
+
report.manual.push(`${tool}: ${installHint(tool, platform)}`);
|
|
54
|
+
return report;
|
|
55
|
+
}
|
|
56
|
+
export async function download(url, target, progress) {
|
|
57
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
58
|
+
const partial = `${target}.part`;
|
|
59
|
+
let offset = 0;
|
|
60
|
+
try {
|
|
61
|
+
offset = statSync(partial).size;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
offset = 0;
|
|
65
|
+
}
|
|
66
|
+
const response = await fetch(url, { redirect: 'follow', headers: offset > 0 ? { Range: `bytes=${offset}-` } : {} });
|
|
67
|
+
if (response.status === 416) {
|
|
68
|
+
renameSync(partial, target);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok || !response.body)
|
|
72
|
+
throw new RecapError('DOWNLOAD_FAILED', `Cannot download ${path.basename(url)}: HTTP ${response.status}`);
|
|
73
|
+
const append = offset > 0 && response.status === 206;
|
|
74
|
+
if (!append)
|
|
75
|
+
offset = 0;
|
|
76
|
+
progress(`Downloading ${path.basename(target)}${append ? ` (resuming at ${offset} bytes)` : ''}...`);
|
|
77
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(partial, { flags: append ? 'a' : 'w' }));
|
|
78
|
+
rmSync(target, { force: true });
|
|
79
|
+
renameSync(partial, target);
|
|
80
|
+
}
|
|
81
|
+
export const RECAP_REPO = 'KikeDeAlba/recap';
|
|
82
|
+
export const RECAP_ASSET_SUFFIX = '-macos-arm64.zip';
|
|
83
|
+
export function recapAppTarget() {
|
|
84
|
+
return path.join(home(), 'Applications', 'Recap.app');
|
|
85
|
+
}
|
|
86
|
+
export async function installRecapApp(progress) {
|
|
87
|
+
if (process.platform !== 'darwin' || process.arch !== 'arm64')
|
|
88
|
+
throw new RecapError('CAPTURE_UNAVAILABLE', 'Recap.app only runs on Apple Silicon Macs');
|
|
89
|
+
const work = mkdtempSync(path.join(tmpdir(), 'recap-app-'));
|
|
90
|
+
try {
|
|
91
|
+
let zip = process.env['RECAP_APP_ZIP'] ?? null;
|
|
92
|
+
if (!zip) {
|
|
93
|
+
const response = await fetch(`https://api.github.com/repos/${RECAP_REPO}/releases/latest`, { headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'recap-setup' } });
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
throw new RecapError('DOWNLOAD_FAILED', `Cannot reach the recap releases: HTTP ${response.status}`);
|
|
96
|
+
const release = (await response.json());
|
|
97
|
+
const asset = (release.assets ?? []).find((item) => item.name.endsWith(RECAP_ASSET_SUFFIX));
|
|
98
|
+
if (!asset)
|
|
99
|
+
throw new RecapError('DOWNLOAD_FAILED', `The ${release.tag_name ?? 'latest'} release of recap has no ${RECAP_ASSET_SUFFIX} asset`);
|
|
100
|
+
zip = path.join(work, asset.name);
|
|
101
|
+
await download(asset.browser_download_url, zip, progress);
|
|
102
|
+
}
|
|
103
|
+
const unpacked = path.join(work, 'unpacked');
|
|
104
|
+
const extracted = await exec('/usr/bin/ditto', ['-x', '-k', zip, unpacked]);
|
|
105
|
+
const source = path.join(unpacked, 'Recap.app');
|
|
106
|
+
if (extracted.status !== 0 || !existsSync(source))
|
|
107
|
+
throw new RecapError('INSTALL_FAILED', `Could not unpack ${zip}: ${trimmed(extracted.stderr)}`);
|
|
108
|
+
const target = recapAppTarget();
|
|
109
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
110
|
+
rmSync(target, { recursive: true, force: true });
|
|
111
|
+
const copied = await exec('/usr/bin/ditto', [source, target]);
|
|
112
|
+
if (copied.status !== 0)
|
|
113
|
+
throw new RecapError('INSTALL_FAILED', `Could not copy Recap.app: ${trimmed(copied.stderr)}`);
|
|
114
|
+
return target;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
rmSync(work, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { agents } from '@kikedealba/kit';
|
|
4
|
+
import { defineManifest, isInstalled, readManifest, registerTool } from '@kikedealba/kit/registry';
|
|
5
|
+
import { bitaCommand } from "../bita/client.js";
|
|
6
|
+
import { readText } from "../core/fsutil.js";
|
|
7
|
+
import { isRecord } from "../core/json.js";
|
|
8
|
+
import { home } from "../core/paths.js";
|
|
9
|
+
import { stableCommand } from "../core/self.js";
|
|
10
|
+
import { HOOK_EVENTS, MEETING_KINDS } from "../bita/hook.js";
|
|
11
|
+
import { PACKAGE_ROOT, VERSION } from "../version.js";
|
|
12
|
+
export const INSTALL_HINT = 'npm install -g @kikedealba/recap && recap setup';
|
|
13
|
+
export const BASE_CAPABILITIES = ['meeting.process', 'meeting.import', 'meeting.minutes', 'meeting.ask', 'meeting.live', 'meeting.media'];
|
|
14
|
+
export function capabilities(captureAvailable) {
|
|
15
|
+
return captureAvailable ? ['meeting.record', ...BASE_CAPABILITIES] : [...BASE_CAPABILITIES];
|
|
16
|
+
}
|
|
17
|
+
function subscription(bin) {
|
|
18
|
+
return {
|
|
19
|
+
tool: 'bita',
|
|
20
|
+
events: [...HOOK_EVENTS],
|
|
21
|
+
filter: { kind: Object.keys(MEETING_KINDS).sort() },
|
|
22
|
+
command: [...bin, 'bita-hook'],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export async function bitaSubscription(ctx) {
|
|
26
|
+
return subscription(await stableCommand(ctx));
|
|
27
|
+
}
|
|
28
|
+
export async function manifest(captureAvailable, subscribe = captureAvailable, ctx) {
|
|
29
|
+
const bin = await stableCommand(ctx);
|
|
30
|
+
return defineManifest({
|
|
31
|
+
name: 'recap',
|
|
32
|
+
version: VERSION,
|
|
33
|
+
description: 'Meeting recorder: local transcription, minutes, live answers',
|
|
34
|
+
bin,
|
|
35
|
+
capabilities: capabilities(captureAvailable),
|
|
36
|
+
subscribes: subscribe ? [subscription(bin)] : [],
|
|
37
|
+
homepage: 'https://github.com/KikeDeAlba/recap',
|
|
38
|
+
install: INSTALL_HINT,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function register(captureAvailable, subscribe = captureAvailable) {
|
|
42
|
+
return registerTool(await manifest(captureAvailable, subscribe));
|
|
43
|
+
}
|
|
44
|
+
export async function bitaLinked(config) {
|
|
45
|
+
const own = await readManifest('recap').catch(() => null);
|
|
46
|
+
if (!own || !isInstalled(own) || !own.subscribes.some((subscription) => subscription.tool === 'bita'))
|
|
47
|
+
return false;
|
|
48
|
+
return (await bitaCommand(config)) !== null;
|
|
49
|
+
}
|
|
50
|
+
export function commandSources() {
|
|
51
|
+
const dir = path.join(PACKAGE_ROOT, 'commands');
|
|
52
|
+
let names = [];
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.md')).sort();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return names.map((name) => ({ name: name.slice(0, -3), file: path.join(dir, name) }));
|
|
60
|
+
}
|
|
61
|
+
export function integration() {
|
|
62
|
+
return {
|
|
63
|
+
tool: 'recap',
|
|
64
|
+
version: VERSION,
|
|
65
|
+
description: 'Record meetings and turn them into minutes with recap',
|
|
66
|
+
skills: [{ name: 'recap', dir: path.join(PACKAGE_ROOT, 'skills', 'recap') }],
|
|
67
|
+
commands: commandSources(),
|
|
68
|
+
claude: {
|
|
69
|
+
permissions: {
|
|
70
|
+
allow: ['Bash(recap status:*)', 'Bash(recap list:*)', 'Bash(recap show:*)', 'Bash(recap wait:*)', 'Bash(recap prompt:*)', 'Bash(recap --version)'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export function claudePluginInstalled() {
|
|
76
|
+
const claudeHome = process.env['CLAUDE_CONFIG_DIR'] ?? path.join(home(), '.claude');
|
|
77
|
+
const text = readText(path.join(claudeHome, 'plugins', 'installed_plugins.json'));
|
|
78
|
+
if (text === null)
|
|
79
|
+
return false;
|
|
80
|
+
try {
|
|
81
|
+
const value = JSON.parse(text);
|
|
82
|
+
const plugins = isRecord(value) && isRecord(value['plugins']) ? value['plugins'] : isRecord(value) ? value : {};
|
|
83
|
+
return Object.keys(plugins).some((key) => key.startsWith('recap@'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return text.includes('"recap@');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function parseAgents(raw) {
|
|
90
|
+
if (raw === undefined || raw === 'detected')
|
|
91
|
+
return 'detected';
|
|
92
|
+
if (raw === 'all' || raw === 'none')
|
|
93
|
+
return raw;
|
|
94
|
+
const wanted = raw.split(',').map((item) => item.trim()).filter((item) => item.length > 0);
|
|
95
|
+
const invalid = wanted.filter((item) => !agents.AGENTS.includes(item));
|
|
96
|
+
if (invalid.length > 0)
|
|
97
|
+
throw new Error(`Unknown agent ${invalid.join(', ')}; use ${agents.AGENTS.join(', ')}, detected, all or none`);
|
|
98
|
+
return wanted;
|
|
99
|
+
}
|
|
100
|
+
export async function installAgents(choice, exec) {
|
|
101
|
+
if (choice === 'none')
|
|
102
|
+
return [];
|
|
103
|
+
let list = choice === 'detected' ? await agents.detectAgents() : choice === 'all' ? [...agents.AGENTS] : choice;
|
|
104
|
+
const steps = [];
|
|
105
|
+
if (list.includes('claude') && claudePluginInstalled()) {
|
|
106
|
+
list = list.filter((agent) => agent !== 'claude');
|
|
107
|
+
steps.push({ agent: 'claude', item: 'integration', state: 'present', detail: 'the recap plugin for Claude Code is installed; it already carries the skill and commands' });
|
|
108
|
+
}
|
|
109
|
+
if (list.length === 0)
|
|
110
|
+
return steps;
|
|
111
|
+
return [...steps, ...(await agents.installIntegration(integration(), { agents: list, ...(exec ? { exec } : {}) }))];
|
|
112
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isRecord, parseJsonSafe } from "../core/json.js";
|
|
2
|
+
export function legacyHookIndexes(data) {
|
|
3
|
+
if (!Array.isArray(data))
|
|
4
|
+
return [];
|
|
5
|
+
const indexes = [];
|
|
6
|
+
data.forEach((hook, index) => {
|
|
7
|
+
if (!isRecord(hook) || !Array.isArray(hook['command']))
|
|
8
|
+
return;
|
|
9
|
+
const command = hook['command'].filter((part) => typeof part === 'string');
|
|
10
|
+
if (command[command.length - 1] === 'bita-hook' && command.some((part) => /recap/i.test(part)))
|
|
11
|
+
indexes.push(index + 1);
|
|
12
|
+
});
|
|
13
|
+
return indexes;
|
|
14
|
+
}
|
|
15
|
+
export async function removeLegacyHook(bita) {
|
|
16
|
+
const listed = await bita.run(['hooks', '--json']).catch(() => null);
|
|
17
|
+
if (!listed || listed.status !== 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const envelope = parseJsonSafe(listed.stdout.trim().split('\n').pop() ?? '');
|
|
20
|
+
const indexes = legacyHookIndexes(isRecord(envelope) ? envelope['data'] : undefined);
|
|
21
|
+
let removed = 0;
|
|
22
|
+
for (const index of indexes.sort((a, b) => b - a)) {
|
|
23
|
+
const result = await bita.run(['hooks', 'rm', String(index), '--json']).catch(() => null);
|
|
24
|
+
if (result?.status === 0)
|
|
25
|
+
removed += 1;
|
|
26
|
+
}
|
|
27
|
+
return removed;
|
|
28
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
5
|
+
export const VERSION = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
|
|
6
|
+
export const SCHEMA_VERSION = 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kikedealba/recap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "Meeting recorder CLI: local transcription with whisper.cpp, minutes written by Claude Code, live answers and bita integration.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jesus Enrique De Alba Gaytan",
|
|
7
|
+
"homepage": "https://github.com/KikeDeAlba/recap#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/KikeDeAlba/recap.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/KikeDeAlba/recap/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"meetings",
|
|
17
|
+
"recording",
|
|
18
|
+
"transcription",
|
|
19
|
+
"minutes",
|
|
20
|
+
"whisper",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"gemini-cli",
|
|
24
|
+
"opencode"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"packageManager": "pnpm@11.22.0",
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=24"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"recap": "dist/bin/recap.js"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"Resources/*.md",
|
|
37
|
+
"skills",
|
|
38
|
+
"commands",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
|
+
"prepack": "pnpm build",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"test": "node --test \"test/**/*.test.ts\"",
|
|
50
|
+
"recap": "node src/bin/recap.ts"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@kikedealba/kit": "^0.2.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^24.0.0",
|
|
57
|
+
"typescript": "^5.7.2"
|
|
58
|
+
}
|
|
59
|
+
}
|