@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,94 @@
1
+ import { readManifest } from '@kikedealba/kit/registry';
2
+ import { usageError } from "../errors.js";
3
+ import { findCapture } from "../capture/capture.js";
4
+ import { capabilities } from "../setup/integration.js";
5
+ import { VERSION } from "../version.js";
6
+ import { askCommand } from "./commands/ask.js";
7
+ import { configCommand } from "./commands/config.js";
8
+ import { bitaHookCommand } from "./commands/hook.js";
9
+ import { liveWorkerCommand } from "./commands/live.js";
10
+ import { compressVideoCommand, deleteCommand, pruneCommand, stripVideoCommand } from "./commands/media.js";
11
+ import { proposalsCommand } from "./commands/proposals.js";
12
+ import { listCommand, processCommand, promptCommand, saveSummaryCommand, showCommand, waitCommand } from "./commands/query.js";
13
+ import { discardCommand, importCommand, startCommand, statusCommand, stopCommand } from "./commands/recording.js";
14
+ import { setupCommand } from "./commands/setup.js";
15
+ import { output } from "./output.js";
16
+ async function capabilitiesCommand(argv) {
17
+ return output('capabilities', argv.includes('--json'), async () => {
18
+ const registered = await readManifest('recap').catch(() => null);
19
+ const capture = (await findCapture()) !== null;
20
+ const data = { name: 'recap', version: VERSION, envelope: 1, capabilities: capabilities(capture), emits: [] };
21
+ return { data: { ...data, ...(registered ? { subscribes: registered.subscribes } : {}) }, text: data.capabilities.join('\n') };
22
+ });
23
+ }
24
+ const COMMANDS = {
25
+ start: startCommand,
26
+ stop: stopCommand,
27
+ discard: discardCommand,
28
+ status: statusCommand,
29
+ list: listCommand,
30
+ show: showCommand,
31
+ process: processCommand,
32
+ import: importCommand,
33
+ 'compress-video': compressVideoCommand,
34
+ 'strip-video': stripVideoCommand,
35
+ prune: pruneCommand,
36
+ delete: deleteCommand,
37
+ prompt: promptCommand,
38
+ 'save-summary': saveSummaryCommand,
39
+ 'bita-hook': bitaHookCommand,
40
+ wait: waitCommand,
41
+ setup: setupCommand,
42
+ ask: askCommand,
43
+ proposals: proposalsCommand,
44
+ config: configCommand,
45
+ 'live-worker': liveWorkerCommand,
46
+ capabilities: capabilitiesCommand,
47
+ };
48
+ export function help() {
49
+ return [
50
+ `recap ${VERSION} — record meetings, transcribe them locally and summarize them with Claude Code`,
51
+ '',
52
+ 'Recording (macOS, through Recap.app):',
53
+ ' recap start --remote|--in-person [title] [--bita-entry id] [--display id] [--timeout s]',
54
+ ' recap stop [--no-process] [--timeout s]',
55
+ ' recap discard [meeting] [--bita-entry id]',
56
+ ' recap status',
57
+ '',
58
+ 'Any system:',
59
+ ' recap import <audio|video file> [--title t] [--remote|--in-person] [--no-process|--background]',
60
+ ' recap process [meeting] [--from stage] [--only stage] [--background]',
61
+ ' recap list [--limit n] | show [meeting] [--bita-entry id] [--path] | wait [meeting] [--bita-entry id] [--timeout s]',
62
+ ' recap prompt [meeting] | save-summary <meeting> <file|->',
63
+ ' recap ask (--active|--meeting m|--bita-entry id) [--question q] [--window s] [--json-stream] | ask --sources --project p',
64
+ ' recap proposals ls|show|accept|reject <meeting> [n] [--bita-entry id] [--md file]',
65
+ ' recap compress-video <meeting> --preset light|medium|max | strip-video <meeting> | prune <meeting> --intermediates | delete <meeting>',
66
+ ' recap config get [key] | config set <key> <value>',
67
+ ' recap setup [--install-deps] [--skip-models] [--skip-permissions] [--skip-bita] [--skip-app] [--agents detected|all|none|claude,opencode,codex,gemini]',
68
+ ' recap capabilities',
69
+ '',
70
+ 'Stages: audio, transcribe, frames, summarize, proposals, wrapup. Every command takes --json.',
71
+ ].join('\n');
72
+ }
73
+ export async function main(argv) {
74
+ const [name, ...rest] = argv;
75
+ if (name === undefined || name === '--help' || name === '-h' || name === 'help') {
76
+ process.stdout.write(`${help()}\n`);
77
+ return 0;
78
+ }
79
+ if (name === '--version' || name === '-v' || name === 'version') {
80
+ process.stdout.write(`${VERSION}\n`);
81
+ return 0;
82
+ }
83
+ const command = COMMANDS[name];
84
+ if (!command) {
85
+ return output(name, argv.includes('--json'), () => {
86
+ throw usageError(`Unknown command "${name}"`, 'Run `recap --help` to see the commands.');
87
+ });
88
+ }
89
+ if (rest.includes('--help') && name !== 'bita-hook') {
90
+ process.stdout.write(`${help()}\n`);
91
+ return 0;
92
+ }
93
+ return command(rest);
94
+ }
@@ -0,0 +1,46 @@
1
+ import { rmSync } from 'node:fs';
2
+ import { activeFile } from "./paths.js";
3
+ import { readText, writeAtomic } from "./fsutil.js";
4
+ import { isRecord, swiftPretty } from "./json.js";
5
+ import { isActiveStatus, loadMeeting, reconcile } from "./meeting.js";
6
+ export function loadActive() {
7
+ const text = readText(activeFile());
8
+ if (text === null)
9
+ return null;
10
+ try {
11
+ const value = JSON.parse(text);
12
+ if (!isRecord(value) || typeof value['meetingId'] !== 'string' || typeof value['dir'] !== 'string' || typeof value['startedAt'] !== 'string')
13
+ return null;
14
+ if (value['mode'] !== 'remote' && value['mode'] !== 'in-person')
15
+ return null;
16
+ return { meetingId: value['meetingId'], dir: value['dir'], mode: value['mode'], startedAt: value['startedAt'] };
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function saveActive(active) {
23
+ writeAtomic(activeFile(), swiftPretty(active));
24
+ }
25
+ export function clearActive() {
26
+ rmSync(activeFile(), { force: true });
27
+ }
28
+ export function currentRecording() {
29
+ const active = loadActive();
30
+ if (!active)
31
+ return null;
32
+ let stored;
33
+ try {
34
+ stored = loadMeeting(active.dir);
35
+ }
36
+ catch {
37
+ clearActive();
38
+ return null;
39
+ }
40
+ const meeting = reconcile(stored, active.dir);
41
+ if (!isActiveStatus(meeting.status)) {
42
+ clearActive();
43
+ return null;
44
+ }
45
+ return { active, meeting };
46
+ }
@@ -0,0 +1,77 @@
1
+ import path from 'node:path';
2
+ import { RecapError, errorMessage } from "../errors.js";
3
+ import { readText, writeAtomic, exists } from "./fsutil.js";
4
+ import { isRecord, swiftPretty } from "./json.js";
5
+ import { liveSettings } from "./live-settings.js";
6
+ import { configFile, expandTilde, modelsDir } from "./paths.js";
7
+ export const MODELS = {
8
+ whisper: {
9
+ fileName: 'ggml-large-v3-turbo.bin',
10
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin',
11
+ },
12
+ vad: {
13
+ fileName: 'ggml-silero-v5.1.2.bin',
14
+ url: 'https://huggingface.co/ggml-org/whisper-vad/resolve/main/ggml-silero-v5.1.2.bin',
15
+ },
16
+ };
17
+ function checkString(config, key) {
18
+ if (config[key] !== undefined && config[key] !== null && typeof config[key] !== 'string')
19
+ throw new Error(`${key} must be a string`);
20
+ }
21
+ export function parseConfig(text) {
22
+ const value = JSON.parse(text);
23
+ if (!isRecord(value))
24
+ throw new Error('the configuration must be a JSON object');
25
+ for (const key of ['root', 'language', 'whisperModel', 'summaryModel'])
26
+ checkString(value, key);
27
+ if (value['vocabulary'] !== undefined && value['vocabulary'] !== null) {
28
+ if (!Array.isArray(value['vocabulary']) || !value['vocabulary'].every((item) => typeof item === 'string'))
29
+ throw new Error('vocabulary must be a list of strings');
30
+ }
31
+ if (value['tools'] !== undefined && value['tools'] !== null) {
32
+ if (!isRecord(value['tools']) || !Object.values(value['tools']).every((item) => typeof item === 'string'))
33
+ throw new Error('tools must map names to paths');
34
+ }
35
+ if (value['live'] !== undefined && value['live'] !== null && !isRecord(value['live']))
36
+ throw new Error('live must be an object');
37
+ const cleaned = {};
38
+ for (const [key, item] of Object.entries(value))
39
+ if (item !== null)
40
+ cleaned[key] = item;
41
+ return cleaned;
42
+ }
43
+ export function loadConfig() {
44
+ const file = configFile();
45
+ if (!exists(file))
46
+ return {};
47
+ const text = readText(file);
48
+ if (text === null)
49
+ throw new RecapError('CONFIG_INVALID', `Cannot read ${file}`);
50
+ try {
51
+ return parseConfig(text);
52
+ }
53
+ catch (error) {
54
+ throw new RecapError('CONFIG_INVALID', `Cannot read ${file}: ${errorMessage(error)}`);
55
+ }
56
+ }
57
+ export function saveConfig(config) {
58
+ writeAtomic(configFile(), swiftPretty(config));
59
+ }
60
+ export function rootDir(config) {
61
+ const override = process.env['RECAP_ROOT'];
62
+ if (override)
63
+ return expandTilde(override);
64
+ return expandTilde(config.root ?? '~/Recap');
65
+ }
66
+ export function transcriptionLanguage(config) {
67
+ return config.language ?? 'es';
68
+ }
69
+ export function whisperModelPath(config) {
70
+ return config.whisperModel ? expandTilde(config.whisperModel) : path.join(modelsDir(), MODELS.whisper.fileName);
71
+ }
72
+ export function vadModelPath() {
73
+ return path.join(modelsDir(), MODELS.vad.fileName);
74
+ }
75
+ export function settingsOf(config) {
76
+ return liveSettings(config.live);
77
+ }
@@ -0,0 +1,30 @@
1
+ const MONTHS = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
2
+ const two = (value) => String(value).padStart(2, '0');
3
+ export function isoDate(date) {
4
+ return date.toISOString().replace(/\.\d{3}Z$/, 'Z');
5
+ }
6
+ export function isoNow() {
7
+ return isoDate(new Date());
8
+ }
9
+ export function parseDate(value) {
10
+ if (!value)
11
+ return null;
12
+ const ms = Date.parse(value);
13
+ return Number.isNaN(ms) ? null : new Date(ms);
14
+ }
15
+ export function esHeaderDate(date) {
16
+ return `${date.getDate()} de ${MONTHS[date.getMonth()]} de ${date.getFullYear()}, ${two(date.getHours())}:${two(date.getMinutes())}`;
17
+ }
18
+ export function dayString(date) {
19
+ return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`;
20
+ }
21
+ export function idStamp(date) {
22
+ return `${dayString(date)}-${two(date.getHours())}${two(date.getMinutes())}`;
23
+ }
24
+ export function minuteStamp(date) {
25
+ return `${dayString(date)} ${two(date.getHours())}:${two(date.getMinutes())}`;
26
+ }
27
+ export function clock(ms) {
28
+ const total = Math.max(0, Math.trunc(ms / 1000));
29
+ return `${two(Math.trunc(total / 3600))}:${two(Math.trunc((total % 3600) / 60))}:${two(total % 60)}`;
30
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export function writeAtomic(file, content) {
4
+ mkdirSync(path.dirname(file), { recursive: true });
5
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
6
+ writeFileSync(temporary, content);
7
+ try {
8
+ renameSync(temporary, file);
9
+ }
10
+ catch (error) {
11
+ rmSync(temporary, { force: true });
12
+ throw error;
13
+ }
14
+ }
15
+ export function readText(file) {
16
+ try {
17
+ return readFileSync(file, 'utf8');
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function exists(file) {
24
+ return existsSync(file);
25
+ }
26
+ export function isDirectory(file) {
27
+ try {
28
+ return statSync(file).isDirectory();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export function fileSize(file) {
35
+ try {
36
+ return statSync(file).size;
37
+ }
38
+ catch {
39
+ return 0;
40
+ }
41
+ }
42
+ export function removePath(file) {
43
+ rmSync(file, { recursive: true, force: true });
44
+ }
@@ -0,0 +1,70 @@
1
+ function encodeString(value, options) {
2
+ const text = JSON.stringify(value);
3
+ return options.escapeSlashes ? text.replace(/\//g, '\\/') : text;
4
+ }
5
+ function encodeValue(value, options, indent) {
6
+ if (value === null || value === undefined)
7
+ return 'null';
8
+ if (typeof value === 'string')
9
+ return encodeString(value, options);
10
+ if (typeof value === 'number')
11
+ return Number.isFinite(value) ? JSON.stringify(value) : 'null';
12
+ if (typeof value === 'boolean')
13
+ return value ? 'true' : 'false';
14
+ const inner = `${indent} `;
15
+ if (Array.isArray(value)) {
16
+ if (value.length === 0)
17
+ return options.pretty ? `[\n\n${indent}]` : '[]';
18
+ const items = value.map((item) => encodeValue(item, options, inner));
19
+ return options.pretty ? `[\n${items.map((item) => `${inner}${item}`).join(',\n')}\n${indent}]` : `[${items.join(',')}]`;
20
+ }
21
+ if (typeof value === 'object') {
22
+ const record = value;
23
+ let keys = Object.keys(record).filter((key) => record[key] !== undefined);
24
+ if (options.sorted)
25
+ keys = keys.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
26
+ if (keys.length === 0)
27
+ return options.pretty ? `{\n\n${indent}}` : '{}';
28
+ const separator = options.pretty ? ' : ' : ':';
29
+ const entries = keys.map((key) => `${encodeString(key, options)}${separator}${encodeValue(record[key], options, inner)}`);
30
+ return options.pretty ? `{\n${entries.map((entry) => `${inner}${entry}`).join(',\n')}\n${indent}}` : `{${entries.join(',')}}`;
31
+ }
32
+ return 'null';
33
+ }
34
+ export function encodeJson(value, options = {}) {
35
+ return encodeValue(value, options, '');
36
+ }
37
+ export const swiftPretty = (value) => encodeJson(value, { pretty: true, sorted: true });
38
+ export const swiftLine = (value) => encodeJson(value, { sorted: true });
39
+ export const swiftDefault = (value) => encodeJson(value, { escapeSlashes: true });
40
+ export function isRecord(value) {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
42
+ }
43
+ export function asString(value) {
44
+ return typeof value === 'string' ? value : undefined;
45
+ }
46
+ export function asInt(value) {
47
+ return typeof value === 'number' && Number.isInteger(value) ? value : undefined;
48
+ }
49
+ export function asIntLoose(value) {
50
+ if (typeof value === 'number' && Number.isInteger(value))
51
+ return value;
52
+ if (typeof value === 'string' && /^[+-]?\d+$/.test(value))
53
+ return Number(value);
54
+ return undefined;
55
+ }
56
+ export function parseJsonSafe(text) {
57
+ try {
58
+ return JSON.parse(text);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ export function extractBetween(text, open, close) {
65
+ const start = text.indexOf(open);
66
+ const end = text.lastIndexOf(close);
67
+ if (start === -1 || end === -1 || start >= end)
68
+ return undefined;
69
+ return parseJsonSafe(text.slice(start, end + 1));
70
+ }
@@ -0,0 +1,140 @@
1
+ import { RecapError } from "../errors.js";
2
+ import { trimmed } from "./text.js";
3
+ export const RANGES = {
4
+ chunkSeconds: [5, 60],
5
+ autoAskSeconds: [3, 120],
6
+ autoAskConcurrency: [1, 6],
7
+ };
8
+ export const DEFAULTS = {
9
+ maxChunkSeconds: 10,
10
+ autoAskMinSeconds: 5,
11
+ autoAskConcurrency: 3,
12
+ autoAskModel: 'haiku',
13
+ };
14
+ const clamp = (value, [low, high]) => Math.min(Math.max(value, low), high);
15
+ function bool(value, fallback) {
16
+ return typeof value === 'boolean' ? value : fallback;
17
+ }
18
+ function int(value, fallback) {
19
+ return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback;
20
+ }
21
+ function model(value) {
22
+ if (typeof value !== 'string')
23
+ return null;
24
+ const text = trimmed(value);
25
+ return text.length === 0 ? null : text;
26
+ }
27
+ export function liveSettings(config) {
28
+ return {
29
+ enabled: bool(config?.enabled, true),
30
+ openWindow: bool(config?.openWindow, true),
31
+ proposals: bool(config?.proposals, true),
32
+ maxChunkSeconds: clamp(int(config?.maxChunkSeconds, DEFAULTS.maxChunkSeconds), RANGES.chunkSeconds),
33
+ assistModel: model(config?.assistModel),
34
+ autoAsk: bool(config?.autoAsk, true),
35
+ autoAskModel: model(config?.autoAskModel) ?? DEFAULTS.autoAskModel,
36
+ autoAskMinSeconds: clamp(int(config?.autoAskMinSeconds, DEFAULTS.autoAskMinSeconds), RANGES.autoAskSeconds),
37
+ autoAskConcurrency: clamp(int(config?.autoAskConcurrency, DEFAULTS.autoAskConcurrency), RANGES.autoAskConcurrency),
38
+ };
39
+ }
40
+ export const CONFIG_KEYS = [
41
+ 'live.enabled',
42
+ 'live.openWindow',
43
+ 'live.proposals',
44
+ 'live.assistModel',
45
+ 'live.maxChunkSeconds',
46
+ 'live.autoAsk',
47
+ 'live.autoAskModel',
48
+ 'live.autoAskMinSeconds',
49
+ 'live.autoAskConcurrency',
50
+ ];
51
+ export function parseConfigKey(raw) {
52
+ const found = CONFIG_KEYS.find((key) => key === raw) ?? CONFIG_KEYS.find((key) => key.toLowerCase() === raw.toLowerCase());
53
+ if (!found)
54
+ throw new RecapError('CONFIG_KEY_UNKNOWN', `Unknown key "${raw}"; use one of ${CONFIG_KEYS.join(', ')}`);
55
+ return found;
56
+ }
57
+ export function configValue(key, live) {
58
+ const settings = liveSettings(live);
59
+ switch (key) {
60
+ case 'live.enabled':
61
+ return settings.enabled;
62
+ case 'live.openWindow':
63
+ return settings.openWindow;
64
+ case 'live.proposals':
65
+ return settings.proposals;
66
+ case 'live.assistModel':
67
+ return settings.assistModel;
68
+ case 'live.maxChunkSeconds':
69
+ return settings.maxChunkSeconds;
70
+ case 'live.autoAsk':
71
+ return settings.autoAsk;
72
+ case 'live.autoAskModel':
73
+ return settings.autoAskModel;
74
+ case 'live.autoAskMinSeconds':
75
+ return settings.autoAskMinSeconds;
76
+ case 'live.autoAskConcurrency':
77
+ return settings.autoAskConcurrency;
78
+ }
79
+ }
80
+ export function configValueText(value) {
81
+ if (value === null)
82
+ return 'null';
83
+ return String(value);
84
+ }
85
+ function parseBool(raw) {
86
+ const value = trimmed(raw).toLowerCase();
87
+ if (['true', 'on', 'yes', '1'].includes(value))
88
+ return true;
89
+ if (['false', 'off', 'no', '0'].includes(value))
90
+ return false;
91
+ throw new RecapError('CONFIG_VALUE_INVALID', `Expected true or false, got "${raw}"`);
92
+ }
93
+ function parseRange(key, raw, [low, high], unit) {
94
+ const text = trimmed(raw);
95
+ const value = /^[+-]?\d+$/.test(text) ? Number(text) : Number.NaN;
96
+ if (!Number.isInteger(value) || value < low || value > high) {
97
+ throw new RecapError('CONFIG_VALUE_INVALID', unit === 'seconds' ? `${key} takes whole seconds between ${low} and ${high}` : `${key} takes a whole number between ${low} and ${high}`);
98
+ }
99
+ return value;
100
+ }
101
+ function optionalModel(raw) {
102
+ const text = trimmed(raw);
103
+ return ['', 'null', 'none', 'default'].includes(text.toLowerCase()) ? undefined : text;
104
+ }
105
+ export function applyConfigValue(key, raw, live) {
106
+ const next = { ...(live ?? {}) };
107
+ switch (key) {
108
+ case 'live.enabled':
109
+ next.enabled = parseBool(raw);
110
+ break;
111
+ case 'live.openWindow':
112
+ next.openWindow = parseBool(raw);
113
+ break;
114
+ case 'live.proposals':
115
+ next.proposals = parseBool(raw);
116
+ break;
117
+ case 'live.assistModel':
118
+ next.assistModel = optionalModel(raw);
119
+ break;
120
+ case 'live.maxChunkSeconds':
121
+ next.maxChunkSeconds = parseRange(key, raw, RANGES.chunkSeconds, 'seconds');
122
+ break;
123
+ case 'live.autoAsk':
124
+ next.autoAsk = parseBool(raw);
125
+ break;
126
+ case 'live.autoAskModel':
127
+ next.autoAskModel = optionalModel(raw);
128
+ break;
129
+ case 'live.autoAskMinSeconds':
130
+ next.autoAskMinSeconds = parseRange(key, raw, RANGES.autoAskSeconds, 'seconds');
131
+ break;
132
+ case 'live.autoAskConcurrency':
133
+ next.autoAskConcurrency = parseRange(key, raw, RANGES.autoAskConcurrency, 'count');
134
+ break;
135
+ }
136
+ return next;
137
+ }
138
+ export function configSnapshot(live) {
139
+ return Object.fromEntries(CONFIG_KEYS.map((key) => [key, configValue(key, live)]));
140
+ }
@@ -0,0 +1,114 @@
1
+ import { mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { RecapError } from "../errors.js";
4
+ import { readText } from "./fsutil.js";
5
+ import { isAlive } from "./proc.js";
6
+ import { trimmed } from "./text.js";
7
+ export function sleepSync(ms) {
8
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
9
+ }
10
+ export function lockOwner(file) {
11
+ const text = readText(file);
12
+ if (text === null)
13
+ return null;
14
+ const value = trimmed(text);
15
+ return /^\d+$/.test(value) ? Number(value) : null;
16
+ }
17
+ function steal(target) {
18
+ const aside = `${target}.stale-${process.pid}-${Math.random().toString(36).slice(2)}`;
19
+ try {
20
+ renameSync(target, aside);
21
+ }
22
+ catch {
23
+ return;
24
+ }
25
+ rmSync(aside, { recursive: true, force: true });
26
+ }
27
+ export class ProcessLock {
28
+ file;
29
+ constructor(file, code = 'ALREADY_PROCESSING', message = (pid) => `The meeting is already being processed (pid ${pid})`) {
30
+ this.file = file;
31
+ mkdirSync(path.dirname(file), { recursive: true });
32
+ for (let attempt = 0;; attempt += 1) {
33
+ try {
34
+ writeFileSync(file, String(process.pid), { flag: 'wx' });
35
+ return;
36
+ }
37
+ catch (error) {
38
+ if (error.code !== 'EEXIST')
39
+ throw error;
40
+ }
41
+ const pid = lockOwner(file);
42
+ if (pid === process.pid)
43
+ return;
44
+ if (pid !== null && isAlive(pid))
45
+ throw new RecapError(code, message(pid));
46
+ if (pid === null && attempt < 20) {
47
+ sleepSync(10);
48
+ continue;
49
+ }
50
+ steal(file);
51
+ }
52
+ }
53
+ release() {
54
+ if (lockOwner(this.file) === process.pid)
55
+ rmSync(this.file, { force: true });
56
+ }
57
+ }
58
+ function tryLock(lockDir) {
59
+ const owner = path.join(lockDir, 'pid');
60
+ try {
61
+ mkdirSync(lockDir);
62
+ writeFileSync(owner, String(process.pid));
63
+ return true;
64
+ }
65
+ catch (error) {
66
+ if (error.code !== 'EEXIST')
67
+ throw new RecapError('WRITE_FAILED', `Cannot take the lock ${lockDir}: ${error.message}`);
68
+ const pid = lockOwner(owner);
69
+ let age = 0;
70
+ try {
71
+ age = Date.now() - statSync(lockDir).mtimeMs;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ if ((pid !== null && pid !== process.pid && !isAlive(pid)) || (pid === null && age > 2_000))
77
+ steal(lockDir);
78
+ return false;
79
+ }
80
+ }
81
+ function unlock(lockDir) {
82
+ if (lockOwner(path.join(lockDir, 'pid')) === process.pid)
83
+ rmSync(lockDir, { recursive: true, force: true });
84
+ }
85
+ export function withDirectoryLock(lockDir, body, timeoutMs = 15_000) {
86
+ mkdirSync(path.dirname(lockDir), { recursive: true });
87
+ const deadline = Date.now() + timeoutMs;
88
+ while (!tryLock(lockDir)) {
89
+ if (Date.now() > deadline)
90
+ throw new RecapError('WRITE_FAILED', `Timed out waiting for the lock ${lockDir}`);
91
+ sleepSync(5);
92
+ }
93
+ try {
94
+ return body();
95
+ }
96
+ finally {
97
+ unlock(lockDir);
98
+ }
99
+ }
100
+ export async function withDirectoryLockAsync(lockDir, body, timeoutMs = 60_000) {
101
+ mkdirSync(path.dirname(lockDir), { recursive: true });
102
+ const deadline = Date.now() + timeoutMs;
103
+ while (!tryLock(lockDir)) {
104
+ if (Date.now() > deadline)
105
+ throw new RecapError('WRITE_FAILED', `Timed out waiting for the lock ${lockDir}`);
106
+ await new Promise((resolve) => setTimeout(resolve, 20));
107
+ }
108
+ try {
109
+ return await body();
110
+ }
111
+ finally {
112
+ unlock(lockDir);
113
+ }
114
+ }