@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,91 @@
1
+ import { trimmed } from "../core/text.js";
2
+ import { PARAGRAPH_GAP_MS, PARAGRAPH_SPAN_MS, orderSegments } from "../pipeline/transcript.js";
3
+ import { questionSimilarity } from "./dedupe.js";
4
+ export function clockMilliseconds(value) {
5
+ if (typeof value !== 'string')
6
+ return null;
7
+ let body = trimmed(value);
8
+ if (body.length === 0)
9
+ return null;
10
+ if (body.startsWith('[') && body.endsWith(']'))
11
+ body = body.slice(1, -1);
12
+ const parts = body.split(':');
13
+ if (parts.length !== 2 && parts.length !== 3)
14
+ return null;
15
+ if (!parts.every((part) => /^[0-9]{1,2}$/.test(part)))
16
+ return null;
17
+ const numbers = parts.map(Number);
18
+ const [hours, minutes, seconds] = numbers.length === 3 ? numbers : [0, numbers[0] ?? 0, numbers[1] ?? 0];
19
+ if (minutes >= 60 || seconds >= 60)
20
+ return null;
21
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000;
22
+ }
23
+ function best(indices, ordered, question) {
24
+ const text = question ? trimmed(question) : '';
25
+ if (text.length === 0)
26
+ return null;
27
+ let winner = null;
28
+ for (const index of indices) {
29
+ const segment = ordered[index];
30
+ if (!segment)
31
+ continue;
32
+ const score = questionSimilarity(text, segment.text);
33
+ if (score > (winner?.score ?? 0))
34
+ winner = { index, score };
35
+ }
36
+ return winner?.index ?? null;
37
+ }
38
+ function anchor(atMs, ordered, question) {
39
+ if (ordered.length === 0)
40
+ return null;
41
+ const second = Math.trunc(atMs / 1000);
42
+ const indices = ordered.map((_, index) => index);
43
+ const exact = indices.filter((index) => Math.trunc((ordered[index]?.startMs ?? 0) / 1000) === second);
44
+ if (exact.length > 0)
45
+ return best(exact, ordered, question) ?? exact[0] ?? null;
46
+ const until = second * 1000 + 999;
47
+ const covering = indices.filter((index) => (ordered[index]?.startMs ?? 0) <= until && (ordered[index]?.endMs ?? 0) >= atMs).pop();
48
+ if (covering !== undefined)
49
+ return covering;
50
+ const before = indices.filter((index) => (ordered[index]?.startMs ?? 0) <= until).pop();
51
+ if (before !== undefined)
52
+ return before;
53
+ let closest = 0;
54
+ for (const index of indices) {
55
+ if (Math.abs((ordered[index]?.startMs ?? 0) - atMs) < Math.abs((ordered[closest]?.startMs ?? 0) - atMs))
56
+ closest = index;
57
+ }
58
+ return closest;
59
+ }
60
+ function paragraphFrom(index, ordered) {
61
+ const first = ordered[index];
62
+ if (!first)
63
+ return [];
64
+ const members = [first];
65
+ let lastEnd = first.endMs;
66
+ for (const segment of ordered.slice(index + 1)) {
67
+ if (segment.channel !== first.channel || segment.startMs - lastEnd > PARAGRAPH_GAP_MS || segment.startMs - first.startMs >= PARAGRAPH_SPAN_MS)
68
+ break;
69
+ members.push(segment);
70
+ lastEnd = Math.max(lastEnd, segment.endMs);
71
+ }
72
+ return members;
73
+ }
74
+ export function resolveOrigin(atMs, segments, question) {
75
+ const ordered = orderSegments(segments.filter((segment) => trimmed(segment.text).length > 0));
76
+ const index = anchor(atMs, ordered, question);
77
+ if (index === null)
78
+ return null;
79
+ const members = paragraphFrom(index, ordered);
80
+ let chosen = ordered[index];
81
+ if (members.length > 1) {
82
+ const refined = best(members.map((_, i) => i), members, question);
83
+ if (refined !== null)
84
+ chosen = members[refined];
85
+ }
86
+ return chosen ? { questionMs: chosen.startMs, channel: chosen.channel } : null;
87
+ }
88
+ export function resolveOriginClock(value, segments, question) {
89
+ const atMs = clockMilliseconds(value);
90
+ return atMs === null ? null : resolveOrigin(atMs, segments, question);
91
+ }
@@ -0,0 +1,390 @@
1
+ import { asIntLoose, isRecord, parseJsonSafe } from "../core/json.js";
2
+ import { home } from "../core/paths.js";
3
+ import { fold, replaceAll, suffix, trimmed } from "../core/text.js";
4
+ import { RecapError } from "../errors.js";
5
+ import { clockMilliseconds, resolveOrigin } from "./origin.js";
6
+ export function parseStreamLine(line) {
7
+ const object = parseJsonSafe(line);
8
+ if (!isRecord(object) || typeof object['type'] !== 'string')
9
+ return [];
10
+ switch (object['type']) {
11
+ case 'stream_event': {
12
+ const event = object['event'];
13
+ if (!isRecord(event) || event['type'] !== 'content_block_delta')
14
+ return [];
15
+ const delta = event['delta'];
16
+ if (!isRecord(delta) || delta['type'] !== 'text_delta' || typeof delta['text'] !== 'string' || delta['text'].length === 0)
17
+ return [];
18
+ return [{ type: 'text', text: delta['text'] }];
19
+ }
20
+ case 'assistant': {
21
+ const message = object['message'];
22
+ const content = isRecord(message) && Array.isArray(message['content']) ? message['content'] : [];
23
+ const events = [];
24
+ for (const block of content) {
25
+ if (!isRecord(block) || block['type'] !== 'tool_use' || typeof block['name'] !== 'string')
26
+ continue;
27
+ const input = {};
28
+ if (isRecord(block['input'])) {
29
+ for (const [key, value] of Object.entries(block['input'])) {
30
+ if (typeof value === 'string')
31
+ input[key] = value;
32
+ else if (typeof value === 'number')
33
+ input[key] = String(value);
34
+ else if (typeof value === 'boolean')
35
+ input[key] = value ? '1' : '0';
36
+ }
37
+ }
38
+ events.push({ type: 'tool', name: block['name'], input });
39
+ }
40
+ return events;
41
+ }
42
+ case 'result': {
43
+ const isError = typeof object['is_error'] === 'boolean' ? object['is_error'] : object['subtype'] !== 'success';
44
+ return [{ type: 'result', text: typeof object['result'] === 'string' ? object['result'] : null, isError }];
45
+ }
46
+ default:
47
+ return [];
48
+ }
49
+ }
50
+ export class ProgressDescriber {
51
+ roots;
52
+ constructor(docsRoot, repos) {
53
+ const roots = repos.map((repo) => ({ prefix: repo.path, label: repo.slug }));
54
+ if (docsRoot)
55
+ roots.push({ prefix: docsRoot, label: 'docs' });
56
+ this.roots = roots.sort((left, right) => right.prefix.length - left.prefix.length);
57
+ }
58
+ describe(name, input) {
59
+ switch (name) {
60
+ case 'Read':
61
+ return `Leyendo ${this.short(input['file_path'] ?? input['path'] ?? '')}`;
62
+ case 'Grep': {
63
+ const scope = input['path'] !== undefined ? ` en ${this.short(input['path'])}` : '';
64
+ return `Buscando «${input['pattern'] ?? ''}»${scope}`;
65
+ }
66
+ case 'Glob': {
67
+ const scope = input['path'] !== undefined ? ` en ${this.short(input['path'])}` : '';
68
+ return `Listando ${input['pattern'] ?? ''}${scope}`;
69
+ }
70
+ case 'Bash': {
71
+ let command = input['command'];
72
+ if (command === undefined)
73
+ return 'Ejecutando un comando';
74
+ for (const root of this.roots)
75
+ command = replaceAll(command, root.prefix, root.label);
76
+ return `Ejecutando ${command}`;
77
+ }
78
+ default:
79
+ return name;
80
+ }
81
+ }
82
+ short(file) {
83
+ for (const root of this.roots) {
84
+ if (file === root.prefix || file.startsWith(`${root.prefix}/`))
85
+ return root.label + file.slice(root.prefix.length);
86
+ }
87
+ const base = home();
88
+ if (file.startsWith(`${base}/`))
89
+ return `~${file.slice(base.length)}`;
90
+ return file;
91
+ }
92
+ }
93
+ export const SOURCES_MARKER = '```fuentes';
94
+ export const QUESTION_PREFIX = 'PREGUNTA:';
95
+ function headerQuestion(text) {
96
+ const clean = trimmed(text);
97
+ if (!clean.toUpperCase().startsWith(QUESTION_PREFIX))
98
+ return null;
99
+ const question = trimmed(clean.slice(QUESTION_PREFIX.length));
100
+ return question.length === 0 ? null : question;
101
+ }
102
+ export function markerPrefixLength(text) {
103
+ for (let length = Math.min(SOURCES_MARKER.length - 1, text.length); length >= 1; length -= 1) {
104
+ if (text.endsWith(SOURCES_MARKER.slice(0, length)))
105
+ return length;
106
+ }
107
+ return 0;
108
+ }
109
+ export class AnswerAssembler {
110
+ question = null;
111
+ emitted = '';
112
+ sourcesText = '';
113
+ pending = '';
114
+ headerDone = false;
115
+ inSources = false;
116
+ feed(delta) {
117
+ if (this.inSources) {
118
+ this.sourcesText += delta;
119
+ return '';
120
+ }
121
+ this.pending += delta;
122
+ if (!this.headerDone && !this.resolveHeader())
123
+ return '';
124
+ return this.drain(false);
125
+ }
126
+ finish() {
127
+ if (!this.headerDone) {
128
+ this.headerDone = true;
129
+ const parsed = headerQuestion(this.pending);
130
+ if (parsed !== null) {
131
+ this.question = parsed;
132
+ this.pending = '';
133
+ }
134
+ }
135
+ return this.drain(true);
136
+ }
137
+ resolveHeader() {
138
+ const stripped = this.pending.replace(/^\s+/, '');
139
+ if (stripped.length === 0)
140
+ return false;
141
+ const head = stripped.slice(0, QUESTION_PREFIX.length).toUpperCase();
142
+ if (QUESTION_PREFIX.startsWith(head) && head.length < QUESTION_PREFIX.length)
143
+ return false;
144
+ if (head !== QUESTION_PREFIX) {
145
+ this.headerDone = true;
146
+ return true;
147
+ }
148
+ const newline = stripped.indexOf('\n');
149
+ if (newline === -1)
150
+ return false;
151
+ this.question = headerQuestion(stripped.slice(0, newline));
152
+ this.pending = stripped.slice(newline + 1).replace(/^\s*\n/, '');
153
+ this.headerDone = true;
154
+ return true;
155
+ }
156
+ drain(final) {
157
+ const marker = this.pending.indexOf(SOURCES_MARKER);
158
+ if (marker !== -1) {
159
+ const before = this.pending.slice(0, marker);
160
+ this.sourcesText += this.pending.slice(marker + SOURCES_MARKER.length);
161
+ this.pending = '';
162
+ this.inSources = true;
163
+ this.emitted += before;
164
+ return before;
165
+ }
166
+ let out = this.pending;
167
+ if (!final) {
168
+ const hold = markerPrefixLength(this.pending);
169
+ out = this.pending.slice(0, this.pending.length - hold);
170
+ this.pending = this.pending.slice(this.pending.length - hold);
171
+ }
172
+ else {
173
+ this.pending = '';
174
+ }
175
+ this.emitted += out;
176
+ return out;
177
+ }
178
+ get answer() {
179
+ return trimmed(this.emitted);
180
+ }
181
+ }
182
+ export function encodeSource(source) {
183
+ return { kind: source.kind, label: source.label, pageId: source.pageId, path: source.path, line: source.line, repo: source.repo, sha: source.sha };
184
+ }
185
+ export function encodeAnswer(answer) {
186
+ return {
187
+ id: answer.id,
188
+ askedAt: answer.askedAt,
189
+ question: answer.question,
190
+ answer: answer.answer,
191
+ found: answer.found,
192
+ sources: answer.sources.map(encodeSource),
193
+ auto: answer.auto,
194
+ answeredAt: answer.answeredAt,
195
+ questionMs: answer.questionMs,
196
+ channel: answer.channel,
197
+ askId: answer.askId,
198
+ };
199
+ }
200
+ function cleanOptional(value) {
201
+ if (typeof value !== 'string')
202
+ return undefined;
203
+ const text = trimmed(value);
204
+ return text.length === 0 ? undefined : text;
205
+ }
206
+ function decodeSourceLoose(item) {
207
+ if (!isRecord(item) || typeof item['kind'] !== 'string')
208
+ return null;
209
+ const kind = item['kind'].toLowerCase();
210
+ if (!['page', 'file', 'commit'].includes(kind))
211
+ return null;
212
+ const pageId = asIntLoose(item['pageId']);
213
+ const line = asIntLoose(item['line']);
214
+ const sourcePath = cleanOptional(item['path']);
215
+ const repo = cleanOptional(item['repo']);
216
+ const sha = cleanOptional(item['sha']);
217
+ if (kind === 'page' && pageId === undefined && sourcePath === undefined)
218
+ return null;
219
+ if (kind === 'file' && sourcePath === undefined)
220
+ return null;
221
+ if (kind === 'commit' && sha === undefined)
222
+ return null;
223
+ let label = typeof item['label'] === 'string' ? trimmed(item['label']) : '';
224
+ if (label.length === 0) {
225
+ if (kind === 'page')
226
+ label = pageId !== undefined ? `Página #${pageId}` : (sourcePath ?? '');
227
+ else if (kind === 'file')
228
+ label = [sourcePath ?? '', line !== undefined ? String(line) : ''].filter((part) => part.length > 0).join(':');
229
+ else
230
+ label = (sha ?? '').slice(0, 10);
231
+ }
232
+ return { kind, label, pageId, path: sourcePath, line, repo, sha };
233
+ }
234
+ export function decodeAnswer(value) {
235
+ if (!isRecord(value))
236
+ return null;
237
+ const { id, askedAt, question, answer, found } = value;
238
+ if (typeof id !== 'string' || typeof askedAt !== 'string' || typeof question !== 'string' || typeof answer !== 'string' || typeof found !== 'boolean')
239
+ return null;
240
+ if (!Array.isArray(value['sources']))
241
+ return null;
242
+ const sources = [];
243
+ for (const item of value['sources']) {
244
+ if (!isRecord(item) || typeof item['kind'] !== 'string' || typeof item['label'] !== 'string')
245
+ return null;
246
+ sources.push({
247
+ kind: item['kind'],
248
+ label: item['label'],
249
+ pageId: typeof item['pageId'] === 'number' ? item['pageId'] : undefined,
250
+ path: typeof item['path'] === 'string' ? item['path'] : undefined,
251
+ line: typeof item['line'] === 'number' ? item['line'] : undefined,
252
+ repo: typeof item['repo'] === 'string' ? item['repo'] : undefined,
253
+ sha: typeof item['sha'] === 'string' ? item['sha'] : undefined,
254
+ });
255
+ }
256
+ return {
257
+ id,
258
+ askedAt,
259
+ question,
260
+ answer,
261
+ found,
262
+ sources,
263
+ auto: typeof value['auto'] === 'boolean' ? value['auto'] : undefined,
264
+ answeredAt: typeof value['answeredAt'] === 'string' ? value['answeredAt'] : undefined,
265
+ questionMs: typeof value['questionMs'] === 'number' ? value['questionMs'] : undefined,
266
+ channel: value['channel'] === 'mic' || value['channel'] === 'system' ? value['channel'] : undefined,
267
+ askId: typeof value['askId'] === 'string' ? value['askId'] : undefined,
268
+ };
269
+ }
270
+ export function parseSourcesBlock(text) {
271
+ let body = text;
272
+ const close = body.indexOf('```');
273
+ if (close !== -1)
274
+ body = body.slice(0, close);
275
+ const start = body.indexOf('{');
276
+ const end = body.lastIndexOf('}');
277
+ if (start === -1 || end === -1 || start >= end)
278
+ return null;
279
+ const object = parseJsonSafe(body.slice(start, end + 1));
280
+ if (!isRecord(object))
281
+ return null;
282
+ const items = Array.isArray(object['sources']) ? object['sources'] : [];
283
+ const question = typeof object['question'] === 'string' ? trimmed(object['question']) : null;
284
+ return {
285
+ question: question && question.length > 0 ? question : null,
286
+ found: typeof object['found'] === 'boolean' ? object['found'] : null,
287
+ sources: items.map(decodeSourceLoose).filter((source) => source !== null),
288
+ atMs: clockMilliseconds(object['at']),
289
+ };
290
+ }
291
+ export const NOT_DOCUMENTED = 'no esta documentado';
292
+ export function buildAnswer(id, askedAt, explicitQuestion, assembler, segments = []) {
293
+ const block = parseSourcesBlock(assembler.sourcesText);
294
+ const text = assembler.answer;
295
+ const sources = block?.sources ?? [];
296
+ const missing = fold(text).includes(NOT_DOCUMENTED);
297
+ const found = !missing && (block?.found ?? sources.length > 0);
298
+ const question = explicitQuestion ?? assembler.question ?? block?.question ?? '';
299
+ const answer = { id, askedAt, question, answer: text, found, sources };
300
+ if (explicitQuestion === null && block?.atMs !== null && block?.atMs !== undefined) {
301
+ const origin = resolveOrigin(block.atMs, segments, question);
302
+ if (origin) {
303
+ answer.questionMs = origin.questionMs;
304
+ answer.channel = origin.channel;
305
+ }
306
+ }
307
+ return answer;
308
+ }
309
+ export function encodeAskEvent(event) {
310
+ switch (event.type) {
311
+ case 'source':
312
+ return { type: 'source', source: encodeSource(event.source) };
313
+ case 'done':
314
+ return { type: 'done', answer: encodeAnswer(event.answer) };
315
+ case 'error':
316
+ return { type: 'error', code: event.code, message: event.message };
317
+ default:
318
+ return { type: event.type, text: event.text };
319
+ }
320
+ }
321
+ export class AskStreamReducer {
322
+ explicitQuestion;
323
+ assembler = new AnswerAssembler();
324
+ questionSent;
325
+ deltaSent = false;
326
+ resultText = null;
327
+ resultError = false;
328
+ constructor(explicitQuestion) {
329
+ this.explicitQuestion = explicitQuestion;
330
+ this.questionSent = explicitQuestion !== null;
331
+ }
332
+ consume(line, describer) {
333
+ const events = [];
334
+ for (const event of parseStreamLine(line)) {
335
+ if (event.type === 'text') {
336
+ const out = this.assembler.feed(event.text);
337
+ events.push(...this.questionEvent());
338
+ if (out.length > 0) {
339
+ this.deltaSent = true;
340
+ events.push({ type: 'delta', text: out });
341
+ }
342
+ }
343
+ else if (event.type === 'tool') {
344
+ events.push({ type: 'progress', text: describer.describe(event.name, event.input) });
345
+ }
346
+ else {
347
+ this.resultText = event.text;
348
+ this.resultError = event.isError;
349
+ }
350
+ }
351
+ return events;
352
+ }
353
+ questionEvent() {
354
+ if (this.questionSent || this.assembler.question === null)
355
+ return [];
356
+ this.questionSent = true;
357
+ return [{ type: 'question', text: this.assembler.question }];
358
+ }
359
+ finish(status, stderr, id, askedAt, segments = []) {
360
+ const events = [];
361
+ const tail = this.assembler.finish();
362
+ events.push(...this.questionEvent());
363
+ if (tail.length > 0) {
364
+ this.deltaSent = true;
365
+ events.push({ type: 'delta', text: tail });
366
+ }
367
+ if (this.resultError || (status !== 0 && this.resultText === null)) {
368
+ const detail = this.resultText ?? trimmed(stderr);
369
+ throw new RecapError('CLAUDE_FAILED', detail.length === 0 ? `claude exited with status ${status}` : suffix(detail, 500));
370
+ }
371
+ let canonical = this.assembler;
372
+ if (this.resultText !== null && trimmed(this.resultText).length > 0) {
373
+ const fresh = new AnswerAssembler();
374
+ fresh.feed(this.resultText);
375
+ fresh.finish();
376
+ canonical = fresh;
377
+ }
378
+ const answer = buildAnswer(id, askedAt, this.explicitQuestion, canonical, segments);
379
+ if (answer.answer.length === 0)
380
+ throw new RecapError('CLAUDE_FAILED', 'claude returned an empty answer');
381
+ if (!this.deltaSent)
382
+ events.push({ type: 'delta', text: answer.answer });
383
+ if (!this.questionSent && answer.question.length > 0) {
384
+ this.questionSent = true;
385
+ events.push({ type: 'question', text: answer.question });
386
+ }
387
+ events.push(...answer.sources.map((source) => ({ type: 'source', source })));
388
+ return { events, answer };
389
+ }
390
+ }
@@ -0,0 +1,191 @@
1
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { settingsOf, whisperModelPath } from "../core/config.js";
4
+ import { readText, writeAtomic } from "../core/fsutil.js";
5
+ import { isRecord } from "../core/json.js";
6
+ import { ProcessLock } from "../core/lock.js";
7
+ import { isActiveStatus, loadMeeting, reconcile } from "../core/meeting.js";
8
+ import { exec, sleep } from "../core/proc.js";
9
+ import { selfCommand } from "../core/self.js";
10
+ import { suffix, trimmed } from "../core/text.js";
11
+ import { requireTool } from "../core/tools.js";
12
+ import { RecapError, errorMessage } from "../errors.js";
13
+ import { docsClient, meetingTarget } from "../bita/client.js";
14
+ import { resultText, runClaude } from "../pipeline/claude.js";
15
+ import { whisperArguments } from "../pipeline/pipeline.js";
16
+ import { parseWhisper } from "../pipeline/transcript.js";
17
+ import { pruneBoard } from "./asking.js";
18
+ import { loadProjectContext, meetingProject } from "./context.js";
19
+ import { AutoAskProcess, AutoAskQueue, QuestionDetector } from "./detector.js";
20
+ import { appendLines, liveFiles, readLines } from "./files.js";
21
+ import { LiveMerger } from "./merger.js";
22
+ export const LIVE_THREADS = 4;
23
+ export const PROMPT_CHARACTERS = 220;
24
+ export const PRUNE_SECONDS = 5;
25
+ export function decodeChunk(value) {
26
+ if (!isRecord(value))
27
+ return null;
28
+ const { channel, seq, startMs, endMs, file } = value;
29
+ if (channel !== 'mic' && channel !== 'system')
30
+ return null;
31
+ if (typeof seq !== 'number' || typeof startMs !== 'number' || typeof endMs !== 'number')
32
+ return null;
33
+ if (file !== undefined && file !== null && typeof file !== 'string')
34
+ return null;
35
+ return { file: typeof file === 'string' ? file : undefined, channel, seq, startMs, endMs };
36
+ }
37
+ export function liveWhisperArguments(config, model, outputBase, wav, previous) {
38
+ return whisperArguments(config, model, outputBase, wav, LIVE_THREADS, previous);
39
+ }
40
+ export class LiveWorker {
41
+ dir;
42
+ config;
43
+ log;
44
+ pollSeconds;
45
+ detectorFactory;
46
+ previousText = {};
47
+ constructor(dir, config, log, options = {}) {
48
+ this.dir = dir;
49
+ this.config = config;
50
+ this.log = log;
51
+ this.pollSeconds = options.pollSeconds ?? 1;
52
+ this.detectorFactory = options.detectorFactory ?? null;
53
+ }
54
+ loadState() {
55
+ const text = readText(liveFiles.workerState(this.dir));
56
+ if (text === null)
57
+ return 0;
58
+ try {
59
+ const value = JSON.parse(text);
60
+ return isRecord(value) && typeof value['processed'] === 'number' ? value['processed'] : 0;
61
+ }
62
+ catch {
63
+ return 0;
64
+ }
65
+ }
66
+ isRecording() {
67
+ try {
68
+ return isActiveStatus(reconcile(loadMeeting(this.dir), this.dir).status);
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ async run() {
75
+ mkdirSync(liveFiles.dir(this.dir), { recursive: true });
76
+ const lock = new ProcessLock(liveFiles.workerLock(this.dir));
77
+ let detector = null;
78
+ try {
79
+ const meeting = loadMeeting(this.dir);
80
+ const whisper = await requireTool('whisper-cli', this.config);
81
+ const model = whisperModelPath(this.config);
82
+ if (!existsSync(model))
83
+ throw new RecapError('MODEL_MISSING', `Whisper model not found at ${model}. Run \`recap setup\`.`);
84
+ const settings = settingsOf(this.config);
85
+ const merger = new LiveMerger(meeting.mode === 'remote', settings.maxChunkSeconds + 10);
86
+ let processed = this.loadState();
87
+ this.log(`live worker started at chunk ${processed}`);
88
+ detector = this.detectorFactory?.(this.dir, meeting) ?? null;
89
+ if (detector) {
90
+ this.log(`question detector on (model ${settings.autoAskModel}, every ${settings.autoAskMinSeconds} s at most, ${settings.autoAskConcurrency} answers at a time)`);
91
+ }
92
+ pruneBoard(this.dir);
93
+ let lastPrune = Date.now();
94
+ let finalPass = false;
95
+ for (;;) {
96
+ detector?.tick();
97
+ if ((Date.now() - lastPrune) / 1000 >= PRUNE_SECONDS) {
98
+ pruneBoard(this.dir);
99
+ lastPrune = Date.now();
100
+ }
101
+ const entries = readLines(liveFiles.chunkIndex(this.dir), decodeChunk);
102
+ if (entries.length > processed) {
103
+ for (const entry of entries.slice(processed)) {
104
+ const segments = await this.transcribe(entry, whisper, model);
105
+ const ready = entry.file === undefined ? merger.advance(entry.channel, entry.endMs, Date.now()) : merger.add(segments, entry.channel, entry.endMs, Date.now());
106
+ appendLines(ready, liveFiles.transcript(this.dir));
107
+ if (ready.length > 0)
108
+ detector?.transcriptGrew();
109
+ processed += 1;
110
+ writeAtomic(liveFiles.workerState(this.dir), JSON.stringify({ processed }));
111
+ detector?.tick();
112
+ }
113
+ finalPass = false;
114
+ continue;
115
+ }
116
+ const released = merger.release(Date.now(), false);
117
+ appendLines(released, liveFiles.transcript(this.dir));
118
+ if (released.length > 0)
119
+ detector?.transcriptGrew();
120
+ if (!this.isRecording()) {
121
+ if (finalPass)
122
+ break;
123
+ finalPass = true;
124
+ continue;
125
+ }
126
+ await sleep(this.pollSeconds * 1000);
127
+ }
128
+ appendLines(merger.release(Date.now(), true), liveFiles.transcript(this.dir));
129
+ this.log(`live worker finished after ${processed} chunks`);
130
+ }
131
+ finally {
132
+ await detector?.stop();
133
+ lock.release();
134
+ }
135
+ }
136
+ async transcribe(entry, whisper, model) {
137
+ if (entry.file === undefined)
138
+ return [];
139
+ const wav = path.join(liveFiles.chunks(this.dir), entry.file);
140
+ if (!existsSync(wav))
141
+ return [];
142
+ const base = wav.replace(/\.[^./\\]+$/, '');
143
+ const json = `${base}.json`;
144
+ try {
145
+ const result = await exec(whisper, liveWhisperArguments(this.config, model, base, wav, this.previousText[entry.channel]));
146
+ if (result.status !== 0) {
147
+ this.log(`whisper failed on ${entry.file}: ${suffix(trimmed(result.stderr), 300)}`);
148
+ return [];
149
+ }
150
+ const text = readText(json);
151
+ if (text === null)
152
+ throw new Error(`${json} was not written`);
153
+ const segments = parseWhisper(text, entry.channel).map((segment) => ({
154
+ ...segment,
155
+ startMs: segment.startMs + entry.startMs,
156
+ endMs: Math.min(segment.endMs + entry.startMs, Math.max(entry.endMs, segment.startMs + entry.startMs)),
157
+ }));
158
+ if (segments.length > 0) {
159
+ const joined = trimmed(`${this.previousText[entry.channel] ?? ''} ${segments.map((segment) => segment.text).join(' ')}`);
160
+ this.previousText[entry.channel] = suffix(joined, PROMPT_CHARACTERS);
161
+ }
162
+ return segments;
163
+ }
164
+ catch (error) {
165
+ this.log(`whisper failed on ${entry.file}: ${errorMessage(error)}`);
166
+ return [];
167
+ }
168
+ finally {
169
+ rmSync(json, { force: true });
170
+ }
171
+ }
172
+ }
173
+ export function makeLiveDetector(dir, meeting, config, log) {
174
+ const settings = settingsOf(config);
175
+ if (!settings.autoAsk)
176
+ return null;
177
+ const runner = new AutoAskProcess(dir, selfCommand());
178
+ const asks = new AutoAskQueue({ dir, concurrency: settings.autoAskConcurrency, run: (job) => runner.run(job), cancelRunning: () => runner.cancel(), log });
179
+ return new QuestionDetector(dir, meeting, settings.autoAskMinSeconds, {
180
+ context: async () => {
181
+ const docs = await docsClient(config, meetingTarget(meeting));
182
+ return loadProjectContext(await meetingProject(meeting, docs), meeting.bitaDocsRoot, docs);
183
+ },
184
+ complete: async (prompt) => resultText(await runClaude({ prompt, cwd: dir, config, tools: [], addDirs: [], model: settings.autoAskModel, restrictTools: [] })),
185
+ enqueue: (question, origin) => {
186
+ asks.enqueue(question, origin);
187
+ },
188
+ cancelAsks: () => asks.cancelAll(),
189
+ log,
190
+ });
191
+ }