@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.
- 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 +101 -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 +18 -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 +108 -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,419 @@
|
|
|
1
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isoNow } from "../core/dates.js";
|
|
4
|
+
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
5
|
+
import { asIntLoose, extractBetween, isRecord, swiftPretty } from "../core/json.js";
|
|
6
|
+
import { withDirectoryLockAsync } from "../core/lock.js";
|
|
7
|
+
import { home } from "../core/paths.js";
|
|
8
|
+
import { fold, prefix, replaceAll, trimmed } from "../core/text.js";
|
|
9
|
+
import { RecapError, errorMessage } from "../errors.js";
|
|
10
|
+
import { loadProjectContext, meetingProject } from "../live/context.js";
|
|
11
|
+
import { resultText, runClaude } from "../pipeline/claude.js";
|
|
12
|
+
import { loadResource } from "../pipeline/resources.js";
|
|
13
|
+
import { callBita } from "./client.js";
|
|
14
|
+
import { meetingDay } from "./wrapup.js";
|
|
15
|
+
export const REVEALING_PHRASES = [
|
|
16
|
+
'se acordo',
|
|
17
|
+
'acordamos',
|
|
18
|
+
'decidimos',
|
|
19
|
+
'se decidio',
|
|
20
|
+
'por decision de',
|
|
21
|
+
'en la reunion',
|
|
22
|
+
'en la junta',
|
|
23
|
+
'segun lo hablado',
|
|
24
|
+
'como se comento',
|
|
25
|
+
'como se menciono',
|
|
26
|
+
'quedamos en',
|
|
27
|
+
'se platico',
|
|
28
|
+
'la grabacion',
|
|
29
|
+
'la transcripcion',
|
|
30
|
+
];
|
|
31
|
+
export function revealing(markdown) {
|
|
32
|
+
const folded = fold(markdown);
|
|
33
|
+
return REVEALING_PHRASES.find((phrase) => folded.includes(phrase)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
export function cleanHeading(heading) {
|
|
36
|
+
return trimmed(trimmed(heading).replace(/^#+/, ''));
|
|
37
|
+
}
|
|
38
|
+
export function stripLeadingHeading(markdown, section) {
|
|
39
|
+
if (section === null)
|
|
40
|
+
return markdown;
|
|
41
|
+
const lines = markdown.split('\n');
|
|
42
|
+
const first = lines[0];
|
|
43
|
+
if (first === undefined || !first.startsWith('#') || fold(cleanHeading(first)) !== fold(section))
|
|
44
|
+
return markdown;
|
|
45
|
+
return trimmed(lines.slice(1).join('\n'));
|
|
46
|
+
}
|
|
47
|
+
function quoteOf(item) {
|
|
48
|
+
if (!isRecord(item) || typeof item['text'] !== 'string')
|
|
49
|
+
return null;
|
|
50
|
+
const text = trimmed(item['text']);
|
|
51
|
+
if (text.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const raw = item['startMs'];
|
|
54
|
+
const startMs = typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
55
|
+
const channel = fold(typeof item['channel'] === 'string' ? item['channel'] : 'mic');
|
|
56
|
+
return { startMs: Math.max(0, startMs), channel: ['system', 'remotos', 'remoto'].includes(channel) ? 'system' : 'mic', text };
|
|
57
|
+
}
|
|
58
|
+
export function parseProposalPlan(text, candidates) {
|
|
59
|
+
const object = extractBetween(text, '{', '}');
|
|
60
|
+
if (!isRecord(object) || !Array.isArray(object['proposals'])) {
|
|
61
|
+
throw new RecapError('PROPOSALS_OUTPUT', `The proposals answer has no {"proposals": [...]} object: ${prefix(text, 200)}`);
|
|
62
|
+
}
|
|
63
|
+
const drafts = [];
|
|
64
|
+
const discarded = [];
|
|
65
|
+
const seen = new Set();
|
|
66
|
+
object['proposals'].forEach((raw, index) => {
|
|
67
|
+
if (!isRecord(raw)) {
|
|
68
|
+
discarded.push({ index, reason: 'not an object' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const pageId = asIntLoose(raw['pageId']);
|
|
72
|
+
const discard = (reason) => discarded.push({ index, pageId, reason });
|
|
73
|
+
if (pageId === undefined)
|
|
74
|
+
return discard('missing pageId');
|
|
75
|
+
if (!candidates.has(pageId))
|
|
76
|
+
return discard(`page ${pageId} is not a candidate`);
|
|
77
|
+
const rawSection = typeof raw['section'] === 'string' ? cleanHeading(raw['section']) : '';
|
|
78
|
+
const section = rawSection.length > 0 ? rawSection : null;
|
|
79
|
+
const markdown = trimmed(typeof raw['markdown'] === 'string' ? raw['markdown'] : '');
|
|
80
|
+
const title = prefix(trimmed(typeof raw['title'] === 'string' ? raw['title'] : ''), 160);
|
|
81
|
+
const rationale = trimmed(typeof raw['rationale'] === 'string' ? raw['rationale'] : '');
|
|
82
|
+
if (markdown.length === 0)
|
|
83
|
+
return discard('empty markdown');
|
|
84
|
+
if (title.length === 0)
|
|
85
|
+
return discard('empty title');
|
|
86
|
+
const phrase = revealing(markdown);
|
|
87
|
+
if (phrase)
|
|
88
|
+
return discard(`the markdown reveals the conversation (${phrase})`);
|
|
89
|
+
const quotes = (Array.isArray(raw['quotes']) ? raw['quotes'] : []).map(quoteOf).filter((quote) => quote !== null);
|
|
90
|
+
if (quotes.length === 0)
|
|
91
|
+
return discard('no quotes from the transcript');
|
|
92
|
+
const key = `${pageId}|${fold(section ?? '')}`;
|
|
93
|
+
if (seen.has(key))
|
|
94
|
+
return discard('duplicate change to the same section');
|
|
95
|
+
seen.add(key);
|
|
96
|
+
drafts.push({ pageId, section, markdown: stripLeadingHeading(markdown, section), title, rationale, quotes: quotes.slice(0, 3) });
|
|
97
|
+
return undefined;
|
|
98
|
+
});
|
|
99
|
+
return { drafts, discarded };
|
|
100
|
+
}
|
|
101
|
+
export const proposalStore = {
|
|
102
|
+
file: (dir) => path.join(dir, 'proposals.json'),
|
|
103
|
+
markdownDir: (dir) => path.join(dir, 'proposals'),
|
|
104
|
+
markdownFile: (dir, n) => path.join(dir, 'proposals', `${n}.md`),
|
|
105
|
+
branch: (entryId) => `proposal/meeting-${entryId}`,
|
|
106
|
+
};
|
|
107
|
+
function decodeProposal(value) {
|
|
108
|
+
if (!isRecord(value))
|
|
109
|
+
return null;
|
|
110
|
+
const { n, pageId, pageTitle, title, rationale, branch, sha, status, file } = value;
|
|
111
|
+
if (typeof n !== 'number' || typeof pageId !== 'number' || typeof pageTitle !== 'string' || typeof title !== 'string')
|
|
112
|
+
return null;
|
|
113
|
+
if (typeof rationale !== 'string' || typeof branch !== 'string' || typeof sha !== 'string' || typeof file !== 'string')
|
|
114
|
+
return null;
|
|
115
|
+
if (status !== 'pending' && status !== 'accepted' && status !== 'rejected' && status !== 'stale')
|
|
116
|
+
return null;
|
|
117
|
+
const quotes = Array.isArray(value['quotes']) ? value['quotes'].map(quoteOf).filter((quote) => quote !== null) : [];
|
|
118
|
+
return {
|
|
119
|
+
n,
|
|
120
|
+
pageId,
|
|
121
|
+
pageTitle,
|
|
122
|
+
section: typeof value['section'] === 'string' ? value['section'] : null,
|
|
123
|
+
title,
|
|
124
|
+
rationale,
|
|
125
|
+
quotes,
|
|
126
|
+
branch,
|
|
127
|
+
sha,
|
|
128
|
+
status,
|
|
129
|
+
appliedSha: typeof value['appliedSha'] === 'string' ? value['appliedSha'] : undefined,
|
|
130
|
+
file,
|
|
131
|
+
updatedAt: typeof value['updatedAt'] === 'string' ? value['updatedAt'] : undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
export function loadProposals(dir) {
|
|
135
|
+
const text = readText(proposalStore.file(dir));
|
|
136
|
+
if (text === null)
|
|
137
|
+
return null;
|
|
138
|
+
try {
|
|
139
|
+
const value = JSON.parse(text);
|
|
140
|
+
if (!isRecord(value) || typeof value['entryId'] !== 'number' || typeof value['branch'] !== 'string' || typeof value['generatedAt'] !== 'string')
|
|
141
|
+
return null;
|
|
142
|
+
if (!Array.isArray(value['proposals']))
|
|
143
|
+
return null;
|
|
144
|
+
const proposals = value['proposals'].map(decodeProposal);
|
|
145
|
+
if (proposals.some((proposal) => proposal === null))
|
|
146
|
+
return null;
|
|
147
|
+
const discarded = Array.isArray(value['discarded'])
|
|
148
|
+
? value['discarded'].filter(isRecord).map((item) => ({ index: Number(item['index']), pageId: typeof item['pageId'] === 'number' ? item['pageId'] : undefined, reason: String(item['reason'] ?? '') }))
|
|
149
|
+
: [];
|
|
150
|
+
return {
|
|
151
|
+
entryId: value['entryId'],
|
|
152
|
+
branch: value['branch'],
|
|
153
|
+
generatedAt: value['generatedAt'],
|
|
154
|
+
branchDropped: value['branchDropped'] === true,
|
|
155
|
+
proposals: proposals,
|
|
156
|
+
discarded,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function encodeProposal(proposal) {
|
|
164
|
+
return {
|
|
165
|
+
n: proposal.n,
|
|
166
|
+
pageId: proposal.pageId,
|
|
167
|
+
pageTitle: proposal.pageTitle,
|
|
168
|
+
section: proposal.section,
|
|
169
|
+
title: proposal.title,
|
|
170
|
+
rationale: proposal.rationale,
|
|
171
|
+
quotes: proposal.quotes.map((quote) => ({ startMs: quote.startMs, channel: quote.channel, text: quote.text })),
|
|
172
|
+
branch: proposal.branch,
|
|
173
|
+
sha: proposal.sha,
|
|
174
|
+
status: proposal.status,
|
|
175
|
+
appliedSha: proposal.appliedSha,
|
|
176
|
+
file: proposal.file,
|
|
177
|
+
updatedAt: proposal.updatedAt,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
export function proposalJson(proposal) {
|
|
181
|
+
return encodeProposal(proposal);
|
|
182
|
+
}
|
|
183
|
+
export function saveProposals(file, dir) {
|
|
184
|
+
writeAtomic(proposalStore.file(dir), swiftPretty({
|
|
185
|
+
entryId: file.entryId,
|
|
186
|
+
branch: file.branch,
|
|
187
|
+
generatedAt: file.generatedAt,
|
|
188
|
+
branchDropped: file.branchDropped,
|
|
189
|
+
proposals: file.proposals.map(encodeProposal),
|
|
190
|
+
discarded: file.discarded.map((item) => ({ index: item.index, pageId: item.pageId, reason: item.reason })),
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
export async function propose(bita, branch, pageId, section, file, reason, entryId) {
|
|
194
|
+
const args = ['docs', 'propose', '--branch', branch, String(pageId), '--md', file];
|
|
195
|
+
if (section !== null)
|
|
196
|
+
args.push('--section', section);
|
|
197
|
+
args.push('--reason', reason, '--source', `meeting:${entryId}`);
|
|
198
|
+
const data = await callBita(bita, args);
|
|
199
|
+
const sha = isRecord(data) && typeof data['sha'] === 'string' ? data['sha'] : '';
|
|
200
|
+
if (sha.length === 0)
|
|
201
|
+
throw new RecapError('BITA_FAILED', 'bita docs propose did not return the commit sha');
|
|
202
|
+
return sha;
|
|
203
|
+
}
|
|
204
|
+
export function ownPageId(meeting) {
|
|
205
|
+
return meeting.wrapup?.pageId ?? meeting.bitaEntry?.pageIds[0];
|
|
206
|
+
}
|
|
207
|
+
export class ProposalGenerator {
|
|
208
|
+
config;
|
|
209
|
+
bita;
|
|
210
|
+
log;
|
|
211
|
+
claude;
|
|
212
|
+
constructor(config, bita, log, claude) {
|
|
213
|
+
this.config = config;
|
|
214
|
+
this.bita = bita;
|
|
215
|
+
this.log = log;
|
|
216
|
+
this.claude =
|
|
217
|
+
claude ??
|
|
218
|
+
(async (prompt, dirs) => resultText(await runClaude({ prompt, cwd: dirs[dirs.length - 1] ?? home(), config, tools: ['Read Grep Glob'], addDirs: dirs, model: config.summaryModel })));
|
|
219
|
+
}
|
|
220
|
+
async candidates(meeting, context) {
|
|
221
|
+
const own = ownPageId(meeting);
|
|
222
|
+
const pages = [...context.pages];
|
|
223
|
+
const known = new Set(pages.map((page) => page.pageId));
|
|
224
|
+
for (const pageId of meeting.bitaEntry?.pageIds ?? []) {
|
|
225
|
+
if (known.has(pageId) || pageId === own)
|
|
226
|
+
continue;
|
|
227
|
+
const response = await this.bita.invoke(['docs', 'page', 'show', String(pageId), '--no-markdown']).catch(() => null);
|
|
228
|
+
if (!response?.ok || !isRecord(response.data) || typeof response.data['title'] !== 'string')
|
|
229
|
+
continue;
|
|
230
|
+
pages.push({ pageId, title: response.data['title'], relPath: typeof response.data['relPath'] === 'string' ? response.data['relPath'] : '', depth: 0 });
|
|
231
|
+
known.add(pageId);
|
|
232
|
+
}
|
|
233
|
+
return pages.filter((page) => page.pageId !== own);
|
|
234
|
+
}
|
|
235
|
+
async run(meeting, dir) {
|
|
236
|
+
const entryId = meeting.bitaEntryId;
|
|
237
|
+
if (entryId === undefined)
|
|
238
|
+
return null;
|
|
239
|
+
const branch = proposalStore.branch(entryId);
|
|
240
|
+
const existing = loadProposals(dir);
|
|
241
|
+
if (existing) {
|
|
242
|
+
if (existing.proposals.some((proposal) => proposal.status !== 'pending')) {
|
|
243
|
+
this.log(`proposals: already reviewed, keeping ${existing.proposals.length}`);
|
|
244
|
+
return existing;
|
|
245
|
+
}
|
|
246
|
+
if (existing.proposals.length > 0)
|
|
247
|
+
await this.bita.invoke(['docs', 'branch', 'drop', branch]).catch(() => null);
|
|
248
|
+
}
|
|
249
|
+
const context = await loadProjectContext(await meetingProject(meeting, this.bita), meeting.bitaDocsRoot, this.bita);
|
|
250
|
+
const pages = await this.candidates(meeting, context);
|
|
251
|
+
const file = { entryId, branch, generatedAt: isoNow(), branchDropped: false, proposals: [], discarded: [] };
|
|
252
|
+
if (pages.length === 0 || context.docsRoot === null) {
|
|
253
|
+
this.log('proposals: no candidate pages');
|
|
254
|
+
saveProposals(file, dir);
|
|
255
|
+
return file;
|
|
256
|
+
}
|
|
257
|
+
const transcript = readText(path.join(dir, 'transcript.md'));
|
|
258
|
+
if (transcript === null)
|
|
259
|
+
throw new RecapError('NO_TRANSCRIPT', `"${meeting.title}" has no transcript yet`);
|
|
260
|
+
const prompt = this.render(meeting, context.docsRoot, pages, transcript);
|
|
261
|
+
const answer = await this.claude(prompt, [context.docsRoot, dir]);
|
|
262
|
+
const parsed = parseProposalPlan(answer, new Set(pages.map((page) => page.pageId)));
|
|
263
|
+
file.discarded = parsed.discarded;
|
|
264
|
+
for (const item of parsed.discarded)
|
|
265
|
+
this.log(`proposals: discarded #${item.index}: ${item.reason}`);
|
|
266
|
+
rmSync(proposalStore.markdownDir(dir), { recursive: true, force: true });
|
|
267
|
+
if (parsed.drafts.length > 0)
|
|
268
|
+
mkdirSync(proposalStore.markdownDir(dir), { recursive: true });
|
|
269
|
+
const titles = new Map();
|
|
270
|
+
for (const page of pages)
|
|
271
|
+
if (!titles.has(page.pageId))
|
|
272
|
+
titles.set(page.pageId, page.title);
|
|
273
|
+
const failures = [];
|
|
274
|
+
for (const draft of parsed.drafts) {
|
|
275
|
+
const n = file.proposals.length + 1;
|
|
276
|
+
const markdownFile = proposalStore.markdownFile(dir, n);
|
|
277
|
+
writeAtomic(markdownFile, `${draft.markdown}\n`);
|
|
278
|
+
try {
|
|
279
|
+
const sha = await propose(this.bita, branch, draft.pageId, draft.section, markdownFile, draft.title, entryId);
|
|
280
|
+
file.proposals.push({
|
|
281
|
+
n,
|
|
282
|
+
pageId: draft.pageId,
|
|
283
|
+
pageTitle: titles.get(draft.pageId) ?? `#${draft.pageId}`,
|
|
284
|
+
section: draft.section,
|
|
285
|
+
title: draft.title,
|
|
286
|
+
rationale: draft.rationale,
|
|
287
|
+
quotes: draft.quotes,
|
|
288
|
+
branch,
|
|
289
|
+
sha,
|
|
290
|
+
status: 'pending',
|
|
291
|
+
file: markdownFile,
|
|
292
|
+
updatedAt: isoNow(),
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
rmSync(markdownFile, { force: true });
|
|
297
|
+
const message = errorMessage(error);
|
|
298
|
+
failures.push(message);
|
|
299
|
+
this.log(`proposals: page #${draft.pageId} failed: ${message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
saveProposals(file, dir);
|
|
303
|
+
this.log(`proposals: ${file.proposals.length} on ${branch}`);
|
|
304
|
+
if (file.proposals.length === 0 && failures[0])
|
|
305
|
+
throw new RecapError('PROPOSALS_FAILED', failures[0]);
|
|
306
|
+
return file;
|
|
307
|
+
}
|
|
308
|
+
render(meeting, docsRoot, pages, transcript) {
|
|
309
|
+
const list = pages.map((page) => ` - #${page.pageId} «${page.title}» — ${path.join(docsRoot, page.relPath)}`).join('\n');
|
|
310
|
+
const speakers = meeting.mode === 'remote'
|
|
311
|
+
? 'Hablantes: «Sala» es el micrófono local; «Remotos» es el audio de la llamada.'
|
|
312
|
+
: 'Hablantes: un solo micrófono en la sala, sin separación por persona.';
|
|
313
|
+
let text = loadResource('proposals-prompt.md');
|
|
314
|
+
text = replaceAll(text, '{{title}}', meeting.bitaEntry?.title ?? meeting.title);
|
|
315
|
+
text = replaceAll(text, '{{date}}', meetingDay(meeting));
|
|
316
|
+
text = replaceAll(text, '{{mode}}', meeting.mode === 'remote' ? 'remota' : 'presencial');
|
|
317
|
+
text = replaceAll(text, '{{speakers}}', speakers);
|
|
318
|
+
text = replaceAll(text, '{{docsRoot}}', docsRoot);
|
|
319
|
+
text = replaceAll(text, '{{pages}}', list);
|
|
320
|
+
text = replaceAll(text, '{{transcript}}', transcript);
|
|
321
|
+
return text;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export class ProposalReview {
|
|
325
|
+
dir;
|
|
326
|
+
bita;
|
|
327
|
+
constructor(dir, bita) {
|
|
328
|
+
this.dir = dir;
|
|
329
|
+
this.bita = bita;
|
|
330
|
+
}
|
|
331
|
+
list() {
|
|
332
|
+
return loadProposals(this.dir)?.proposals ?? [];
|
|
333
|
+
}
|
|
334
|
+
proposal(n) {
|
|
335
|
+
const file = loadProposals(this.dir);
|
|
336
|
+
if (!file)
|
|
337
|
+
throw new RecapError('NO_PROPOSALS', 'The meeting has no proposals');
|
|
338
|
+
const found = file.proposals.find((item) => item.n === n);
|
|
339
|
+
if (!found)
|
|
340
|
+
throw new RecapError('PROPOSAL_NOT_FOUND', `There is no proposal ${n}`);
|
|
341
|
+
return found;
|
|
342
|
+
}
|
|
343
|
+
async diff(proposal) {
|
|
344
|
+
const response = await this.bita.invoke(['docs', 'branch', 'diff', proposal.branch, '--commit', proposal.sha]).catch(() => null);
|
|
345
|
+
return response?.ok ? response.data : null;
|
|
346
|
+
}
|
|
347
|
+
load() {
|
|
348
|
+
const file = loadProposals(this.dir);
|
|
349
|
+
if (!file)
|
|
350
|
+
throw new RecapError('NO_PROPOSALS', 'The meeting has no proposals');
|
|
351
|
+
return file;
|
|
352
|
+
}
|
|
353
|
+
find(n, file) {
|
|
354
|
+
const found = file.proposals.find((item) => item.n === n);
|
|
355
|
+
if (!found)
|
|
356
|
+
throw new RecapError('PROPOSAL_NOT_FOUND', `There is no proposal ${n}`);
|
|
357
|
+
return { ...found };
|
|
358
|
+
}
|
|
359
|
+
async store(proposal, file) {
|
|
360
|
+
const index = file.proposals.findIndex((item) => item.n === proposal.n);
|
|
361
|
+
if (index !== -1)
|
|
362
|
+
file.proposals[index] = proposal;
|
|
363
|
+
if (!file.branchDropped && !file.proposals.some((item) => item.status === 'pending')) {
|
|
364
|
+
const response = await this.bita.invoke(['docs', 'branch', 'drop', file.branch]).catch(() => null);
|
|
365
|
+
file.branchDropped = response?.ok === true;
|
|
366
|
+
}
|
|
367
|
+
saveProposals(file, this.dir);
|
|
368
|
+
}
|
|
369
|
+
accept(n, editedMarkdown) {
|
|
370
|
+
return withDirectoryLockAsync(path.join(this.dir, '.proposals.lockdir'), async () => {
|
|
371
|
+
const file = this.load();
|
|
372
|
+
const proposal = this.find(n, file);
|
|
373
|
+
if (proposal.status !== 'pending' && proposal.status !== 'stale')
|
|
374
|
+
throw new RecapError('PROPOSAL_CLOSED', `Proposal ${n} is already ${proposal.status}`);
|
|
375
|
+
const target = proposal.file;
|
|
376
|
+
if (editedMarkdown !== undefined) {
|
|
377
|
+
const text = readText(editedMarkdown);
|
|
378
|
+
if (text === null)
|
|
379
|
+
throw new RecapError('FILE_UNREADABLE', `Cannot read ${editedMarkdown}`);
|
|
380
|
+
if (trimmed(text).length === 0)
|
|
381
|
+
throw new RecapError('EMPTY_MARKDOWN', `${editedMarkdown} is empty`);
|
|
382
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
383
|
+
if (path.resolve(editedMarkdown) !== path.resolve(target))
|
|
384
|
+
writeAtomic(target, text);
|
|
385
|
+
}
|
|
386
|
+
if (editedMarkdown !== undefined || file.branchDropped) {
|
|
387
|
+
proposal.sha = await propose(this.bita, proposal.branch, proposal.pageId, proposal.section, target, proposal.title, file.entryId);
|
|
388
|
+
file.branchDropped = false;
|
|
389
|
+
}
|
|
390
|
+
const response = await this.bita.invoke(['docs', 'branch', 'apply', proposal.branch, '--commit', proposal.sha]);
|
|
391
|
+
if (response.ok) {
|
|
392
|
+
proposal.status = 'accepted';
|
|
393
|
+
const data = isRecord(response.data) ? response.data : {};
|
|
394
|
+
proposal.appliedSha = typeof data['appliedSha'] === 'string' ? data['appliedSha'] : typeof data['sha'] === 'string' ? data['sha'] : undefined;
|
|
395
|
+
}
|
|
396
|
+
else if (response.errorCode === 'MERGE_CONFLICT') {
|
|
397
|
+
proposal.status = 'stale';
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
throw new RecapError('BITA_FAILED', `bita docs branch apply failed: ${response.errorMessage ?? 'no reason given'}`);
|
|
401
|
+
}
|
|
402
|
+
proposal.updatedAt = isoNow();
|
|
403
|
+
await this.store(proposal, file);
|
|
404
|
+
return proposal;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
reject(n) {
|
|
408
|
+
return withDirectoryLockAsync(path.join(this.dir, '.proposals.lockdir'), async () => {
|
|
409
|
+
const file = this.load();
|
|
410
|
+
const proposal = this.find(n, file);
|
|
411
|
+
if (proposal.status !== 'pending' && proposal.status !== 'stale')
|
|
412
|
+
throw new RecapError('PROPOSAL_CLOSED', `Proposal ${n} is already ${proposal.status}`);
|
|
413
|
+
proposal.status = 'rejected';
|
|
414
|
+
proposal.updatedAt = isoNow();
|
|
415
|
+
await this.store(proposal, file);
|
|
416
|
+
return proposal;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { dayString, parseDate } from "../core/dates.js";
|
|
3
|
+
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
4
|
+
import { extractBetween, isRecord, swiftDefault } from "../core/json.js";
|
|
5
|
+
import { durationSeconds, emptyWrapup, updateMeeting } from "../core/meeting.js";
|
|
6
|
+
import { fold, formatDuration, prefix, replaceAll, suffix, trimmed } from "../core/text.js";
|
|
7
|
+
import { RecapError } from "../errors.js";
|
|
8
|
+
import { resultText, runSummaryClaude } from "../pipeline/claude.js";
|
|
9
|
+
import { loadResource } from "../pipeline/resources.js";
|
|
10
|
+
import { callBita, docsClient, meetingTarget, requireBita } from "./client.js";
|
|
11
|
+
export const FORBIDDEN_SECTIONS = ['pendiente', 'proximos pasos', 'hallazgo', 'preguntas abiertas', 'lo que falta'];
|
|
12
|
+
export function stripForbiddenSections(markdown) {
|
|
13
|
+
const kept = [];
|
|
14
|
+
let skipping = false;
|
|
15
|
+
let inFence = false;
|
|
16
|
+
for (const line of markdown.split('\n')) {
|
|
17
|
+
if (line.startsWith('```'))
|
|
18
|
+
inFence = !inFence;
|
|
19
|
+
if (!inFence && line.startsWith('## ')) {
|
|
20
|
+
const heading = fold(line.slice(3));
|
|
21
|
+
skipping = FORBIDDEN_SECTIONS.some((section) => heading.startsWith(section));
|
|
22
|
+
}
|
|
23
|
+
if (!skipping)
|
|
24
|
+
kept.push(line);
|
|
25
|
+
}
|
|
26
|
+
return kept.join('\n');
|
|
27
|
+
}
|
|
28
|
+
export function demoteHeadings(markdown) {
|
|
29
|
+
let inFence = false;
|
|
30
|
+
return markdown
|
|
31
|
+
.split('\n')
|
|
32
|
+
.map((line) => {
|
|
33
|
+
if (line.startsWith('```'))
|
|
34
|
+
inFence = !inFence;
|
|
35
|
+
return !inFence && line.startsWith('#') ? `#${line}` : line;
|
|
36
|
+
})
|
|
37
|
+
.join('\n');
|
|
38
|
+
}
|
|
39
|
+
export function parseWrapup(text) {
|
|
40
|
+
const start = text.indexOf('{');
|
|
41
|
+
const end = text.lastIndexOf('}');
|
|
42
|
+
if (start === -1 || end === -1 || start >= end)
|
|
43
|
+
throw new RecapError('WRAPUP_OUTPUT', `The wrap-up answer has no JSON object: ${prefix(text, 200)}`);
|
|
44
|
+
const raw = extractBetween(text, '{', '}');
|
|
45
|
+
const incomplete = (reason) => new RecapError('WRAPUP_OUTPUT', `The wrap-up JSON is incomplete: ${reason}`);
|
|
46
|
+
if (!isRecord(raw))
|
|
47
|
+
throw incomplete('the JSON object cannot be parsed');
|
|
48
|
+
if (typeof raw['title'] !== 'string')
|
|
49
|
+
throw incomplete('title is missing');
|
|
50
|
+
if (typeof raw['pageTitle'] !== 'string')
|
|
51
|
+
throw incomplete('pageTitle is missing');
|
|
52
|
+
if (typeof raw['pageMarkdown'] !== 'string')
|
|
53
|
+
throw incomplete('pageMarkdown is missing');
|
|
54
|
+
if (!Array.isArray(raw['backlog']))
|
|
55
|
+
throw incomplete('backlog is missing');
|
|
56
|
+
if (raw['project'] !== undefined && raw['project'] !== null && typeof raw['project'] !== 'string')
|
|
57
|
+
throw incomplete('project must be a string');
|
|
58
|
+
const items = [];
|
|
59
|
+
for (const item of raw['backlog']) {
|
|
60
|
+
if (!isRecord(item) || typeof item['kind'] !== 'string' || typeof item['title'] !== 'string')
|
|
61
|
+
throw incomplete('a backlog item is invalid');
|
|
62
|
+
if (item['body'] !== undefined && item['body'] !== null && typeof item['body'] !== 'string')
|
|
63
|
+
throw incomplete('a backlog body is invalid');
|
|
64
|
+
items.push({ kind: item['kind'], title: item['title'], body: typeof item['body'] === 'string' ? item['body'] : undefined });
|
|
65
|
+
}
|
|
66
|
+
const title = prefix(trimmed(raw['title']), 120);
|
|
67
|
+
const pageTitle = trimmed(raw['pageTitle']).length === 0 ? title : trimmed(raw['pageTitle']);
|
|
68
|
+
const page = trimmed(stripForbiddenSections(raw['pageMarkdown']));
|
|
69
|
+
if (title.length === 0 || page.length === 0)
|
|
70
|
+
throw new RecapError('WRAPUP_OUTPUT', 'The wrap-up answer has an empty title or page');
|
|
71
|
+
const project = typeof raw['project'] === 'string' ? trimmed(raw['project']) : undefined;
|
|
72
|
+
const backlog = items
|
|
73
|
+
.filter((item) => ['pending', 'finding'].includes(item.kind) && trimmed(item.title).length > 0)
|
|
74
|
+
.map((item) => ({ kind: item.kind, title: prefix(trimmed(item.title), 200), body: item.body === undefined ? undefined : trimmed(item.body) }));
|
|
75
|
+
return { title, project: project && project.length > 0 ? project : undefined, pageTitle, pageMarkdown: page, backlog };
|
|
76
|
+
}
|
|
77
|
+
export const GENERIC_TITLES = new Set([
|
|
78
|
+
'',
|
|
79
|
+
'reunion',
|
|
80
|
+
'reunion presencial',
|
|
81
|
+
'reunion remota',
|
|
82
|
+
'junta',
|
|
83
|
+
'junta presencial',
|
|
84
|
+
'junta remota',
|
|
85
|
+
'meet',
|
|
86
|
+
'meeting',
|
|
87
|
+
'llamada',
|
|
88
|
+
'videollamada',
|
|
89
|
+
'remote meeting',
|
|
90
|
+
'in-person meeting',
|
|
91
|
+
'in person meeting',
|
|
92
|
+
'reunion en sala',
|
|
93
|
+
'sesion',
|
|
94
|
+
]);
|
|
95
|
+
export function isGenericTitle(title) {
|
|
96
|
+
const folded = fold(title)
|
|
97
|
+
.split(/[^\p{L}\p{M}\p{N}-]+/u)
|
|
98
|
+
.filter((part) => part.length > 0)
|
|
99
|
+
.join(' ');
|
|
100
|
+
return GENERIC_TITLES.has(folded);
|
|
101
|
+
}
|
|
102
|
+
export function chooseProject(current, proposed, available) {
|
|
103
|
+
if (current && trimmed(current).length > 0)
|
|
104
|
+
return { apply: null, resolved: true };
|
|
105
|
+
if (!proposed)
|
|
106
|
+
return { apply: null, resolved: false };
|
|
107
|
+
const wanted = fold(proposed);
|
|
108
|
+
const match = available.find((name) => fold(name) === wanted);
|
|
109
|
+
return match ? { apply: match, resolved: true } : { apply: null, resolved: false };
|
|
110
|
+
}
|
|
111
|
+
export const NOTE_SECTION = 'Reunión';
|
|
112
|
+
export function noteBody(summary, meeting, dir) {
|
|
113
|
+
let body = summary;
|
|
114
|
+
const start = body.indexOf('## Resumen');
|
|
115
|
+
if (start !== -1)
|
|
116
|
+
body = body.slice(start);
|
|
117
|
+
const mode = meeting.mode === 'remote' ? 'remota' : 'presencial';
|
|
118
|
+
const lines = [`Minuta de la reunión ${mode} (${formatDuration(durationSeconds(meeting))}), generada por recap a partir de la grabación.`, ''];
|
|
119
|
+
let inFence = false;
|
|
120
|
+
for (const line of body.split('\n')) {
|
|
121
|
+
if (line.startsWith('```'))
|
|
122
|
+
inFence = !inFence;
|
|
123
|
+
lines.push(!inFence && line.startsWith('#') ? `#${line}` : line);
|
|
124
|
+
}
|
|
125
|
+
while (lines.length > 0 && trimmed(lines[lines.length - 1] ?? '').length === 0)
|
|
126
|
+
lines.pop();
|
|
127
|
+
lines.push('', `Archivos de la reunión: \`${dir}\` (\`summary.md\`, \`transcript.md\`${meeting.mode === 'remote' ? ', `frames/`' : ''}).`);
|
|
128
|
+
return `${lines.join('\n')}\n`;
|
|
129
|
+
}
|
|
130
|
+
export async function saveMinutes(meeting, dir, config, client) {
|
|
131
|
+
const entryId = meeting.bitaEntryId;
|
|
132
|
+
if (entryId === undefined)
|
|
133
|
+
return;
|
|
134
|
+
const bita = client ?? (await requireBita(config, meetingTarget(meeting)));
|
|
135
|
+
const summary = readText(path.join(dir, 'summary.md'));
|
|
136
|
+
if (summary === null)
|
|
137
|
+
throw new RecapError('NO_SUMMARY', `"${meeting.title}" has no summary to send to bita`);
|
|
138
|
+
const note = path.join(dir, 'bita-note.md');
|
|
139
|
+
writeAtomic(note, noteBody(summary, meeting, dir));
|
|
140
|
+
const result = await bita.run(['note', 'save', String(entryId), '--note-md', note, '--section', NOTE_SECTION, '--json', ...bita.targetArgs()]);
|
|
141
|
+
if (result.status !== 0) {
|
|
142
|
+
throw new RecapError('BITA_FAILED', `bita note save ${entryId} failed: ${suffix(trimmed(result.stdout + result.stderr), 400)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export function meetingDay(meeting) {
|
|
146
|
+
return dayString(parseDate(meeting.startedAt) ?? parseDate(meeting.createdAt) ?? new Date());
|
|
147
|
+
}
|
|
148
|
+
export function encodeWrapupPlan(plan) {
|
|
149
|
+
return swiftDefault({
|
|
150
|
+
title: plan.title,
|
|
151
|
+
project: plan.project,
|
|
152
|
+
pageTitle: plan.pageTitle,
|
|
153
|
+
pageMarkdown: plan.pageMarkdown,
|
|
154
|
+
backlog: plan.backlog.map((item) => ({ kind: item.kind, title: item.title, body: item.body })),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
async function pageText(docs, pageId) {
|
|
158
|
+
const page = await callBita(docs, ['docs', 'page', 'show', String(pageId)]);
|
|
159
|
+
const doc = isRecord(page) && isRecord(page['doc']) ? page['doc'] : undefined;
|
|
160
|
+
const file = typeof doc?.['path'] === 'string' ? doc['path'] : undefined;
|
|
161
|
+
return file ? readText(file) : null;
|
|
162
|
+
}
|
|
163
|
+
export async function planWrapup(meeting, dir, snapshot, projects, existingPage, claude) {
|
|
164
|
+
const summary = readText(path.join(dir, 'summary.md')) ?? '';
|
|
165
|
+
const transcript = readText(path.join(dir, 'transcript.md')) ?? '';
|
|
166
|
+
const pageNote = existingPage === null
|
|
167
|
+
? ''
|
|
168
|
+
: `- Esta reunión ya tiene página. \`pageMarkdown\` se agregará como una sección nueva de esa página; no repitas lo que ya dice:\n<pagina_actual>\n${prefix(existingPage, 20_000)}\n</pagina_actual>`;
|
|
169
|
+
let prompt = loadResource('wrapup-prompt.md');
|
|
170
|
+
prompt = replaceAll(prompt, '{{currentTitle}}', snapshot.title);
|
|
171
|
+
prompt = replaceAll(prompt, '{{currentProject}}', snapshot.projectName ?? 'ninguno');
|
|
172
|
+
prompt = replaceAll(prompt, '{{date}}', meetingDay(meeting));
|
|
173
|
+
prompt = replaceAll(prompt, '{{duration}}', formatDuration(durationSeconds(meeting)));
|
|
174
|
+
prompt = replaceAll(prompt, '{{mode}}', meeting.mode === 'remote' ? 'remota' : 'presencial');
|
|
175
|
+
prompt = replaceAll(prompt, '{{projects}}', projects.map((name) => ` - ${name}`).join('\n'));
|
|
176
|
+
prompt = replaceAll(prompt, '{{existingPage}}', pageNote);
|
|
177
|
+
prompt = replaceAll(prompt, '{{summary}}', summary);
|
|
178
|
+
prompt = replaceAll(prompt, '{{transcript}}', transcript);
|
|
179
|
+
return parseWrapup(await claude(prompt));
|
|
180
|
+
}
|
|
181
|
+
export async function runWrapup(meeting, dir, config, dependencies) {
|
|
182
|
+
const entryId = meeting.bitaEntryId;
|
|
183
|
+
if (entryId === undefined)
|
|
184
|
+
return;
|
|
185
|
+
const bita = dependencies?.bita ?? (await requireBita(config, meetingTarget(meeting)));
|
|
186
|
+
const docs = dependencies?.docs ?? (await docsClient(config, meetingTarget(meeting))) ?? bita;
|
|
187
|
+
const claude = dependencies?.claude ?? (async (prompt) => resultText(await runSummaryClaude(prompt, dir, config)));
|
|
188
|
+
const snapshot = meeting.bitaEntry ?? { title: meeting.title, pageIds: [] };
|
|
189
|
+
const wrapup = { ...emptyWrapup(), ...(meeting.wrapup ?? {}) };
|
|
190
|
+
const listed = await callBita(bita, ['projects']);
|
|
191
|
+
const projects = (Array.isArray(listed) ? listed : [])
|
|
192
|
+
.filter(isRecord)
|
|
193
|
+
.filter((project) => project['active'] !== false)
|
|
194
|
+
.flatMap((project) => (typeof project['name'] === 'string' ? [project['name']] : []));
|
|
195
|
+
const existingPageId = wrapup.pageId ?? snapshot.pageIds[0];
|
|
196
|
+
const existingPage = existingPageId === undefined ? null : await pageText(docs, existingPageId);
|
|
197
|
+
const plan = await planWrapup(meeting, dir, snapshot, projects, existingPage, claude);
|
|
198
|
+
writeAtomic(path.join(dir, 'wrapup.json'), encodeWrapupPlan(plan));
|
|
199
|
+
if (isGenericTitle(snapshot.title)) {
|
|
200
|
+
await callBita(bita, ['amend', String(entryId), '--title', plan.title]);
|
|
201
|
+
wrapup.title = plan.title;
|
|
202
|
+
wrapup.titleChanged = true;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
wrapup.title = snapshot.title;
|
|
206
|
+
}
|
|
207
|
+
const choice = chooseProject(snapshot.projectName, plan.project, projects);
|
|
208
|
+
if (choice.apply)
|
|
209
|
+
await callBita(bita, ['amend', String(entryId), '--project', choice.apply]);
|
|
210
|
+
wrapup.project = choice.apply ?? snapshot.projectName;
|
|
211
|
+
wrapup.projectResolved = choice.resolved;
|
|
212
|
+
const pageFile = path.join(dir, 'bita-page.md');
|
|
213
|
+
if (existingPageId !== undefined) {
|
|
214
|
+
writeAtomic(pageFile, demoteHeadings(plan.pageMarkdown));
|
|
215
|
+
await callBita(docs, ['docs', 'page', 'write', String(existingPageId), '--md', pageFile, '--section', `Reunión ${meetingDay(meeting)}`]);
|
|
216
|
+
wrapup.pageId = existingPageId;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
writeAtomic(pageFile, plan.pageMarkdown);
|
|
220
|
+
const args = ['docs', 'page', 'new', plan.pageTitle, '--from-entry', String(entryId)];
|
|
221
|
+
if (wrapup.project)
|
|
222
|
+
args.push('--project', wrapup.project);
|
|
223
|
+
const created = await callBita(docs, args);
|
|
224
|
+
const page = isRecord(created) && isRecord(created['page']) ? created['page'] : undefined;
|
|
225
|
+
const pageId = typeof page?.['pageId'] === 'number' ? page['pageId'] : undefined;
|
|
226
|
+
if (pageId === undefined)
|
|
227
|
+
throw new RecapError('BITA_FAILED', 'bita docs page new did not return the page id');
|
|
228
|
+
await callBita(docs, ['docs', 'page', 'write', String(pageId), '--md', pageFile]);
|
|
229
|
+
wrapup.pageId = pageId;
|
|
230
|
+
wrapup.pageCreated = true;
|
|
231
|
+
}
|
|
232
|
+
updateMeeting(dir, (current) => {
|
|
233
|
+
current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
|
|
234
|
+
});
|
|
235
|
+
for (const item of plan.backlog) {
|
|
236
|
+
if (wrapup.backlogKeys[item.title] !== undefined)
|
|
237
|
+
continue;
|
|
238
|
+
const args = ['backlog', 'add', '--kind', item.kind, '--title', item.title, '--page', String(wrapup.pageId ?? 0)];
|
|
239
|
+
if (item.body) {
|
|
240
|
+
const bodyFile = path.join(dir, 'bita-backlog-item.md');
|
|
241
|
+
writeAtomic(bodyFile, item.body);
|
|
242
|
+
args.push('--md', bodyFile);
|
|
243
|
+
}
|
|
244
|
+
const added = await callBita(docs, args);
|
|
245
|
+
wrapup.backlogKeys[item.title] = isRecord(added) && typeof added['key'] === 'string' ? added['key'] : '?';
|
|
246
|
+
updateMeeting(dir, (current) => {
|
|
247
|
+
current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
await saveMinutes(meeting, dir, config, bita);
|
|
251
|
+
updateMeeting(dir, (current) => {
|
|
252
|
+
current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
|
|
253
|
+
});
|
|
254
|
+
}
|