@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.
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 +109 -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 +22 -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 +112 -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,77 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { settingsOf } from "../core/config.js";
3
+ import { isoNow, minuteStamp, parseDate } from "../core/dates.js";
4
+ import { replaceAll, trimmed } from "../core/text.js";
5
+ import { RecapError } from "../errors.js";
6
+ import { streamArguments, streamClaude } from "../pipeline/claude.js";
7
+ import { loadResource } from "../pipeline/resources.js";
8
+ import { existingRepos } from "./context.js";
9
+ import { appendLines, liveFiles } from "./files.js";
10
+ import { loadLiveTranscript, renderLive, transcriptWindow } from "./merger.js";
11
+ import { AskStreamReducer, ProgressDescriber, encodeAnswer } from "./stream.js";
12
+ export const READ_TOOLS = ['Read', 'Grep', 'Glob'];
13
+ export const GIT_SUBCOMMANDS = ['log', 'show', 'diff'];
14
+ export const AVAILABLE_TOOLS = ['Read', 'Grep', 'Glob', 'Bash'];
15
+ export function allowedTools(context) {
16
+ return [...READ_TOOLS, ...existingRepos(context).flatMap((repo) => GIT_SUBCOMMANDS.map((sub) => `Bash(git -C ${repo.path} ${sub}:*)`))];
17
+ }
18
+ export function askAddDirs(context) {
19
+ return [...(context.docsRoot ? [context.docsRoot] : []), ...existingRepos(context).map((repo) => repo.path)];
20
+ }
21
+ export function renderAskPrompt(template, request, context, segments) {
22
+ const window = transcriptWindow(segments, request.windowSeconds);
23
+ const labelled = request.meeting.mode === 'remote';
24
+ const title = request.meeting.bitaEntry?.title ?? request.meeting.title;
25
+ const question = request.question ? trimmed(request.question) : '';
26
+ const questionBlock = question.length > 0
27
+ ? `La pregunta que hay que responder es:\n\n> ${question}\n\nUsa la transcripción solo como contexto.`
28
+ : 'No te dieron la pregunta: identifícala en la transcripción de abajo. Es la última pregunta dirigida a quien graba (la persona del micrófono «Sala» en reuniones remotas) o la última que quedó sin responder. Si hay varias, responde la más reciente.';
29
+ const pages = context.pages.length === 0
30
+ ? ' (sin páginas registradas para este proyecto)'
31
+ : context.pages.map((page) => ` ${' '.repeat(Math.max(0, page.depth))}- #${page.pageId} ${page.title} — ${page.relPath}`).join('\n');
32
+ const repos = existingRepos(context);
33
+ const reposText = repos.length === 0 ? ' (sin repositorios registrados)' : repos.map((repo) => ` - ${repo.slug}: ${repo.path} (git: \`git -C ${repo.path} log|show|diff …\`)`).join('\n');
34
+ const speakers = labelled
35
+ ? '«Sala» es el micrófono local (quien graba y quien esté en su sala); «Remotos» es el audio de la llamada.'
36
+ : 'Un solo micrófono en la sala, sin separación por persona.';
37
+ let text = template;
38
+ text = replaceAll(text, '{{title}}', title);
39
+ text = replaceAll(text, '{{project}}', context.project ?? 'sin proyecto');
40
+ text = replaceAll(text, '{{now}}', minuteStamp(parseDate(request.now) ?? new Date()));
41
+ text = replaceAll(text, '{{questionBlock}}', questionBlock);
42
+ text = replaceAll(text, '{{docsRoot}}', context.docsRoot ?? '(desconocida)');
43
+ text = replaceAll(text, '{{pages}}', pages);
44
+ text = replaceAll(text, '{{repos}}', reposText);
45
+ text = replaceAll(text, '{{speakers}}', speakers);
46
+ text = replaceAll(text, '{{transcript}}', renderLive(window, labelled));
47
+ return text;
48
+ }
49
+ export async function runAsk(config, request, context, emit, onSpawn) {
50
+ const segments = loadLiveTranscript(request.dir);
51
+ const explicit = request.question && trimmed(request.question).length > 0 ? trimmed(request.question) : null;
52
+ if (explicit === null && segments.length === 0)
53
+ throw new RecapError('NO_QUESTION', 'The live transcript is empty; pass --question');
54
+ const prompt = renderAskPrompt(loadResource('ask-prompt.md'), request, context, segments);
55
+ if (explicit !== null)
56
+ emit({ type: 'question', text: explicit });
57
+ const describer = new ProgressDescriber(context.docsRoot, existingRepos(context));
58
+ const args = streamArguments([allowedTools(context).join(' ')], askAddDirs(context), settingsOf(config).assistModel, AVAILABLE_TOOLS);
59
+ const reducer = new AskStreamReducer(explicit);
60
+ const result = await streamClaude(prompt, request.dir, config, args, (line) => {
61
+ for (const event of reducer.consume(line, describer))
62
+ emit(event);
63
+ }, onSpawn);
64
+ const { events, answer } = reducer.finish(result.status, result.stderr, randomUUID().toLowerCase(), request.now, transcriptWindow(segments, request.windowSeconds));
65
+ answer.auto = request.auto ? true : undefined;
66
+ if (request.questionMs !== undefined || request.channel !== undefined) {
67
+ answer.questionMs = request.questionMs;
68
+ answer.channel = request.channel;
69
+ }
70
+ answer.askId = request.askId;
71
+ for (const event of events)
72
+ emit(event);
73
+ answer.answeredAt = isoNow();
74
+ appendLines([encodeAnswer(answer)], liveFiles.answers(request.dir));
75
+ emit({ type: 'done', answer });
76
+ return answer;
77
+ }
@@ -0,0 +1,185 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isoNow, parseDate } from "../core/dates.js";
5
+ import { readText, writeAtomic } from "../core/fsutil.js";
6
+ import { isRecord, swiftLine } from "../core/json.js";
7
+ import { withDirectoryLock } from "../core/lock.js";
8
+ import { isAlive } from "../core/proc.js";
9
+ import { RecapError } from "../errors.js";
10
+ import { liveFiles } from "./files.js";
11
+ export function decodeAskingState(value) {
12
+ if (!isRecord(value) || typeof value['startedAt'] !== 'string' || parseDate(value['startedAt']) === null)
13
+ return null;
14
+ if (value['id'] !== undefined && value['id'] !== null && typeof value['id'] !== 'string')
15
+ return null;
16
+ if (value['question'] !== undefined && value['question'] !== null && typeof value['question'] !== 'string')
17
+ return null;
18
+ return {
19
+ id: typeof value['id'] === 'string' ? value['id'] : undefined,
20
+ question: typeof value['question'] === 'string' ? value['question'] : null,
21
+ startedAt: value['startedAt'],
22
+ auto: value['auto'] === true,
23
+ questionMs: typeof value['questionMs'] === 'number' && Number.isInteger(value['questionMs']) ? value['questionMs'] : undefined,
24
+ channel: value['channel'] === 'mic' || value['channel'] === 'system' ? value['channel'] : undefined,
25
+ state: value['state'] === 'queued' || value['state'] === 'running' ? value['state'] : undefined,
26
+ pid: typeof value['pid'] === 'number' && Number.isInteger(value['pid']) ? value['pid'] : undefined,
27
+ };
28
+ }
29
+ export function encodeAskingState(state) {
30
+ return swiftLine({
31
+ id: state.id,
32
+ question: state.question,
33
+ startedAt: state.startedAt,
34
+ auto: state.auto,
35
+ questionMs: state.questionMs,
36
+ channel: state.channel,
37
+ state: state.state,
38
+ pid: state.pid,
39
+ });
40
+ }
41
+ export function isRunningAsk(state) {
42
+ return state.state !== 'queued';
43
+ }
44
+ export function readLegacyAsking(meetingDir) {
45
+ const text = readText(liveFiles.asking(meetingDir));
46
+ if (text === null)
47
+ return null;
48
+ try {
49
+ return decodeAskingState(JSON.parse(text));
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ export function makeAskId() {
56
+ return randomUUID().toLowerCase();
57
+ }
58
+ export function isValidAskId(id) {
59
+ return id.length > 0 && id.length <= 64 && !id.startsWith('-') && /^[a-z0-9-]+$/.test(id);
60
+ }
61
+ function boardFile(meetingDir, id) {
62
+ return path.join(liveFiles.askingDir(meetingDir), `${id}.json`);
63
+ }
64
+ function lockDir(meetingDir) {
65
+ return path.join(liveFiles.askingDir(meetingDir), '.board.lock');
66
+ }
67
+ function stored(meetingDir) {
68
+ const folder = liveFiles.askingDir(meetingDir);
69
+ let names = [];
70
+ try {
71
+ names = readdirSync(folder).filter((name) => name.endsWith('.json') && !name.startsWith('.'));
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ const found = [];
77
+ for (const name of names) {
78
+ const file = path.join(folder, name);
79
+ const text = readText(file);
80
+ if (text === null)
81
+ continue;
82
+ let state = null;
83
+ try {
84
+ state = decodeAskingState(JSON.parse(text));
85
+ }
86
+ catch {
87
+ state = null;
88
+ }
89
+ if (!state)
90
+ continue;
91
+ let modified = 0;
92
+ try {
93
+ modified = statSync(file).mtimeMs;
94
+ }
95
+ catch {
96
+ modified = 0;
97
+ }
98
+ found.push({ file, state, alive: state.pid === undefined ? true : isAlive(state.pid), modified });
99
+ }
100
+ return found
101
+ .sort((left, right) => (left.modified === right.modified ? (left.file < right.file ? -1 : left.file > right.file ? 1 : 0) : left.modified - right.modified))
102
+ .map(({ file, state, alive }) => ({ file, state, alive }));
103
+ }
104
+ export function boardEntries(meetingDir) {
105
+ return stored(meetingDir)
106
+ .filter((item) => item.alive)
107
+ .map((item) => item.state);
108
+ }
109
+ export function latestRunning(entries) {
110
+ let winner = null;
111
+ for (const state of entries) {
112
+ if (!isRunningAsk(state))
113
+ continue;
114
+ const time = parseDate(state.startedAt)?.getTime() ?? 0;
115
+ if (!winner || time >= winner.time)
116
+ winner = { state, time };
117
+ }
118
+ return winner?.state ?? null;
119
+ }
120
+ function refreshLegacy(meetingDir) {
121
+ const legacy = liveFiles.asking(meetingDir);
122
+ const latest = latestRunning(boardEntries(meetingDir));
123
+ if (!latest) {
124
+ rmSync(legacy, { force: true });
125
+ return;
126
+ }
127
+ writeAtomic(legacy, encodeAskingState(latest));
128
+ }
129
+ export function writeBoard(meetingDir, state) {
130
+ if (!state.id || !isValidAskId(state.id))
131
+ throw new RecapError('ASK_ID_INVALID', `Invalid ask id ${state.id ?? '(none)'}`);
132
+ const id = state.id;
133
+ withDirectoryLock(lockDir(meetingDir), () => {
134
+ writeAtomic(boardFile(meetingDir, id), encodeAskingState(state));
135
+ refreshLegacy(meetingDir);
136
+ });
137
+ }
138
+ export function removeBoard(meetingDir, id) {
139
+ if (!isValidAskId(id))
140
+ return;
141
+ try {
142
+ withDirectoryLock(lockDir(meetingDir), () => {
143
+ rmSync(boardFile(meetingDir, id), { force: true });
144
+ refreshLegacy(meetingDir);
145
+ });
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ }
151
+ export function pruneBoard(meetingDir) {
152
+ if (!existsSync(liveFiles.askingDir(meetingDir)))
153
+ return;
154
+ try {
155
+ withDirectoryLock(lockDir(meetingDir), () => {
156
+ for (const item of stored(meetingDir))
157
+ if (!item.alive)
158
+ rmSync(item.file, { force: true });
159
+ refreshLegacy(meetingDir);
160
+ });
161
+ }
162
+ catch {
163
+ return;
164
+ }
165
+ }
166
+ export async function performAsk(options, body) {
167
+ const id = options.id ?? makeAskId();
168
+ const question = options.question && options.question.trim().length > 0 ? options.question.trim() : null;
169
+ writeBoard(options.dir, {
170
+ id,
171
+ question,
172
+ startedAt: options.now ?? isoNow(),
173
+ auto: options.auto,
174
+ questionMs: options.questionMs,
175
+ channel: options.channel,
176
+ state: 'running',
177
+ pid: options.pid ?? process.pid,
178
+ });
179
+ try {
180
+ return await body();
181
+ }
182
+ finally {
183
+ removeBoard(options.dir, id);
184
+ }
185
+ }
@@ -0,0 +1,90 @@
1
+ import path from 'node:path';
2
+ import { isDirectory } from "../core/fsutil.js";
3
+ import { isRecord } from "../core/json.js";
4
+ import { trimmed } from "../core/text.js";
5
+ export function emptyContext(overrides = {}) {
6
+ return { project: null, docsRoot: null, pages: [], repos: [], ...overrides };
7
+ }
8
+ export function existingRepos(context) {
9
+ return context.repos.filter((repo) => repo.exists);
10
+ }
11
+ export function parsePages(data) {
12
+ const list = isRecord(data) && Array.isArray(data['pages']) ? data['pages'] : [];
13
+ const result = [];
14
+ const walk = (items, depth) => {
15
+ for (const item of items) {
16
+ if (!isRecord(item))
17
+ continue;
18
+ const id = item['pageId'];
19
+ const title = item['title'];
20
+ if (typeof id !== 'number' || !Number.isInteger(id) || typeof title !== 'string')
21
+ continue;
22
+ result.push({
23
+ pageId: id,
24
+ title,
25
+ relPath: typeof item['relPath'] === 'string' ? item['relPath'] : '',
26
+ depth: typeof item['depth'] === 'number' && Number.isInteger(item['depth']) ? item['depth'] : depth,
27
+ });
28
+ if (Array.isArray(item['children']))
29
+ walk(item['children'], depth + 1);
30
+ }
31
+ };
32
+ walk(list, 0);
33
+ return result;
34
+ }
35
+ export function parseRepos(data) {
36
+ const list = isRecord(data) && Array.isArray(data['repos']) ? data['repos'] : [];
37
+ const seen = new Set();
38
+ const result = [];
39
+ for (const item of list) {
40
+ if (!isRecord(item))
41
+ continue;
42
+ const repoPath = item['path'];
43
+ if (typeof repoPath !== 'string' || repoPath.length === 0 || seen.has(repoPath))
44
+ continue;
45
+ seen.add(repoPath);
46
+ const slugValue = typeof item['slug'] === 'string' && item['slug'].length > 0 ? item['slug'] : path.basename(repoPath);
47
+ const reported = typeof item['exists'] === 'boolean' ? item['exists'] : true;
48
+ result.push({ path: repoPath, slug: slugValue, exists: reported && isDirectory(repoPath) });
49
+ }
50
+ return result;
51
+ }
52
+ function metaRoot(meta) {
53
+ return typeof meta?.['root'] === 'string' ? meta['root'] : null;
54
+ }
55
+ export async function loadProjectContext(project, docsRoot, bita) {
56
+ const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null, docsRoot: docsRoot ?? null });
57
+ if (!bita)
58
+ return context;
59
+ if (context.project) {
60
+ const pages = await bita.invoke(['docs', 'page', 'ls', '--project', context.project]).catch(() => null);
61
+ if (pages?.ok) {
62
+ context.pages = parsePages(pages.data);
63
+ context.docsRoot ??= metaRoot(pages.meta);
64
+ }
65
+ const repos = await bita.invoke(['project', 'repo', 'ls', '--project', context.project]).catch(() => null);
66
+ if (repos?.ok)
67
+ context.repos = parseRepos(repos.data);
68
+ }
69
+ if (context.docsRoot === null) {
70
+ const tree = await bita.invoke(['docs', 'tree']).catch(() => null);
71
+ if (tree?.ok)
72
+ context.docsRoot = metaRoot(tree.meta);
73
+ }
74
+ return context;
75
+ }
76
+ export function meetingProjectName(meeting) {
77
+ return meeting.bitaEntry?.projectName ?? meeting.wrapup?.project ?? null;
78
+ }
79
+ export async function meetingProject(meeting, bita) {
80
+ const name = meetingProjectName(meeting);
81
+ if (name && trimmed(name).length > 0)
82
+ return name;
83
+ const pageId = meeting.wrapup?.pageId ?? meeting.bitaEntry?.pageIds[0];
84
+ if (!bita || pageId === undefined)
85
+ return null;
86
+ const response = await bita.invoke(['docs', 'page', 'show', String(pageId), '--no-markdown']).catch(() => null);
87
+ if (!response?.ok || !isRecord(response.data))
88
+ return null;
89
+ return typeof response.data['projectName'] === 'string' ? response.data['projectName'] : null;
90
+ }
@@ -0,0 +1,21 @@
1
+ import { jaccard, jaccardSets, tokens } from "../core/text.js";
2
+ export const DEDUPE_THRESHOLD = 0.5;
3
+ export const STOPWORDS = new Set([
4
+ 'el', 'la', 'los', 'las', 'lo', 'un', 'una', 'unos', 'unas', 'de', 'del', 'al', 'en', 'por', 'para', 'con',
5
+ 'sin', 'se', 'es', 'son', 'como', 'cual', 'cuales', 'donde', 'cuando', 'quien', 'que', 'y', 'o',
6
+ 'u', 'le', 'les', 'su', 'sus', 'me', 'mi', 'te', 'tu', 'nos', 'este', 'esta', 'esto', 'ese', 'esa', 'eso',
7
+ 'hay', 'ya', 'mas', 'pero', 'si', 'no', 'muy', 'hace', 'hacer', 'puede', 'podemos',
8
+ ]);
9
+ export function questionTokens(text) {
10
+ return new Set([...tokens(text)].filter((token) => !STOPWORDS.has(token)));
11
+ }
12
+ export function questionSimilarity(a, b) {
13
+ const left = questionTokens(a);
14
+ const right = questionTokens(b);
15
+ if (left.size === 0 || right.size === 0)
16
+ return jaccard(a, b);
17
+ return jaccardSets(left, right);
18
+ }
19
+ export function isDuplicateQuestion(question, known) {
20
+ return known.some((other) => questionSimilarity(question, other) >= DEDUPE_THRESHOLD);
21
+ }