@kikedealba/recap 0.0.0-stage → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +388 -2
  3. package/Resources/ask-prompt.md +49 -0
  4. package/Resources/detect-prompt.md +71 -0
  5. package/Resources/proposals-prompt.md +44 -0
  6. package/Resources/summary-prompt.md +37 -0
  7. package/Resources/wrapup-prompt.md +32 -0
  8. package/commands/recap-ask.md +28 -0
  9. package/commands/recap-list.md +21 -0
  10. package/commands/recap-proposals.md +32 -0
  11. package/commands/recap-start.md +47 -0
  12. package/commands/recap-status.md +14 -0
  13. package/commands/recap-stop.md +69 -0
  14. package/commands/recap-summarize.md +31 -0
  15. package/dist/bin/recap.js +4 -0
  16. package/dist/bita/client.js +155 -0
  17. package/dist/bita/hook.js +81 -0
  18. package/dist/bita/proposals.js +419 -0
  19. package/dist/bita/wrapup.js +254 -0
  20. package/dist/capture/capture.js +101 -0
  21. package/dist/capture/import.js +80 -0
  22. package/dist/capture/recording.js +125 -0
  23. package/dist/cli/args.js +83 -0
  24. package/dist/cli/commands/ask.js +127 -0
  25. package/dist/cli/commands/config.js +40 -0
  26. package/dist/cli/commands/hook.js +94 -0
  27. package/dist/cli/commands/live.js +23 -0
  28. package/dist/cli/commands/media.js +53 -0
  29. package/dist/cli/commands/proposals.js +76 -0
  30. package/dist/cli/commands/query.js +172 -0
  31. package/dist/cli/commands/recording.js +102 -0
  32. package/dist/cli/commands/setup.js +132 -0
  33. package/dist/cli/output.js +24 -0
  34. package/dist/cli/router.js +94 -0
  35. package/dist/core/active.js +46 -0
  36. package/dist/core/config.js +77 -0
  37. package/dist/core/dates.js +30 -0
  38. package/dist/core/fsutil.js +44 -0
  39. package/dist/core/json.js +70 -0
  40. package/dist/core/live-settings.js +140 -0
  41. package/dist/core/lock.js +114 -0
  42. package/dist/core/meeting.js +201 -0
  43. package/dist/core/paths.js +55 -0
  44. package/dist/core/proc.js +129 -0
  45. package/dist/core/record.js +46 -0
  46. package/dist/core/self.js +18 -0
  47. package/dist/core/text.js +89 -0
  48. package/dist/core/tools.js +91 -0
  49. package/dist/errors.js +36 -0
  50. package/dist/live/ask.js +77 -0
  51. package/dist/live/asking.js +185 -0
  52. package/dist/live/context.js +90 -0
  53. package/dist/live/dedupe.js +21 -0
  54. package/dist/live/detector.js +498 -0
  55. package/dist/live/files.js +48 -0
  56. package/dist/live/merger.js +100 -0
  57. package/dist/live/origin.js +91 -0
  58. package/dist/live/stream.js +390 -0
  59. package/dist/live/worker.js +191 -0
  60. package/dist/media/media.js +83 -0
  61. package/dist/media/operations.js +131 -0
  62. package/dist/media/probe.js +50 -0
  63. package/dist/pipeline/claude.js +73 -0
  64. package/dist/pipeline/pipeline.js +275 -0
  65. package/dist/pipeline/resources.js +16 -0
  66. package/dist/pipeline/stages.js +27 -0
  67. package/dist/pipeline/summary.js +56 -0
  68. package/dist/pipeline/transcript.js +116 -0
  69. package/dist/setup/deps.js +119 -0
  70. package/dist/setup/integration.js +108 -0
  71. package/dist/setup/legacy.js +28 -0
  72. package/dist/version.js +6 -0
  73. package/package.json +57 -4
  74. package/skills/recap/SKILL.md +182 -0
