@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,498 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
3
|
+
import { isRecord, swiftLine } from "../core/json.js";
|
|
4
|
+
import { isoNow } from "../core/dates.js";
|
|
5
|
+
import { sleep, signalTree } from "../core/proc.js";
|
|
6
|
+
import { prefix, replaceAll, suffix, trimmed } from "../core/text.js";
|
|
7
|
+
import { RecapError, describeError } from "../errors.js";
|
|
8
|
+
import { loadResource } from "../pipeline/resources.js";
|
|
9
|
+
import { decodeSegment, orderSegments } from "../pipeline/transcript.js";
|
|
10
|
+
import { boardEntries, makeAskId, removeBoard, writeBoard } from "./asking.js";
|
|
11
|
+
import { isDuplicateQuestion } from "./dedupe.js";
|
|
12
|
+
import { liveFiles, readLines } from "./files.js";
|
|
13
|
+
import { renderLive } from "./merger.js";
|
|
14
|
+
import { clockMilliseconds, resolveOrigin } from "./origin.js";
|
|
15
|
+
import { decodeAnswer } from "./stream.js";
|
|
16
|
+
function detectorError(text) {
|
|
17
|
+
return new RecapError('DETECTOR_OUTPUT', `Unexpected detector output: ${prefix(trimmed(text), 200)}`);
|
|
18
|
+
}
|
|
19
|
+
function findJson(text) {
|
|
20
|
+
const candidates = [
|
|
21
|
+
['{', '}'],
|
|
22
|
+
['[', ']'],
|
|
23
|
+
];
|
|
24
|
+
const position = (char) => {
|
|
25
|
+
const index = text.indexOf(char);
|
|
26
|
+
return index === -1 ? text.length : index;
|
|
27
|
+
};
|
|
28
|
+
candidates.sort((left, right) => position(left[0]) - position(right[0]));
|
|
29
|
+
for (const [open, close] of candidates) {
|
|
30
|
+
const start = text.indexOf(open);
|
|
31
|
+
const end = text.lastIndexOf(close);
|
|
32
|
+
if (start === -1 || end === -1 || start >= end)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text.slice(start, end + 1));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
function cleanQuestion(question) {
|
|
44
|
+
const text = trimmed(question);
|
|
45
|
+
return text.length === 0 || text.toLowerCase() === 'null' ? null : text;
|
|
46
|
+
}
|
|
47
|
+
function questionItem(object) {
|
|
48
|
+
const value = object['question'];
|
|
49
|
+
if (value === undefined || value === null)
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof value !== 'string')
|
|
52
|
+
throw new RecapError('DETECTOR_OUTPUT', `Unexpected detector question: ${prefix(JSON.stringify(value), 200)}`);
|
|
53
|
+
const text = cleanQuestion(value);
|
|
54
|
+
return text === null ? null : { text, atMs: clockMilliseconds(object['at']) };
|
|
55
|
+
}
|
|
56
|
+
function questionItems(list) {
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const element of list) {
|
|
59
|
+
if (typeof element === 'string') {
|
|
60
|
+
const text = cleanQuestion(element);
|
|
61
|
+
if (text !== null)
|
|
62
|
+
result.push({ text, atMs: null });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(element))
|
|
66
|
+
throw new RecapError('DETECTOR_OUTPUT', `Unexpected detector question: ${prefix(JSON.stringify(element) ?? String(element), 200)}`);
|
|
67
|
+
const item = questionItem(element);
|
|
68
|
+
if (item)
|
|
69
|
+
result.push(item);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
export function parseDetectorResponse(text) {
|
|
74
|
+
const value = findJson(text);
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
throw detectorError(text);
|
|
77
|
+
if (Array.isArray(value))
|
|
78
|
+
return questionItems(value);
|
|
79
|
+
if (!isRecord(value))
|
|
80
|
+
throw detectorError(text);
|
|
81
|
+
if ('questions' in value) {
|
|
82
|
+
const list = value['questions'];
|
|
83
|
+
if (list === null)
|
|
84
|
+
return [];
|
|
85
|
+
if (!Array.isArray(list))
|
|
86
|
+
throw new RecapError('DETECTOR_OUTPUT', `Unexpected detector questions: ${prefix(JSON.stringify(list), 200)}`);
|
|
87
|
+
return questionItems(list);
|
|
88
|
+
}
|
|
89
|
+
if (!('question' in value))
|
|
90
|
+
throw detectorError(text);
|
|
91
|
+
const item = questionItem(value);
|
|
92
|
+
return item ? [item] : [];
|
|
93
|
+
}
|
|
94
|
+
export class DetectionPacer {
|
|
95
|
+
minSeconds;
|
|
96
|
+
lastRun = null;
|
|
97
|
+
pending = true;
|
|
98
|
+
constructor(minSeconds) {
|
|
99
|
+
this.minSeconds = minSeconds;
|
|
100
|
+
}
|
|
101
|
+
grew() {
|
|
102
|
+
this.pending = true;
|
|
103
|
+
}
|
|
104
|
+
settle(remaining) {
|
|
105
|
+
if (remaining)
|
|
106
|
+
this.pending = true;
|
|
107
|
+
}
|
|
108
|
+
shouldRun(now) {
|
|
109
|
+
if (!this.pending)
|
|
110
|
+
return false;
|
|
111
|
+
if (this.lastRun !== null && (now - this.lastRun) / 1000 < this.minSeconds)
|
|
112
|
+
return false;
|
|
113
|
+
this.pending = false;
|
|
114
|
+
this.lastRun = now;
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export const START_CURSOR = { detectedThroughMs: 0, examinedSegments: 0 };
|
|
119
|
+
export function loadCursor(meetingDir) {
|
|
120
|
+
const text = readText(liveFiles.detectorState(meetingDir));
|
|
121
|
+
if (text === null)
|
|
122
|
+
return { ...START_CURSOR };
|
|
123
|
+
try {
|
|
124
|
+
const value = JSON.parse(text);
|
|
125
|
+
if (isRecord(value) && typeof value['detectedThroughMs'] === 'number' && typeof value['examinedSegments'] === 'number') {
|
|
126
|
+
return { detectedThroughMs: value['detectedThroughMs'], examinedSegments: value['examinedSegments'] };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { ...START_CURSOR };
|
|
131
|
+
}
|
|
132
|
+
return { ...START_CURSOR };
|
|
133
|
+
}
|
|
134
|
+
export function saveCursor(meetingDir, cursor) {
|
|
135
|
+
writeAtomic(liveFiles.detectorState(meetingDir), swiftLine({ detectedThroughMs: cursor.detectedThroughMs, examinedSegments: cursor.examinedSegments }));
|
|
136
|
+
}
|
|
137
|
+
export const REVIEWED_SECONDS = 60;
|
|
138
|
+
export function detectorBatch(segments, cursor) {
|
|
139
|
+
const examined = Math.min(Math.max(0, cursor.examinedSegments), segments.length);
|
|
140
|
+
const from = cursor.detectedThroughMs - REVIEWED_SECONDS * 1000;
|
|
141
|
+
const reviewed = orderSegments(segments.slice(0, examined).filter((segment) => segment.endMs >= from && trimmed(segment.text).length > 0));
|
|
142
|
+
const tail = segments.slice(examined);
|
|
143
|
+
const fresh = orderSegments(tail.filter((segment) => trimmed(segment.text).length > 0));
|
|
144
|
+
const through = tail.length > 0 ? Math.max(...tail.map((segment) => segment.endMs)) : cursor.detectedThroughMs;
|
|
145
|
+
return { reviewed, fresh, cursor: { detectedThroughMs: Math.max(cursor.detectedThroughMs, through), examinedSegments: segments.length } };
|
|
146
|
+
}
|
|
147
|
+
export function renderDetectPrompt(template, meeting, context, batch, known) {
|
|
148
|
+
const labelled = meeting.mode === 'remote';
|
|
149
|
+
const title = meeting.bitaEntry?.title ?? meeting.title;
|
|
150
|
+
const pages = context.pages.length === 0
|
|
151
|
+
? ' (sin páginas registradas para este proyecto)'
|
|
152
|
+
: context.pages.map((page) => ` ${' '.repeat(Math.max(0, page.depth))}- ${page.title}`).join('\n');
|
|
153
|
+
const speakers = labelled
|
|
154
|
+
? 'Es una reunión remota: «Sala» es el micrófono de quien graba y «Remotos» es el audio de la llamada. Las preguntas que vienen de «Remotos» suelen ir dirigidas a quien graba y pesan más; una pregunta de «Sala» solo cuenta si es claramente una duda técnica abierta.'
|
|
155
|
+
: 'Es una reunión presencial con un solo micrófono («Sala»), sin separación por persona: juzga solo por el contenido de lo que se dice.';
|
|
156
|
+
const knownList = known.length === 0 ? '(ninguna)' : known.map((question) => `- ${question}`).join('\n');
|
|
157
|
+
const reviewed = batch.reviewed.length === 0 ? '(nada)' : renderLive(batch.reviewed, labelled);
|
|
158
|
+
let text = template;
|
|
159
|
+
text = replaceAll(text, '{{title}}', title);
|
|
160
|
+
text = replaceAll(text, '{{project}}', context.project ?? 'sin proyecto');
|
|
161
|
+
text = replaceAll(text, '{{pages}}', pages);
|
|
162
|
+
text = replaceAll(text, '{{speakers}}', speakers);
|
|
163
|
+
text = replaceAll(text, '{{known}}', knownList);
|
|
164
|
+
text = replaceAll(text, '{{reviewed}}', reviewed);
|
|
165
|
+
text = replaceAll(text, '{{transcript}}', renderLive(batch.fresh, labelled));
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
export function readTranscript(meetingDir) {
|
|
169
|
+
return readLines(liveFiles.transcript(meetingDir), decodeSegment);
|
|
170
|
+
}
|
|
171
|
+
export class QuestionDetector {
|
|
172
|
+
dir;
|
|
173
|
+
meeting;
|
|
174
|
+
dependencies;
|
|
175
|
+
pacer;
|
|
176
|
+
busy = null;
|
|
177
|
+
stopped = false;
|
|
178
|
+
detected = [];
|
|
179
|
+
cachedContext = null;
|
|
180
|
+
constructor(dir, meeting, minSeconds, dependencies) {
|
|
181
|
+
this.dir = dir;
|
|
182
|
+
this.meeting = meeting;
|
|
183
|
+
this.dependencies = dependencies;
|
|
184
|
+
this.pacer = new DetectionPacer(minSeconds);
|
|
185
|
+
}
|
|
186
|
+
get detectedQuestions() {
|
|
187
|
+
return [...this.detected];
|
|
188
|
+
}
|
|
189
|
+
now() {
|
|
190
|
+
return this.dependencies.now?.() ?? Date.now();
|
|
191
|
+
}
|
|
192
|
+
transcriptGrew() {
|
|
193
|
+
this.pacer.grew();
|
|
194
|
+
}
|
|
195
|
+
tick() {
|
|
196
|
+
if (this.stopped || this.busy !== null || !this.pacer.shouldRun(this.now()))
|
|
197
|
+
return false;
|
|
198
|
+
this.busy = (async () => {
|
|
199
|
+
const outcome = await this.runCycle();
|
|
200
|
+
const remaining = outcome.type === 'failed' ? true : this.hasUnexamined();
|
|
201
|
+
this.pacer.settle(remaining);
|
|
202
|
+
})().finally(() => {
|
|
203
|
+
this.busy = null;
|
|
204
|
+
});
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
async waitUntilIdle(timeoutSeconds) {
|
|
208
|
+
const current = this.busy;
|
|
209
|
+
if (!current)
|
|
210
|
+
return true;
|
|
211
|
+
let timer;
|
|
212
|
+
const timeout = new Promise((resolve) => {
|
|
213
|
+
timer = setTimeout(() => resolve(false), timeoutSeconds * 1000);
|
|
214
|
+
});
|
|
215
|
+
try {
|
|
216
|
+
return await Promise.race([current.then(() => true), timeout]);
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
clearTimeout(timer);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async stop(timeoutSeconds = 20) {
|
|
223
|
+
this.stopped = true;
|
|
224
|
+
if (!(await this.waitUntilIdle(timeoutSeconds)))
|
|
225
|
+
this.dependencies.log(`question detector did not stop within ${timeoutSeconds} s`);
|
|
226
|
+
await this.dependencies.cancelAsks?.();
|
|
227
|
+
}
|
|
228
|
+
hasUnexamined() {
|
|
229
|
+
return readTranscript(this.dir).length > loadCursor(this.dir).examinedSegments;
|
|
230
|
+
}
|
|
231
|
+
async runCycle() {
|
|
232
|
+
const outcome = await this.detect();
|
|
233
|
+
if (outcome.type === 'examined')
|
|
234
|
+
for (const question of outcome.duplicates)
|
|
235
|
+
this.dependencies.log(`auto ask: skipped duplicate «${question}»`);
|
|
236
|
+
if (outcome.type === 'failed')
|
|
237
|
+
this.dependencies.log(`auto ask: detection failed: ${outcome.message}`);
|
|
238
|
+
return outcome;
|
|
239
|
+
}
|
|
240
|
+
async detect() {
|
|
241
|
+
if (this.stopped)
|
|
242
|
+
return { type: 'idle' };
|
|
243
|
+
const segments = readTranscript(this.dir);
|
|
244
|
+
const batch = detectorBatch(segments, loadCursor(this.dir));
|
|
245
|
+
if (batch.fresh.length === 0) {
|
|
246
|
+
if (batch.cursor.examinedSegments !== loadCursor(this.dir).examinedSegments) {
|
|
247
|
+
try {
|
|
248
|
+
saveCursor(this.dir, batch.cursor);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
return { type: 'idle' };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { type: 'idle' };
|
|
255
|
+
}
|
|
256
|
+
const known = this.knownQuestions();
|
|
257
|
+
let found;
|
|
258
|
+
try {
|
|
259
|
+
const template = this.dependencies.template ? this.dependencies.template() : loadResource('detect-prompt.md');
|
|
260
|
+
const prompt = renderDetectPrompt(template, this.meeting, await this.context(), batch, known);
|
|
261
|
+
found = parseDetectorResponse(await this.dependencies.complete(prompt));
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
return { type: 'failed', message: describeError(error) };
|
|
265
|
+
}
|
|
266
|
+
const queued = [];
|
|
267
|
+
const duplicates = [];
|
|
268
|
+
for (const detection of found) {
|
|
269
|
+
const question = detection.text;
|
|
270
|
+
if (isDuplicateQuestion(question, known)) {
|
|
271
|
+
duplicates.push(question);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (this.stopped)
|
|
275
|
+
break;
|
|
276
|
+
this.detected.push(question);
|
|
277
|
+
known.push(question);
|
|
278
|
+
const origin = detection.atMs === null ? null : resolveOrigin(detection.atMs, [...batch.reviewed, ...batch.fresh], question);
|
|
279
|
+
this.dependencies.log(`auto ask: detected «${question}»${origin ? ` at ${origin.questionMs} ms (${origin.channel})` : ''}`);
|
|
280
|
+
this.dependencies.enqueue(question, origin);
|
|
281
|
+
queued.push(question);
|
|
282
|
+
}
|
|
283
|
+
if (this.stopped && queued.length < found.length - duplicates.length)
|
|
284
|
+
return { type: 'examined', queued, duplicates };
|
|
285
|
+
try {
|
|
286
|
+
saveCursor(this.dir, batch.cursor);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
return { type: 'failed', message: describeError(error) };
|
|
290
|
+
}
|
|
291
|
+
return { type: 'examined', queued, duplicates };
|
|
292
|
+
}
|
|
293
|
+
knownQuestions() {
|
|
294
|
+
const answered = readLines(liveFiles.answers(this.dir), decodeAnswer).map((answer) => answer.question);
|
|
295
|
+
const asking = boardEntries(this.dir).flatMap((state) => (state.question === null ? [] : [state.question]));
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
return [...answered, ...asking, ...this.detected].filter((question) => {
|
|
298
|
+
if (trimmed(question).length === 0 || seen.has(question))
|
|
299
|
+
return false;
|
|
300
|
+
seen.add(question);
|
|
301
|
+
return true;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
async context() {
|
|
305
|
+
if (this.cachedContext)
|
|
306
|
+
return this.cachedContext;
|
|
307
|
+
this.cachedContext = await this.dependencies.context();
|
|
308
|
+
return this.cachedContext;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
export class AutoAskQueue {
|
|
312
|
+
dir;
|
|
313
|
+
concurrency;
|
|
314
|
+
pid;
|
|
315
|
+
now;
|
|
316
|
+
execute;
|
|
317
|
+
cancelRunning;
|
|
318
|
+
log;
|
|
319
|
+
waiting = [];
|
|
320
|
+
running = new Map();
|
|
321
|
+
stopped = false;
|
|
322
|
+
constructor(options) {
|
|
323
|
+
this.dir = options.dir;
|
|
324
|
+
this.concurrency = Math.max(1, options.concurrency);
|
|
325
|
+
this.pid = options.pid ?? process.pid;
|
|
326
|
+
this.now = options.now ?? isoNow;
|
|
327
|
+
this.execute = options.run;
|
|
328
|
+
this.cancelRunning = options.cancelRunning ?? (() => undefined);
|
|
329
|
+
this.log = options.log;
|
|
330
|
+
}
|
|
331
|
+
get runningCount() {
|
|
332
|
+
return this.running.size;
|
|
333
|
+
}
|
|
334
|
+
get waitingCount() {
|
|
335
|
+
return this.waiting.length;
|
|
336
|
+
}
|
|
337
|
+
enqueue(question, origin) {
|
|
338
|
+
if (this.stopped)
|
|
339
|
+
return null;
|
|
340
|
+
const job = { id: makeAskId(), question, origin, queuedAt: this.now() };
|
|
341
|
+
try {
|
|
342
|
+
writeBoard(this.dir, this.asking(job, 'queued'));
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
this.log(`auto ask: cannot write the queued ask: ${describeError(error)}`);
|
|
346
|
+
}
|
|
347
|
+
this.waiting.push(job);
|
|
348
|
+
const ahead = this.waiting.length - 1;
|
|
349
|
+
this.log(`auto ask: queued «${question}» as ${job.id}${ahead > 0 ? ` (${ahead} waiting ahead)` : ''}`);
|
|
350
|
+
this.pump();
|
|
351
|
+
return job;
|
|
352
|
+
}
|
|
353
|
+
async waitUntilIdle(timeoutSeconds) {
|
|
354
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
355
|
+
while (this.running.size > 0 || (this.waiting.length > 0 && !this.stopped)) {
|
|
356
|
+
if (Date.now() > deadline)
|
|
357
|
+
return false;
|
|
358
|
+
const pending = [...this.running.values()];
|
|
359
|
+
if (pending.length === 0) {
|
|
360
|
+
await sleep(10);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
let timer;
|
|
364
|
+
await Promise.race([
|
|
365
|
+
Promise.allSettled(pending),
|
|
366
|
+
new Promise((resolve) => {
|
|
367
|
+
timer = setTimeout(resolve, Math.max(0, deadline - Date.now()));
|
|
368
|
+
}),
|
|
369
|
+
]);
|
|
370
|
+
clearTimeout(timer);
|
|
371
|
+
}
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
async cancelAll(timeoutSeconds = 10) {
|
|
375
|
+
this.stopped = true;
|
|
376
|
+
const dropped = this.waiting;
|
|
377
|
+
this.waiting = [];
|
|
378
|
+
for (const job of dropped) {
|
|
379
|
+
removeBoard(this.dir, job.id);
|
|
380
|
+
this.log(`auto ask: dropped «${job.question}» because the worker stopped`);
|
|
381
|
+
}
|
|
382
|
+
this.cancelRunning();
|
|
383
|
+
if (!(await this.waitUntilIdle(timeoutSeconds)))
|
|
384
|
+
this.log(`auto asks did not stop within ${timeoutSeconds} s`);
|
|
385
|
+
}
|
|
386
|
+
pump() {
|
|
387
|
+
while (!this.stopped && this.running.size < this.concurrency && this.waiting.length > 0) {
|
|
388
|
+
const job = this.waiting.shift();
|
|
389
|
+
if (!job)
|
|
390
|
+
break;
|
|
391
|
+
try {
|
|
392
|
+
writeBoard(this.dir, this.asking(job, 'running'));
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
this.log(`auto ask: cannot mark ${job.id} as running`);
|
|
396
|
+
}
|
|
397
|
+
this.running.set(job.id, this.run(job));
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async run(job) {
|
|
401
|
+
const started = Date.now();
|
|
402
|
+
this.log(`auto ask: started «${job.question}» (${job.id})`);
|
|
403
|
+
try {
|
|
404
|
+
await this.execute(job);
|
|
405
|
+
this.log(`auto ask: answered «${job.question}» in ${((Date.now() - started) / 1000).toFixed(1)} s`);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
this.log(`auto ask: failed «${job.question}»: ${describeError(error)}`);
|
|
409
|
+
}
|
|
410
|
+
removeBoard(this.dir, job.id);
|
|
411
|
+
this.running.delete(job.id);
|
|
412
|
+
this.pump();
|
|
413
|
+
}
|
|
414
|
+
asking(job, phase) {
|
|
415
|
+
return {
|
|
416
|
+
id: job.id,
|
|
417
|
+
question: job.question,
|
|
418
|
+
startedAt: phase === 'queued' ? job.queuedAt : this.now(),
|
|
419
|
+
auto: true,
|
|
420
|
+
questionMs: job.origin?.questionMs,
|
|
421
|
+
channel: job.origin?.channel,
|
|
422
|
+
state: phase,
|
|
423
|
+
pid: this.pid,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export function autoAskArguments(dir, question, origin = null, askId) {
|
|
428
|
+
const args = ['ask', '--dir', dir, '--question', question, '--auto', '--json'];
|
|
429
|
+
if (origin)
|
|
430
|
+
args.push('--question-ms', String(origin.questionMs), '--channel', origin.channel);
|
|
431
|
+
if (askId)
|
|
432
|
+
args.push('--ask-id', askId);
|
|
433
|
+
return args;
|
|
434
|
+
}
|
|
435
|
+
export function envelopeFailure(output) {
|
|
436
|
+
for (const line of output.split('\n').reverse()) {
|
|
437
|
+
try {
|
|
438
|
+
const object = JSON.parse(line);
|
|
439
|
+
if (isRecord(object) && isRecord(object['error']) && typeof object['error']['code'] === 'string') {
|
|
440
|
+
const error = object['error'];
|
|
441
|
+
return new RecapError(error['code'], typeof error['message'] === 'string' ? error['message'] : error['code']);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
export class AutoAskProcess {
|
|
451
|
+
dir;
|
|
452
|
+
command;
|
|
453
|
+
processes = new Map();
|
|
454
|
+
constructor(dir, command) {
|
|
455
|
+
this.dir = dir;
|
|
456
|
+
this.command = command;
|
|
457
|
+
}
|
|
458
|
+
run(job) {
|
|
459
|
+
const [executable, ...prefixArgs] = this.command;
|
|
460
|
+
return new Promise((resolve, reject) => {
|
|
461
|
+
const child = spawn(executable ?? process.execPath, [...prefixArgs, ...autoAskArguments(this.dir, job.question, job.origin, job.id)], {
|
|
462
|
+
cwd: this.dir,
|
|
463
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
464
|
+
detached: process.platform !== 'win32',
|
|
465
|
+
windowsHide: true,
|
|
466
|
+
});
|
|
467
|
+
let stdout = '';
|
|
468
|
+
let stderr = '';
|
|
469
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => (stdout += chunk));
|
|
470
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
|
|
471
|
+
if (child.pid !== undefined)
|
|
472
|
+
this.processes.set(job.id, child.pid);
|
|
473
|
+
child.once('error', (error) => {
|
|
474
|
+
this.processes.delete(job.id);
|
|
475
|
+
reject(new RecapError('ASK_FAILED', error.message));
|
|
476
|
+
});
|
|
477
|
+
child.once('close', (code, signal) => {
|
|
478
|
+
this.processes.delete(job.id);
|
|
479
|
+
if (signal !== null || code === 129 || code === 130 || code === 143) {
|
|
480
|
+
reject(new RecapError('ASK_INTERRUPTED', `The auto ask was stopped (status ${code ?? signal})`));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (code !== 0) {
|
|
484
|
+
const failure = envelopeFailure(stdout);
|
|
485
|
+
if (failure)
|
|
486
|
+
return reject(failure);
|
|
487
|
+
const detail = trimmed(stderr);
|
|
488
|
+
return reject(new RecapError('ASK_FAILED', detail.length === 0 ? `recap ask exited with status ${code}` : suffix(detail, 400)));
|
|
489
|
+
}
|
|
490
|
+
resolve();
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
cancel() {
|
|
495
|
+
for (const pid of this.processes.values())
|
|
496
|
+
signalTree(pid, 'SIGTERM');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readText } from "../core/fsutil.js";
|
|
4
|
+
import { swiftLine } from "../core/json.js";
|
|
5
|
+
export const LIVE_DIR = 'live';
|
|
6
|
+
export const CHUNKS_DIR = 'chunks';
|
|
7
|
+
export const liveFiles = {
|
|
8
|
+
dir: (meetingDir) => path.join(meetingDir, LIVE_DIR),
|
|
9
|
+
chunks: (meetingDir) => path.join(meetingDir, LIVE_DIR, CHUNKS_DIR),
|
|
10
|
+
chunkIndex: (meetingDir) => path.join(meetingDir, LIVE_DIR, CHUNKS_DIR, 'index.jsonl'),
|
|
11
|
+
transcript: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'transcript.jsonl'),
|
|
12
|
+
answers: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'answers.jsonl'),
|
|
13
|
+
workerLock: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'worker.pid'),
|
|
14
|
+
workerState: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'worker-state.json'),
|
|
15
|
+
detectorState: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'detector-state.json'),
|
|
16
|
+
asking: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'asking.json'),
|
|
17
|
+
askingDir: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'asking'),
|
|
18
|
+
workerLog: (meetingDir) => path.join(meetingDir, LIVE_DIR, 'worker.log'),
|
|
19
|
+
};
|
|
20
|
+
export function jsonLine(value) {
|
|
21
|
+
return swiftLine(value);
|
|
22
|
+
}
|
|
23
|
+
export function appendLines(values, file) {
|
|
24
|
+
if (values.length === 0)
|
|
25
|
+
return;
|
|
26
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
27
|
+
appendFileSync(file, values.map((value) => `${jsonLine(value)}\n`).join(''));
|
|
28
|
+
}
|
|
29
|
+
export function parseLines(text, decode) {
|
|
30
|
+
const result = [];
|
|
31
|
+
for (const line of text.split('\n')) {
|
|
32
|
+
if (line.trim().length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
try {
|
|
35
|
+
const decoded = decode(JSON.parse(line));
|
|
36
|
+
if (decoded !== null)
|
|
37
|
+
result.push(decoded);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
export function readLines(file, decode) {
|
|
46
|
+
const text = readText(file);
|
|
47
|
+
return text === null ? [] : parseLines(text, decode);
|
|
48
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readText } from "../core/fsutil.js";
|
|
2
|
+
import { setsEqual, tokens } from "../core/text.js";
|
|
3
|
+
import { clock } from "../core/dates.js";
|
|
4
|
+
import { ECHO_WINDOW_MS, decodeSegment, isEcho, orderSegments, paragraphs, speakerLabel } from "../pipeline/transcript.js";
|
|
5
|
+
import { liveFiles, readLines } from "./files.js";
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
export const SYSTEM_MEMORY_MS = 60_000;
|
|
8
|
+
export const REPEAT_WINDOW_MS = 30_000;
|
|
9
|
+
export class LiveMerger {
|
|
10
|
+
hasSystem;
|
|
11
|
+
holdSeconds;
|
|
12
|
+
systemCoveredMs = 0;
|
|
13
|
+
recentSystem = [];
|
|
14
|
+
pendingMic = [];
|
|
15
|
+
lastText = {};
|
|
16
|
+
constructor(hasSystem, holdSeconds) {
|
|
17
|
+
this.hasSystem = hasSystem;
|
|
18
|
+
this.holdSeconds = holdSeconds;
|
|
19
|
+
}
|
|
20
|
+
get pendingCount() {
|
|
21
|
+
return this.pendingMic.length;
|
|
22
|
+
}
|
|
23
|
+
add(segments, channel, coveredUntilMs, now) {
|
|
24
|
+
const fresh = segments.filter((segment) => !this.isRepeat(segment));
|
|
25
|
+
if (channel === 'system') {
|
|
26
|
+
this.recentSystem.push(...fresh);
|
|
27
|
+
this.systemCoveredMs = Math.max(this.systemCoveredMs, coveredUntilMs);
|
|
28
|
+
const horizon = this.systemCoveredMs - SYSTEM_MEMORY_MS;
|
|
29
|
+
this.recentSystem = this.recentSystem.filter((segment) => segment.endMs >= horizon);
|
|
30
|
+
return orderSegments([...fresh, ...this.release(now, false)]);
|
|
31
|
+
}
|
|
32
|
+
if (!this.hasSystem)
|
|
33
|
+
return orderSegments(fresh);
|
|
34
|
+
this.pendingMic.push(...fresh.map((segment) => ({ segment, addedAt: now })));
|
|
35
|
+
return orderSegments(this.release(now, false));
|
|
36
|
+
}
|
|
37
|
+
advance(channel, toMs, now) {
|
|
38
|
+
if (channel === 'system')
|
|
39
|
+
this.systemCoveredMs = Math.max(this.systemCoveredMs, toMs);
|
|
40
|
+
return orderSegments(this.release(now, false));
|
|
41
|
+
}
|
|
42
|
+
release(now, force) {
|
|
43
|
+
const ready = [];
|
|
44
|
+
const waiting = [];
|
|
45
|
+
for (const item of this.pendingMic) {
|
|
46
|
+
const covered = item.segment.endMs + ECHO_WINDOW_MS <= this.systemCoveredMs;
|
|
47
|
+
const expired = (now - item.addedAt) / 1000 >= this.holdSeconds;
|
|
48
|
+
if (force || covered || expired) {
|
|
49
|
+
if (!this.recentSystem.some((system) => isEcho(item.segment, system)))
|
|
50
|
+
ready.push(item.segment);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
waiting.push(item);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.pendingMic = waiting;
|
|
57
|
+
return orderSegments(ready);
|
|
58
|
+
}
|
|
59
|
+
isRepeat(segment) {
|
|
60
|
+
const previous = this.lastText[segment.channel];
|
|
61
|
+
this.lastText[segment.channel] = segment;
|
|
62
|
+
if (!previous)
|
|
63
|
+
return false;
|
|
64
|
+
const current = tokens(segment.text);
|
|
65
|
+
return segment.startMs - previous.endMs <= REPEAT_WINDOW_MS && current.size > 0 && setsEqual(tokens(previous.text), current);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function loadLiveTranscript(meetingDir) {
|
|
69
|
+
const live = readLines(liveFiles.transcript(meetingDir), decodeSegment);
|
|
70
|
+
if (live.length > 0)
|
|
71
|
+
return orderSegments(live);
|
|
72
|
+
const text = readText(path.join(meetingDir, 'transcript.json'));
|
|
73
|
+
if (text === null)
|
|
74
|
+
return [];
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(text);
|
|
77
|
+
if (!Array.isArray(value))
|
|
78
|
+
return [];
|
|
79
|
+
const segments = value.map(decodeSegment);
|
|
80
|
+
if (segments.some((segment) => segment === null))
|
|
81
|
+
return [];
|
|
82
|
+
return orderSegments(segments);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function transcriptWindow(segments, seconds) {
|
|
89
|
+
if (segments.length === 0)
|
|
90
|
+
return [];
|
|
91
|
+
const latest = Math.max(...segments.map((segment) => segment.endMs));
|
|
92
|
+
const from = latest - Math.max(1, seconds) * 1000;
|
|
93
|
+
return segments.filter((segment) => segment.endMs >= from);
|
|
94
|
+
}
|
|
95
|
+
export function renderLive(segments, labelled) {
|
|
96
|
+
const grouped = paragraphs(segments);
|
|
97
|
+
if (grouped.length === 0)
|
|
98
|
+
return '_Todavía no hay transcripción._';
|
|
99
|
+
return grouped.map((paragraph) => `[${clock(paragraph.startMs)}]${labelled ? ` ${speakerLabel(paragraph.channel)}:` : ''} ${paragraph.text}`).join('\n');
|
|
100
|
+
}
|