@@ -0,0 +1,201 @@
1
+ import { mkdirSync, readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RecapError, errorMessage } from "../errors.js";
4
+ import { rootDir } from "./config.js";
5
+ import { idStamp, isoDate, isoNow, parseDate } from "./dates.js";
6
+ import { exists, readText, writeAtomic } from "./fsutil.js";
7
+ import { isRecord, swiftPretty } from "./json.js";
8
+ import { isAlive } from "./proc.js";
9
+ import { slug } from "./text.js";
10
+ export const MEETING_MODES = ['remote', 'in-person'];
11
+ export function recordingFileName(mode) {
12
+ return mode === 'remote' ? 'recording.mov' : 'recording.m4a';
13
+ }
14
+ export function emptyWrapup() {
15
+ return { titleChanged: false, projectResolved: false, pageCreated: false, backlogKeys: {} };
16
+ }
17
+ export const MEETING_FILE = 'meeting.json';
18
+ export function durationSeconds(meeting, now = new Date()) {
19
+ const started = parseDate(meeting.startedAt);
20
+ if (!started)
21
+ return undefined;
22
+ const ended = parseDate(meeting.endedAt) ?? now;
23
+ return Math.trunc((ended.getTime() - started.getTime()) / 1000);
24
+ }
25
+ function validMeeting(value) {
26
+ if (!isRecord(value))
27
+ return false;
28
+ return (typeof value['id'] === 'string' &&
29
+ typeof value['title'] === 'string' &&
30
+ (value['mode'] === 'remote' || value['mode'] === 'in-person') &&
31
+ typeof value['status'] === 'string' &&
32
+ ['starting', 'recording', 'recorded', 'failed', 'processing', 'processed'].includes(value['status']) &&
33
+ typeof value['createdAt'] === 'string' &&
34
+ parseDate(value['createdAt']) !== null);
35
+ }
36
+ export function decodeMeeting(text) {
37
+ const value = JSON.parse(text);
38
+ if (!validMeeting(value))
39
+ throw new Error('the meeting file is missing required fields');
40
+ const meeting = value;
41
+ meeting.schemaVersion = typeof meeting.schemaVersion === 'number' ? meeting.schemaVersion : 1;
42
+ meeting.stages = isRecord(meeting.stages) ? meeting.stages : {};
43
+ for (const [key, item] of Object.entries(meeting))
44
+ if (item === null)
45
+ delete meeting[key];
46
+ if (meeting.wrapup)
47
+ meeting.wrapup = { ...emptyWrapup(), ...meeting.wrapup };
48
+ if (meeting.bitaEntry)
49
+ meeting.bitaEntry = { ...meeting.bitaEntry, pageIds: Array.isArray(meeting.bitaEntry.pageIds) ? meeting.bitaEntry.pageIds : [] };
50
+ return meeting;
51
+ }
52
+ export function loadMeeting(dir) {
53
+ const file = path.join(dir, MEETING_FILE);
54
+ const text = readText(file);
55
+ try {
56
+ if (text === null)
57
+ throw new Error('no such file');
58
+ return decodeMeeting(text);
59
+ }
60
+ catch (error) {
61
+ throw new RecapError('MEETING_UNREADABLE', `Cannot read ${file}: ${errorMessage(error)}`);
62
+ }
63
+ }
64
+ export function encodeMeeting(meeting) {
65
+ return swiftPretty({ ...meeting, schemaVersion: meeting.schemaVersion ?? 1, stages: meeting.stages ?? {} });
66
+ }
67
+ export function saveMeeting(meeting, dir) {
68
+ writeAtomic(path.join(dir, MEETING_FILE), encodeMeeting(meeting));
69
+ }
70
+ export function updateMeeting(dir, change) {
71
+ const meeting = loadMeeting(dir);
72
+ change(meeting);
73
+ saveMeeting(meeting, dir);
74
+ return meeting;
75
+ }
76
+ export function recordingPath(dir, mode) {
77
+ const primary = path.join(dir, recordingFileName(mode));
78
+ if (mode !== 'remote' || exists(primary))
79
+ return primary;
80
+ const audio = path.join(dir, 'recording.m4a');
81
+ return exists(audio) ? audio : primary;
82
+ }
83
+ export function isActiveStatus(status) {
84
+ return status === 'recording' || status === 'starting';
85
+ }
86
+ export function reconcile(meeting, dir) {
87
+ if (!isActiveStatus(meeting.status))
88
+ return meeting;
89
+ if (meeting.recorderPid !== undefined && isAlive(meeting.recorderPid))
90
+ return meeting;
91
+ const created = parseDate(meeting.createdAt);
92
+ if (meeting.recorderPid === undefined && meeting.status === 'starting' && created && Date.now() - created.getTime() < 300_000)
93
+ return meeting;
94
+ let size = 0;
95
+ let modified = null;
96
+ try {
97
+ const info = statSync(recordingPath(dir, meeting.mode));
98
+ size = info.size;
99
+ modified = info.mtime;
100
+ }
101
+ catch {
102
+ size = 0;
103
+ }
104
+ try {
105
+ return updateMeeting(dir, (current) => {
106
+ current.status = size > 0 && current.startedAt !== undefined ? 'recorded' : 'failed';
107
+ current.endedAt = modified ? isoDate(modified) : isoNow();
108
+ delete current.recorderPid;
109
+ current.error = 'The recorder exited without closing the file cleanly';
110
+ });
111
+ }
112
+ catch {
113
+ return meeting;
114
+ }
115
+ }
116
+ export class MeetingStore {
117
+ root;
118
+ constructor(config) {
119
+ this.root = rootDir(config);
120
+ }
121
+ create(options) {
122
+ mkdirSync(this.root, { recursive: true });
123
+ const now = options.now ?? new Date();
124
+ const base = `${idStamp(now)}-${slug(options.title)}`;
125
+ let id = base;
126
+ let suffix = 2;
127
+ while (exists(path.join(this.root, id))) {
128
+ id = `${base}-${suffix}`;
129
+ suffix += 1;
130
+ }
131
+ const dir = path.join(this.root, id);
132
+ mkdirSync(dir, { recursive: true });
133
+ const meeting = {
134
+ schemaVersion: 1,
135
+ id,
136
+ title: options.title,
137
+ mode: options.mode,
138
+ status: 'starting',
139
+ createdAt: isoDate(now),
140
+ display: options.display,
141
+ bitaEntryId: options.bitaEntryId,
142
+ bitaDatabasePath: options.bita?.databasePath,
143
+ bitaDocsRoot: options.bita?.docsRoot,
144
+ stages: {},
145
+ };
146
+ saveMeeting(meeting, dir);
147
+ return { meeting, dir };
148
+ }
149
+ all() {
150
+ let names;
151
+ try {
152
+ names = readdirSync(this.root);
153
+ }
154
+ catch {
155
+ return [];
156
+ }
157
+ const found = [];
158
+ for (const name of names) {
159
+ const dir = path.join(this.root, name);
160
+ try {
161
+ found.push({ meeting: reconcile(loadMeeting(dir), dir), dir });
162
+ }
163
+ catch {
164
+ continue;
165
+ }
166
+ }
167
+ return found.sort((left, right) => (parseDate(right.meeting.createdAt)?.getTime() ?? 0) - (parseDate(left.meeting.createdAt)?.getTime() ?? 0));
168
+ }
169
+ resolve(reference) {
170
+ const meetings = this.all();
171
+ if (!reference || reference === 'last') {
172
+ const latest = meetings[0];
173
+ if (!latest)
174
+ throw new RecapError('NO_MEETINGS', `No meetings in ${this.root}`);
175
+ return latest;
176
+ }
177
+ const exact = meetings.find((item) => item.meeting.id === reference);
178
+ if (exact)
179
+ return exact;
180
+ const matches = meetings.filter((item) => item.meeting.id.includes(reference));
181
+ if (matches.length === 1 && matches[0])
182
+ return matches[0];
183
+ if (matches.length === 0)
184
+ throw new RecapError('MEETING_NOT_FOUND', `No meeting matches "${reference}"`);
185
+ throw new RecapError('MEETING_AMBIGUOUS', `"${reference}" matches ${matches.length} meetings`);
186
+ }
187
+ find(bitaEntryId) {
188
+ return this.all().find((item) => item.meeting.bitaEntryId === bitaEntryId) ?? null;
189
+ }
190
+ }
191
+ export function resolveTarget(store, reference, bitaEntryId) {
192
+ if (bitaEntryId !== undefined) {
193
+ const found = store.find(bitaEntryId);
194
+ if (!found)
195
+ throw new RecapError('MEETING_NOT_FOUND', `No meeting is linked to bita entry ${bitaEntryId}`);
196
+ return found;
197
+ }
198
+ if (reference === undefined)
199
+ throw new RecapError('MEETING_REQUIRED', 'Pass the meeting id, a unique part of it, "last" or --bita-entry');
200
+ return store.resolve(reference);
201
+ }
@@ -0,0 +1,55 @@
1
+ import { homedir } from 'node:os';
2
+ import path from 'node:path';
3
+ import { dataHome, platformContext, stateHome } from '@kikedealba/kit/platform';
4
+ function env(key) {
5
+ const value = process.env[key];
6
+ return value && value.length > 0 ? value : undefined;
7
+ }
8
+ export function home() {
9
+ return env('HOME') ?? homedir();
10
+ }
11
+ export function isWindows() {
12
+ return process.platform === 'win32';
13
+ }
14
+ export function expandTilde(value) {
15
+ if (value === '~')
16
+ return home();
17
+ if (value.startsWith('~/') || value.startsWith('~\\'))
18
+ return path.join(home(), value.slice(2));
19
+ return path.resolve(value);
20
+ }
21
+ function context() {
22
+ return platformContext({ home: home() });
23
+ }
24
+ export function configFile() {
25
+ const override = env('RECAP_CONFIG_PATH');
26
+ if (override)
27
+ return path.resolve(override);
28
+ if (isWindows())
29
+ return path.join(env('APPDATA') ?? path.join(home(), 'AppData', 'Roaming'), 'recap', 'config.json');
30
+ return path.join(home(), '.config', 'recap', 'config.json');
31
+ }
32
+ export function configDir() {
33
+ return path.dirname(configFile());
34
+ }
35
+ export function stateDir() {
36
+ const override = env('RECAP_STATE_DIR');
37
+ if (override)
38
+ return path.resolve(override);
39
+ return path.join(stateHome(context()), 'recap');
40
+ }
41
+ export function dataDir() {
42
+ const override = env('RECAP_DATA_DIR');
43
+ if (override)
44
+ return path.resolve(override);
45
+ return path.join(dataHome(context()), 'recap');
46
+ }
47
+ export function activeFile() {
48
+ return path.join(stateDir(), 'active.json');
49
+ }
50
+ export function modelsDir() {
51
+ return path.join(dataDir(), 'models');
52
+ }
53
+ export function eventsLog() {
54
+ return path.join(stateDir(), 'events.log');
55
+ }
@@ -0,0 +1,129 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { RecapError } from "../errors.js";
5
+ export function isAlive(pid) {
6
+ if (!Number.isInteger(pid) || pid <= 0)
7
+ return false;
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ return error.code === 'EPERM';
14
+ }
15
+ }
16
+ export function sleep(ms) {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+ export async function waitUntil(timeoutSeconds, intervalSeconds, condition) {
20
+ const deadline = Date.now() + timeoutSeconds * 1000;
21
+ while (Date.now() < deadline) {
22
+ if (await condition())
23
+ return true;
24
+ await sleep(intervalSeconds * 1000);
25
+ }
26
+ return condition();
27
+ }
28
+ function needsShell(command) {
29
+ return process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
30
+ }
31
+ function quoteForCmd(arg) {
32
+ if (arg.length > 0 && !/[\s"&|<>^%()!,;=]/.test(arg))
33
+ return arg;
34
+ return `"${arg.replace(/"/g, '""')}"`;
35
+ }
36
+ export function commandLine(command, args) {
37
+ if (!needsShell(command))
38
+ return { command, args: [...args], shell: false };
39
+ return { command: quoteForCmd(command), args: args.map(quoteForCmd), shell: true };
40
+ }
41
+ export function exec(command, args, options = {}) {
42
+ return new Promise((resolve, reject) => {
43
+ const line = commandLine(command, args);
44
+ const child = spawn(line.command, line.args, {
45
+ env: options.env ?? process.env,
46
+ cwd: options.cwd,
47
+ windowsHide: true,
48
+ shell: line.shell,
49
+ detached: options.detached ?? false,
50
+ stdio: ['pipe', 'pipe', 'pipe'],
51
+ });
52
+ let stdout = '';
53
+ let stderr = '';
54
+ child.once('spawn', () => {
55
+ if (child.pid !== undefined)
56
+ options.onSpawn?.(child.pid);
57
+ });
58
+ child.stdout.setEncoding('utf8').on('data', (chunk) => {
59
+ stdout += chunk;
60
+ options.onStdout?.(chunk);
61
+ });
62
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
63
+ stderr += chunk;
64
+ });
65
+ child.once('error', (error) => {
66
+ reject(new RecapError('SPAWN_FAILED', `Cannot start ${path.basename(command)}: ${error.message}`));
67
+ });
68
+ child.once('close', (code, signal) => {
69
+ resolve({ status: code ?? (signal ? 128 + signalNumber(signal) : 1), signal, stdout, stderr });
70
+ });
71
+ child.stdin.on('error', () => undefined);
72
+ child.stdin.end(options.input ?? '');
73
+ });
74
+ }
75
+ export function signalNumber(signal) {
76
+ const table = { SIGHUP: 1, SIGINT: 2, SIGKILL: 9, SIGTERM: 15 };
77
+ return table[signal] ?? 1;
78
+ }
79
+ export function spawnDetached(command, args, logPath, env = process.env) {
80
+ mkdirSync(path.dirname(logPath), { recursive: true });
81
+ const log = openSync(logPath, 'a');
82
+ try {
83
+ const line = commandLine(command, args);
84
+ const child = spawn(line.command, line.args, {
85
+ detached: true,
86
+ stdio: ['ignore', log, log],
87
+ env,
88
+ windowsHide: true,
89
+ shell: line.shell,
90
+ });
91
+ if (child.pid === undefined)
92
+ throw new RecapError('SPAWN_FAILED', `Cannot start ${path.basename(command)}`);
93
+ child.unref();
94
+ return child.pid;
95
+ }
96
+ finally {
97
+ closeSync(log);
98
+ }
99
+ }
100
+ export function signalProcess(pid, signal) {
101
+ try {
102
+ process.kill(pid, signal);
103
+ return true;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
109
+ export function signalTree(pid, signal) {
110
+ if (pid <= 1 || pid === process.pid)
111
+ return;
112
+ if (process.platform === 'win32') {
113
+ spawnSync('taskkill', ['/T', '/F', '/PID', String(pid)], { windowsHide: true, stdio: 'ignore' });
114
+ return;
115
+ }
116
+ try {
117
+ process.kill(-pid, signal);
118
+ }
119
+ catch {
120
+ signalProcess(pid, signal);
121
+ }
122
+ }
123
+ export async function terminateTree(pid) {
124
+ signalTree(pid, 'SIGTERM');
125
+ if (await waitUntil(3, 0.05, () => !isAlive(pid)))
126
+ return;
127
+ signalTree(pid, 'SIGKILL');
128
+ await waitUntil(1, 0.05, () => !isAlive(pid));
129
+ }
@@ -0,0 +1,46 @@
1
+ import path from 'node:path';
2
+ import { exists } from "./fsutil.js";
3
+ import { durationSeconds, recordingPath } from "./meeting.js";
4
+ import { hasVideo, measureStorage } from "../media/media.js";
5
+ import { liveFiles, readLines } from "../live/files.js";
6
+ import { decodeAnswer, encodeAnswer } from "../live/stream.js";
7
+ import { loadProposals, proposalJson } from "../bita/proposals.js";
8
+ function existing(dir, name) {
9
+ const file = path.join(dir, name);
10
+ return exists(file) ? file : undefined;
11
+ }
12
+ export function meetingRecord(meeting, dir, detailed = false) {
13
+ const recording = recordingPath(dir, meeting.mode);
14
+ const live = liveFiles.transcript(dir);
15
+ const record = {
16
+ id: meeting.id,
17
+ title: meeting.title,
18
+ mode: meeting.mode,
19
+ status: meeting.status,
20
+ createdAt: meeting.createdAt,
21
+ startedAt: meeting.startedAt,
22
+ endedAt: meeting.endedAt,
23
+ durationSeconds: durationSeconds(meeting),
24
+ bitaEntryId: meeting.bitaEntryId,
25
+ bitaEntry: meeting.bitaEntry,
26
+ wrapup: meeting.wrapup,
27
+ error: meeting.error,
28
+ stages: meeting.stages,
29
+ dir,
30
+ recording: existing(dir, path.basename(recording)),
31
+ hasVideo: hasVideo(dir, meeting.mode),
32
+ storage: measureStorage(dir, meeting.mode),
33
+ video: meeting.video,
34
+ videoRemovedAt: meeting.videoRemovedAt,
35
+ transcript: existing(dir, 'transcript.md'),
36
+ summary: existing(dir, 'summary.md'),
37
+ transcriptSegments: existing(dir, 'transcript.json'),
38
+ frames: existing(dir, 'frames.json'),
39
+ liveTranscript: exists(live) ? live : null,
40
+ };
41
+ if (detailed) {
42
+ record['answers'] = readLines(liveFiles.answers(dir), decodeAnswer).map(encodeAnswer);
43
+ record['proposals'] = (loadProposals(dir)?.proposals ?? []).map(proposalJson);
44
+ }
45
+ return record;
46
+ }
@@ -0,0 +1,18 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PACKAGE_ROOT } from "../version.js";
4
+ import { spawnDetached } from "./proc.js";
5
+ export function binEntry() {
6
+ const built = path.join(PACKAGE_ROOT, 'dist', 'bin', 'recap.js');
7
+ return existsSync(built) ? built : path.join(PACKAGE_ROOT, 'src', 'bin', 'recap.ts');
8
+ }
9
+ export function selfCommand() {
10
+ return [process.execPath, binEntry()];
11
+ }
12
+ export function spawnSelf(args, logPath) {
13
+ const [node, entry] = selfCommand();
14
+ return spawnDetached(node ?? process.execPath, [entry ?? binEntry(), ...args], logPath);
15
+ }
16
+ export function processInBackground(meetingId, dir, extra = []) {
17
+ return spawnSelf(['process', meetingId, ...extra], path.join(dir, 'process.log'));
18
+ }
@@ -0,0 +1,89 @@
1
+ export function trimmed(text) {
2
+ return text.replace(/^[\s\u0085]+|[\s\u0085]+$/gu, '');
3
+ }
4
+ export function fold(text) {
5
+ return trimmed(text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().normalize('NFC'));
6
+ }
7
+ export function tokens(text) {
8
+ return new Set(fold(text)
9
+ .split(/[^\p{L}\p{M}\p{N}]+/u)
10
+ .filter((token) => [...token].length > 1));
11
+ }
12
+ export function jaccard(a, b) {
13
+ return jaccardSets(tokens(a), tokens(b));
14
+ }
15
+ export function jaccardSets(left, right) {
16
+ if (left.size === 0 || right.size === 0)
17
+ return 0;
18
+ let shared = 0;
19
+ for (const token of left)
20
+ if (right.has(token))
21
+ shared += 1;
22
+ return shared / (left.size + right.size - shared);
23
+ }
24
+ export function setsEqual(left, right) {
25
+ if (left.size !== right.size)
26
+ return false;
27
+ for (const token of left)
28
+ if (!right.has(token))
29
+ return false;
30
+ return true;
31
+ }
32
+ export function suffix(text, length) {
33
+ const chars = [...text];
34
+ return chars.length <= length ? text : chars.slice(chars.length - length).join('');
35
+ }
36
+ export function prefix(text, length) {
37
+ const chars = [...text];
38
+ return chars.length <= length ? text : chars.slice(0, length).join('');
39
+ }
40
+ export function replaceAll(text, search, replacement) {
41
+ return text.split(search).join(replacement);
42
+ }
43
+ export function padEnd(text, length) {
44
+ const chars = [...text];
45
+ return chars.length >= length ? chars.slice(0, length).join('') : text + ' '.repeat(length - chars.length);
46
+ }
47
+ export function slug(text) {
48
+ const folded = text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase();
49
+ let out = '';
50
+ let pendingDash = false;
51
+ for (const char of folded) {
52
+ if (/^[a-z0-9]$/.test(char)) {
53
+ if (pendingDash && out.length > 0)
54
+ out += '-';
55
+ out += char;
56
+ pendingDash = false;
57
+ }
58
+ else {
59
+ pendingDash = true;
60
+ }
61
+ }
62
+ const cut = out.slice(0, 48).replace(/^-+|-+$/g, '');
63
+ return cut.length === 0 ? 'meeting' : cut;
64
+ }
65
+ export function formatDuration(seconds) {
66
+ if (seconds === undefined || seconds === null)
67
+ return '-';
68
+ const h = Math.trunc(seconds / 3600);
69
+ const m = Math.trunc((seconds % 3600) / 60);
70
+ const s = seconds % 60;
71
+ const two = (value) => String(value).padStart(2, '0');
72
+ return h > 0 ? `${h}h${two(m)}m` : `${m}m${two(s)}s`;
73
+ }
74
+ export function formatBytes(bytes) {
75
+ if (bytes === 0)
76
+ return 'Zero KB';
77
+ if (Math.abs(bytes) < 1000)
78
+ return `${bytes} bytes`;
79
+ const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
80
+ let value = bytes / 1000;
81
+ let unit = 0;
82
+ while (Math.abs(value) >= 1000 && unit < units.length - 1) {
83
+ value /= 1000;
84
+ unit += 1;
85
+ }
86
+ const decimals = unit === 0 ? 0 : unit === 1 ? 1 : 2;
87
+ const text = value.toFixed(decimals).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
88
+ return `${text} ${units[unit]}`;
89
+ }
@@ -0,0 +1,91 @@
1
+ import { constants, accessSync, statSync } from 'node:fs';
2
+ import { userInfo } from 'node:os';
3
+ import path from 'node:path';
4
+ import { childEnvironment, platformContext, which } from '@kikedealba/kit/platform';
5
+ import { RecapError } from "../errors.js";
6
+ import { configFile, expandTilde, home } from "./paths.js";
7
+ export const CHECKED_TOOLS = ['ffmpeg', 'whisper-cli', 'claude', 'bita'];
8
+ export function installHint(tool, platform = process.platform) {
9
+ switch (tool) {
10
+ case 'ffmpeg':
11
+ case 'ffprobe':
12
+ return platform === 'darwin' ? 'brew install ffmpeg' : platform === 'win32' ? 'winget install Gyan.FFmpeg' : 'sudo apt install ffmpeg';
13
+ case 'whisper-cli':
14
+ return platform === 'darwin'
15
+ ? 'brew install whisper-cpp'
16
+ : platform === 'win32'
17
+ ? 'download whisper-bin-x64.zip from https://github.com/ggml-org/whisper.cpp/releases and put whisper-cli.exe on PATH'
18
+ : 'brew install whisper-cpp (Homebrew on Linux) or build whisper.cpp from https://github.com/ggml-org/whisper.cpp';
19
+ case 'claude':
20
+ return 'npm install -g @anthropic-ai/claude-code';
21
+ case 'bita':
22
+ return 'npm install -g @kikedealba/bita';
23
+ }
24
+ }
25
+ function isExecutable(file) {
26
+ try {
27
+ if (!statSync(file).isFile())
28
+ return false;
29
+ if (process.platform === 'win32')
30
+ return true;
31
+ accessSync(file, constants.X_OK);
32
+ return true;
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ export function configuredPath(tool, config) {
39
+ return config.tools?.[tool];
40
+ }
41
+ function toolDirs(config) {
42
+ return Object.values(config.tools ?? {}).map((value) => path.dirname(expandTilde(value)));
43
+ }
44
+ function ctx() {
45
+ return platformContext({ home: home() });
46
+ }
47
+ export async function locateTool(tool, config) {
48
+ const configured = configuredPath(tool, config);
49
+ if (configured) {
50
+ const file = expandTilde(configured);
51
+ if (isExecutable(file))
52
+ return file;
53
+ }
54
+ if (tool === 'ffprobe') {
55
+ const ffmpeg = config.tools?.['ffmpeg'];
56
+ if (ffmpeg) {
57
+ const sibling = path.join(path.dirname(expandTilde(ffmpeg)), process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
58
+ if (isExecutable(sibling))
59
+ return sibling;
60
+ }
61
+ }
62
+ return which(tool, ctx(), toolDirs(config));
63
+ }
64
+ export async function requireTool(tool, config) {
65
+ const found = await locateTool(tool, config);
66
+ if (!found) {
67
+ throw new RecapError('DEPENDENCY_MISSING', `${tool} not found. Install it with \`${installHint(tool)}\` or set tools.${tool} in ${configFile()}`, {
68
+ hint: installHint(tool),
69
+ });
70
+ }
71
+ return found;
72
+ }
73
+ function currentUser() {
74
+ try {
75
+ return userInfo().username;
76
+ }
77
+ catch {
78
+ return '';
79
+ }
80
+ }
81
+ export function toolEnvironment(executable, config, base = process.env) {
82
+ const context = platformContext({ env: base, home: home() });
83
+ const environment = childEnvironment(context, [path.dirname(executable), ...toolDirs(config)]);
84
+ if (!environment['HOME'] && process.platform !== 'win32')
85
+ environment['HOME'] = home();
86
+ if (!environment['USER'])
87
+ environment['USER'] = currentUser();
88
+ if (!environment['LOGNAME'])
89
+ environment['LOGNAME'] = environment['USER'];
90
+ return environment;
91
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,36 @@
1
+ export const EXIT = {
2
+ ok: 0,
3
+ failure: 1,
4
+ usage: 2,
5
+ };
6
+ export class RecapError extends Error {
7
+ code;
8
+ hint;
9
+ exitCode;
10
+ constructor(code, message, options = {}) {
11
+ super(message);
12
+ this.name = 'RecapError';
13
+ this.code = code;
14
+ this.hint = options.hint;
15
+ this.exitCode = options.exitCode ?? EXIT.failure;
16
+ }
17
+ get description() {
18
+ return `${this.code}: ${this.message}`;
19
+ }
20
+ }
21
+ export function usageError(message, hint) {
22
+ return new RecapError('USAGE', message, { exitCode: EXIT.usage, ...(hint ? { hint } : {}) });
23
+ }
24
+ export function describeError(error) {
25
+ if (error instanceof RecapError)
26
+ return error.description;
27
+ return error instanceof Error ? error.message : String(error);
28
+ }
29
+ export function errorMessage(error) {
30
+ return error instanceof Error ? error.message : String(error);
31
+ }
32
+ export function asRecapError(error) {
33
+ if (error instanceof RecapError)
34
+ return error;
35
+ return new RecapError('UNEXPECTED', errorMessage(error));
36
+ }