@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,32 @@
|
|
|
1
|
+
Vas a cerrar en bita el registro de una reunión ya transcrita y resumida. Con la minuta y la transcripción de abajo, decide cómo queda documentada.
|
|
2
|
+
|
|
3
|
+
- Título actual del contador: «{{currentTitle}}»
|
|
4
|
+
- Proyecto actual del contador: {{currentProject}}
|
|
5
|
+
- Fecha: {{date}} · Duración: {{duration}} · Modo: {{mode}}
|
|
6
|
+
- Proyectos que existen en bita (elige solo de esta lista, con el nombre exacto):
|
|
7
|
+
{{projects}}
|
|
8
|
+
{{existingPage}}
|
|
9
|
+
|
|
10
|
+
Responde únicamente con un objeto JSON, sin texto antes ni después y sin bloque de código, con esta forma:
|
|
11
|
+
|
|
12
|
+
{"title": "...", "project": "..." | null, "pageTitle": "...", "pageMarkdown": "...", "backlog": [{"kind": "pending" | "finding", "title": "...", "body": "..."}]}
|
|
13
|
+
|
|
14
|
+
Reglas por campo:
|
|
15
|
+
|
|
16
|
+
- **title**: el tema real de la reunión, corto (máximo 80 caracteres), reconocible en Jira. Por ejemplo, «Reunión presencial: iniciativa de recompra con cupón post-entrega». Sin fecha.
|
|
17
|
+
- **project**: el nombre exacto de uno de los proyectos de la lista, solo si la conversación deja claro a cuál pertenece (se menciona el cliente, el producto o el sistema). Si hay duda, o si ninguno aplica, `null`. Nunca inventes un nombre.
|
|
18
|
+
- **pageTitle**: el título de la página de documentación, el tema sin la palabra «reunión». Por ejemplo, «Iniciativa de recompra con cupón post-entrega».
|
|
19
|
+
- **pageMarkdown**: el cuerpo de la página, en Markdown, sin título H1. Debe ser un documento técnico formal que explica **cómo es** lo que se trató, en presente:
|
|
20
|
+
- Secciones `##` por tema: contexto y objetivo, cómo funciona o cómo se propone, decisiones tomadas y, si aplica, arquitectura, datos, costos o plan de pruebas, con las cifras y nombres que se dijeron.
|
|
21
|
+
- Si hay un flujo o una arquitectura, puedes incluir un diagrama en un bloque `mermaid`. Solo con componentes que se mencionaron: si algo no se nombró, no lo supongas.
|
|
22
|
+
- **Prohibido**: secciones de «Pendientes», «Próximos pasos», «Hallazgos», «Preguntas abiertas» o «Lo que falta»; eso va en `backlog`. Tampoco «se acordó con el usuario», «según lo solicitado», «decidimos», «creo que», primera ni segunda persona, ni comentarios sobre la grabación o la transcripción.
|
|
23
|
+
- Entre 3 y 7 secciones. Nada que no esté en la minuta o en la transcripción.
|
|
24
|
+
- **backlog**: un ítem por cada pendiente (`kind: "pending"`, redactado como acción que empieza con verbo; en `body` el responsable, la fecha y el contexto si se mencionaron) y por cada pregunta abierta o riesgo (`kind: "finding"`). Títulos de una línea, sin numeración. Lista vacía si no hay ninguno.
|
|
25
|
+
|
|
26
|
+
<minuta>
|
|
27
|
+
{{summary}}
|
|
28
|
+
</minuta>
|
|
29
|
+
|
|
30
|
+
<transcripcion>
|
|
31
|
+
{{transcript}}
|
|
32
|
+
</transcripcion>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Responde la última pregunta de la reunión en curso con las páginas y repos del proyecto
|
|
3
|
+
argument-hint: [pregunta, opcional]
|
|
4
|
+
allowed-tools: Bash(recap ask:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Responde en vivo una pregunta de la reunión que se está grabando.
|
|
8
|
+
|
|
9
|
+
- **Con `$ARGUMENTS`**, es la pregunta:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
recap ask --active --question "$ARGUMENTS" --json
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- **Sin `$ARGUMENTS`**, recap toma la última pregunta de la transcripción en
|
|
16
|
+
vivo:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
recap ask --active --json
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Muestra `data.answer` tal cual y, debajo, las fuentes de `data.sources` (su
|
|
23
|
+
`label`). Si `data.found` es false, dilo en una línea: no está documentado.
|
|
24
|
+
Si falla con `NOT_RECORDING`, no hay grabación activa; con `NO_QUESTION`, la
|
|
25
|
+
transcripción en vivo todavía está vacía y hace falta escribir la pregunta.
|
|
26
|
+
Puede haber otras respuestas en curso al mismo tiempo; no se estorban. Las
|
|
27
|
+
respuestas que el live-worker encontró solo llevan `auto: true` en
|
|
28
|
+
`live/answers.jsonl`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Lista las reuniones grabadas, o muestra la minuta de una
|
|
3
|
+
argument-hint: [id, parte del título o "last"]
|
|
4
|
+
allowed-tools: Bash(recap list:*), Bash(recap show:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**Sin `$ARGUMENTS`**, lista las reuniones recientes:
|
|
8
|
+
|
|
9
|
+
!`recap list`
|
|
10
|
+
|
|
11
|
+
y muéstralas en una tabla corta (fecha, modo, estado, duración, título).
|
|
12
|
+
|
|
13
|
+
**Con `$ARGUMENTS`**, muestra esa reunión:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
recap show "$ARGUMENTS"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Si tiene minuta, muéstrala tal cual. Si no, di en qué estado está y si alguna
|
|
20
|
+
etapa falló. Si el texto coincide con varias reuniones (`MEETING_AMBIGUOUS`),
|
|
21
|
+
lista las candidatas y pregunta cuál.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Revisa los cambios a la documentación que propuso una reunión
|
|
3
|
+
argument-hint: [id de la reunión o "last"]
|
|
4
|
+
allowed-tools: Bash(recap proposals:*), Bash(recap show:*), Read, Write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Al cerrar una reunión ligada a bita, recap deja los cambios explícitos a
|
|
8
|
+
páginas existentes como propuestas en la rama `proposal/meeting-<entrada>` de
|
|
9
|
+
los docs. Nada llega a `main` sin aceptarse.
|
|
10
|
+
|
|
11
|
+
1. La reunión es `$ARGUMENTS`, o `last` si viene vacío. Lista sus propuestas:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
recap proposals ls <reunión> --json
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. Por cada una en `pending` o `stale`, muestra el detalle con
|
|
18
|
+
`recap proposals show <reunión> <n> --json`: título, página y sección,
|
|
19
|
+
justificación, citas (con su minuto) y el diff.
|
|
20
|
+
3. Pregunta qué hacer con cada una y aplícalo:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
recap proposals accept <reunión> <n> --json
|
|
24
|
+
recap proposals accept <reunión> <n> --md <archivo-editado> --json
|
|
25
|
+
recap proposals reject <reunión> <n> --json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Para editar antes de aceptar, copia `proposal.file`, haz el cambio que pida
|
|
29
|
+
el usuario y pásalo con `--md`.
|
|
30
|
+
|
|
31
|
+
Si `accept` deja la propuesta en `stale`, la página cambió desde la propuesta:
|
|
32
|
+
muéstralo, y ofrece editarla sobre la versión actual y aceptarla con `--md`.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Empieza a grabar una reunión (remota o presencial)
|
|
3
|
+
argument-hint: [remota|presencial] [título]
|
|
4
|
+
allowed-tools: Bash(recap start:*), Bash(recap status:*), Bash(recap import:*), Bash(bita start:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Estado actual:
|
|
8
|
+
|
|
9
|
+
!`recap status --json`
|
|
10
|
+
|
|
11
|
+
Empieza a grabar una reunión con `$ARGUMENTS`.
|
|
12
|
+
|
|
13
|
+
1. **Si arriba dice que ya se está grabando**, no arranques otra: dilo en una
|
|
14
|
+
línea y termina.
|
|
15
|
+
2. **Modo.** Sácalo de `$ARGUMENTS` o del contexto de la conversación:
|
|
16
|
+
- remota: Meet, Zoom, Teams, llamada, videollamada, en línea.
|
|
17
|
+
- presencial: en sala, en oficina, en persona.
|
|
18
|
+
|
|
19
|
+
Si no se puede saber, pregunta solo "¿remota o presencial?" y espera.
|
|
20
|
+
3. **Título.** Lo que quede de `$ARGUMENTS` sin la palabra del modo, literal. Si
|
|
21
|
+
no hay, uno corto sacado del contexto; si tampoco hay contexto, arranca sin
|
|
22
|
+
título.
|
|
23
|
+
4. **Por dónde arrancar.** Si arriba `data.bitaLinked` es true, recap escucha
|
|
24
|
+
los eventos de bita: arranca por bita, para que el tiempo también quede
|
|
25
|
+
medido y la minuta llegue al documento de la entrada:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
bita start "<título>" --kind remote-meeting
|
|
29
|
+
bita start "<título>" --kind in-person-meeting
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Si `data.bitaLinked` es false, arranca recap directamente:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
recap start --remote "<título>"
|
|
36
|
+
recap start --in-person "<título>"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Si falla por permisos (`SCREEN_DENIED`, `MICROPHONE_DENIED`), di exactamente qué
|
|
40
|
+
activar en Configuración > Privacidad y seguridad. Si es `RECORDER_TIMEOUT` o
|
|
41
|
+
falta una dependencia, sugiere `recap setup`. Si es `CAPTURE_UNAVAILABLE`
|
|
42
|
+
(Windows o Linux), explica que ahí no se graba: que grabe con otra app y la
|
|
43
|
+
procese con `recap import <archivo>`. Si arrancaste por bita, confirma
|
|
44
|
+
con `recap status` que la grabación empezó.
|
|
45
|
+
|
|
46
|
+
Responde en una línea: modo, título, que ya está grabando y el id de bita si
|
|
47
|
+
arrancó por ahí. Nada más.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Muestra si se está grabando y en qué va la última reunión
|
|
3
|
+
allowed-tools: Bash(recap status:*)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
!`recap status --json`
|
|
7
|
+
|
|
8
|
+
Resume en una o dos líneas:
|
|
9
|
+
|
|
10
|
+
- Si `data.recording` es true: modo, título y tiempo transcurrido.
|
|
11
|
+
- Si no: que no se está grabando, y el estado de la última reunión
|
|
12
|
+
(`data.latest.status`). Si está en `processing`, di qué etapas ya están en
|
|
13
|
+
`done` dentro de `data.latest.stages`. Si alguna está en `failed`, muestra
|
|
14
|
+
su error.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Termina la grabación y deja la reunión documentada
|
|
3
|
+
argument-hint: [--no-wait para no esperar el resultado]
|
|
4
|
+
allowed-tools: Bash(recap stop:*), Bash(recap status:*), Bash(recap wait:*), Bash(recap show:*), Bash(bita stop:*), Bash(bita ls:*), Bash(bita amend:*), Bash(bita docs page move:*), Bash(bita backlog edit:*), Bash(inkwell page move:*), Bash(inkwell backlog edit:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Detén la grabación activa y deja la reunión documentada.
|
|
8
|
+
|
|
9
|
+
Grabación actual:
|
|
10
|
+
|
|
11
|
+
!`recap status --json`
|
|
12
|
+
|
|
13
|
+
Si no hay grabación activa (`NOT_RECORDING`), dilo en una línea y termina.
|
|
14
|
+
|
|
15
|
+
## Si la grabación sigue a un contador de bita
|
|
16
|
+
|
|
17
|
+
Cuando `data.active.bitaEntryId` trae un id, para **el contador**, no recap. Sin
|
|
18
|
+
`--did` y sin escribir la página:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
bita stop <id> --json
|
|
22
|
+
recap wait --bita-entry <id> --json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
El hook de bita detiene la grabación y recap hace lo demás:
|
|
26
|
+
|
|
27
|
+
- transcribe y escribe la minuta;
|
|
28
|
+
- le pone al contador un título real y, si no tenía, su proyecto;
|
|
29
|
+
- crea o completa la página;
|
|
30
|
+
- pasa los pendientes al backlog;
|
|
31
|
+
- deja la minuta en la sección «Reunión» de la entrada.
|
|
32
|
+
|
|
33
|
+
`recap wait` tarda de uno a cinco minutos en una reunión de una hora: córrelo con
|
|
34
|
+
un timeout amplio (15 min). Si después de un minuto `recap status` sigue
|
|
35
|
+
grabando, el evento no llegó: párala con `recap stop`.
|
|
36
|
+
|
|
37
|
+
## Si es una grabación suelta
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
recap stop --json
|
|
41
|
+
recap wait --json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Qué responder
|
|
45
|
+
|
|
46
|
+
Con el resultado de `recap wait` responde:
|
|
47
|
+
|
|
48
|
+
- título y duración;
|
|
49
|
+
- con bita: el proyecto, la página (`wrapup.pageId`) y cuántos pendientes y
|
|
50
|
+
hallazgos quedaron en el backlog;
|
|
51
|
+
- sin bita: los **Acuerdos** y **Pendientes** de la minuta, tal cual.
|
|
52
|
+
|
|
53
|
+
Si una etapa falló, muestra su error y sugiere `recap process <id>`: retoma
|
|
54
|
+
desde la etapa que falló.
|
|
55
|
+
|
|
56
|
+
**Si `wrapup.projectResolved` es false**, pregunta solo «¿De qué proyecto fue?»
|
|
57
|
+
y, con la respuesta:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
bita amend <entryId> --project <X>
|
|
61
|
+
bita docs page move <pageId> --project <X>
|
|
62
|
+
bita backlog edit <CLAVE> --project <X> # una por cada valor de wrapup.backlogKeys
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Si inkwell está instalado, usa `inkwell page move <pageId> --project <X>` e
|
|
66
|
+
`inkwell backlog edit <CLAVE> --project <X>` en lugar de los dos últimos.
|
|
67
|
+
|
|
68
|
+
Con `--no-wait` en `$ARGUMENTS`, solo para y responde en una línea que el
|
|
69
|
+
procesamiento sigue en segundo plano.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Rehace la minuta de una reunión dentro de la sesión, con indicaciones
|
|
3
|
+
argument-hint: [id o "last"] [indicaciones, p. ej. "enfócate en los riesgos"]
|
|
4
|
+
allowed-tools: Bash(recap prompt:*), Bash(recap save-summary:*), Bash(recap show:*), Read, Write
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Rehaz la minuta de una reunión aquí mismo, en lugar del `claude -p` sin
|
|
8
|
+
supervisión del pipeline.
|
|
9
|
+
|
|
10
|
+
1. El primer token de `$ARGUMENTS` es la reunión (id, parte del id o `last`);
|
|
11
|
+
si no parece un id, usa `last`. El resto son indicaciones del usuario.
|
|
12
|
+
2. Obtén las instrucciones completas, con la transcripción y la lista de
|
|
13
|
+
capturas:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
recap prompt <reunión>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
3. Síguelas al pie de la letra. Las rutas de las capturas son relativas al
|
|
20
|
+
directorio que aparece en la primera línea; ábrelas con Read cuando el tema
|
|
21
|
+
lo amerite. Aplica las indicaciones del usuario encima de esas
|
|
22
|
+
instrucciones, sin romper la estructura de secciones.
|
|
23
|
+
4. Escribe la minuta, empezando por `## Resumen`, en un archivo del directorio
|
|
24
|
+
temporal de la sesión, y guárdala:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
recap save-summary <reunión> <archivo>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Responde con los **Acuerdos** y **Pendientes** de la nueva minuta y la ruta de
|
|
31
|
+
`summary.md`.
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { findTool, invokeTool } from '@kikedealba/kit/registry';
|
|
3
|
+
import { parseEnvelope } from '@kikedealba/kit/envelope';
|
|
4
|
+
import { isRecord } from "../core/json.js";
|
|
5
|
+
import { home } from "../core/paths.js";
|
|
6
|
+
import { exec } from "../core/proc.js";
|
|
7
|
+
import { suffix, trimmed } from "../core/text.js";
|
|
8
|
+
import { locateTool, toolEnvironment } from "../core/tools.js";
|
|
9
|
+
import { RecapError, errorMessage } from "../errors.js";
|
|
10
|
+
export function parseResponse(stdout, stderr, status) {
|
|
11
|
+
const envelope = parseEnvelope(stdout);
|
|
12
|
+
const error = isRecord(envelope?.['error']) ? envelope['error'] : undefined;
|
|
13
|
+
const ok = status === 0 && envelope?.['ok'] === true;
|
|
14
|
+
return {
|
|
15
|
+
ok,
|
|
16
|
+
data: envelope?.['data'],
|
|
17
|
+
meta: isRecord(envelope?.['meta']) ? envelope['meta'] : undefined,
|
|
18
|
+
errorCode: typeof error?.['code'] === 'string' ? error['code'] : undefined,
|
|
19
|
+
errorMessage: typeof error?.['message'] === 'string' ? error['message'] : ok ? undefined : suffix(trimmed(stdout + stderr), 300),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export async function callBita(bita, args) {
|
|
23
|
+
const response = await bita.invoke(args);
|
|
24
|
+
if (!response.ok)
|
|
25
|
+
throw new RecapError('BITA_FAILED', `bita ${args.slice(0, 3).join(' ')} failed: ${response.errorMessage ?? 'no reason given'}`);
|
|
26
|
+
return response.data;
|
|
27
|
+
}
|
|
28
|
+
export const QUIET_ENV = { BITA_NO_HOOKS: '1', KIT_NO_EVENTS: '1' };
|
|
29
|
+
export function neutralCwd() {
|
|
30
|
+
return path.parse(home()).root || '/';
|
|
31
|
+
}
|
|
32
|
+
export async function bitaCommand(config) {
|
|
33
|
+
const registered = await findTool('bita').catch(() => null);
|
|
34
|
+
if (registered)
|
|
35
|
+
return registered.bin;
|
|
36
|
+
const located = await locateTool('bita', config);
|
|
37
|
+
return located ? [located] : null;
|
|
38
|
+
}
|
|
39
|
+
export class BitaClient {
|
|
40
|
+
command;
|
|
41
|
+
config;
|
|
42
|
+
target;
|
|
43
|
+
constructor(command, config, target) {
|
|
44
|
+
this.command = command;
|
|
45
|
+
this.config = config;
|
|
46
|
+
this.target = target;
|
|
47
|
+
}
|
|
48
|
+
static async create(config, target) {
|
|
49
|
+
const command = await bitaCommand(config);
|
|
50
|
+
return command ? new BitaClient(command, config, target) : null;
|
|
51
|
+
}
|
|
52
|
+
targetArgs() {
|
|
53
|
+
const args = [];
|
|
54
|
+
if (this.target.databasePath)
|
|
55
|
+
args.push('--db-path', this.target.databasePath);
|
|
56
|
+
if (this.target.docsRoot)
|
|
57
|
+
args.push('--docs-dir', this.target.docsRoot);
|
|
58
|
+
return args;
|
|
59
|
+
}
|
|
60
|
+
async run(args) {
|
|
61
|
+
const [executable, ...prefix] = this.command;
|
|
62
|
+
if (!executable)
|
|
63
|
+
throw new RecapError('DEPENDENCY_MISSING', 'bita not found');
|
|
64
|
+
const env = { ...toolEnvironment(executable, this.config), ...QUIET_ENV };
|
|
65
|
+
return exec(executable, [...prefix, ...args], { env, cwd: neutralCwd() });
|
|
66
|
+
}
|
|
67
|
+
async invoke(args) {
|
|
68
|
+
const result = await this.run([...args, '--json', ...this.targetArgs()]);
|
|
69
|
+
return parseResponse(result.stdout, result.stderr, result.status);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function inkwellArguments(args) {
|
|
73
|
+
if (args[0] === 'backlog')
|
|
74
|
+
return [...args];
|
|
75
|
+
if (args[0] !== 'docs')
|
|
76
|
+
return null;
|
|
77
|
+
if (args[1] === 'propose')
|
|
78
|
+
return ['git', 'propose', ...args.slice(2)];
|
|
79
|
+
return args.slice(1);
|
|
80
|
+
}
|
|
81
|
+
let inkwellCache = null;
|
|
82
|
+
export function resetInkwellCache() {
|
|
83
|
+
inkwellCache = null;
|
|
84
|
+
}
|
|
85
|
+
async function discoverInkwell() {
|
|
86
|
+
if (process.env['RECAP_NO_INKWELL'] === '1')
|
|
87
|
+
return null;
|
|
88
|
+
const tool = await findTool('inkwell', { capability: 'docs.page.write' }).catch(() => null);
|
|
89
|
+
if (!tool)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const { envelope } = await invokeTool(tool, ['migrate', 'status'], { env: { ...process.env, ...QUIET_ENV }, timeoutMs: 20_000 });
|
|
93
|
+
return envelope.ok && envelope.data?.migrated === true ? tool : null;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function findInkwell() {
|
|
100
|
+
inkwellCache ??= discoverInkwell();
|
|
101
|
+
return inkwellCache;
|
|
102
|
+
}
|
|
103
|
+
export class InkwellClient {
|
|
104
|
+
tool;
|
|
105
|
+
constructor(tool) {
|
|
106
|
+
this.tool = tool;
|
|
107
|
+
}
|
|
108
|
+
async invoke(args) {
|
|
109
|
+
try {
|
|
110
|
+
const { envelope } = await invokeTool(this.tool, args, { env: { ...process.env, ...QUIET_ENV }, cwd: neutralCwd(), timeoutMs: 300_000 });
|
|
111
|
+
return {
|
|
112
|
+
ok: envelope.ok,
|
|
113
|
+
data: envelope.data,
|
|
114
|
+
meta: envelope.meta,
|
|
115
|
+
errorCode: envelope.error?.code,
|
|
116
|
+
errorMessage: envelope.error?.message,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return { ok: false, errorCode: 'INKWELL_FAILED', errorMessage: errorMessage(error) };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export class DocsRouter {
|
|
125
|
+
bita;
|
|
126
|
+
inkwell;
|
|
127
|
+
constructor(bita, inkwell) {
|
|
128
|
+
this.bita = bita;
|
|
129
|
+
this.inkwell = inkwell;
|
|
130
|
+
}
|
|
131
|
+
async invoke(args) {
|
|
132
|
+
const mapped = this.inkwell ? inkwellArguments(args) : null;
|
|
133
|
+
if (mapped && this.inkwell)
|
|
134
|
+
return this.inkwell.invoke(mapped);
|
|
135
|
+
if (this.bita)
|
|
136
|
+
return this.bita.invoke(args);
|
|
137
|
+
return { ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: 'bita not found' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function meetingTarget(meeting) {
|
|
141
|
+
return { databasePath: meeting.bitaDatabasePath, docsRoot: meeting.bitaDocsRoot };
|
|
142
|
+
}
|
|
143
|
+
export async function docsClient(config, target) {
|
|
144
|
+
const bita = await BitaClient.create(config, target);
|
|
145
|
+
const inkwell = await findInkwell();
|
|
146
|
+
if (!bita && !inkwell)
|
|
147
|
+
return null;
|
|
148
|
+
return new DocsRouter(bita, inkwell ? new InkwellClient(inkwell) : null);
|
|
149
|
+
}
|
|
150
|
+
export async function requireBita(config, target) {
|
|
151
|
+
const client = await BitaClient.create(config, target);
|
|
152
|
+
if (!client)
|
|
153
|
+
throw new RecapError('DEPENDENCY_MISSING', 'bita not found. Install it with `npm install -g @kikedealba/bita`', { hint: 'npm install -g @kikedealba/bita' });
|
|
154
|
+
return client;
|
|
155
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { isRecord } from "../core/json.js";
|
|
2
|
+
export const MEETING_KINDS = {
|
|
3
|
+
'remote-meeting': 'remote',
|
|
4
|
+
'in-person-meeting': 'in-person',
|
|
5
|
+
};
|
|
6
|
+
export const HOOK_EVENTS = ['start', 'stop', 'cancel', 'amend'];
|
|
7
|
+
function optionalString(value) {
|
|
8
|
+
return typeof value === 'string' ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
export function decodeHookEvent(text) {
|
|
11
|
+
const value = JSON.parse(text);
|
|
12
|
+
if (!isRecord(value) || typeof value['event'] !== 'string' || !isRecord(value['entry']))
|
|
13
|
+
throw new Error('the event has no event name or entry');
|
|
14
|
+
const entry = value['entry'];
|
|
15
|
+
if (typeof entry['id'] !== 'number' || !Number.isInteger(entry['id']) || typeof entry['description'] !== 'string')
|
|
16
|
+
throw new Error('the entry has no id or description');
|
|
17
|
+
const pageIds = Array.isArray(value['pageIds']) ? value['pageIds'].filter((id) => typeof id === 'number' && Number.isInteger(id)) : undefined;
|
|
18
|
+
return {
|
|
19
|
+
event: value['event'],
|
|
20
|
+
entry: {
|
|
21
|
+
id: entry['id'],
|
|
22
|
+
description: entry['description'],
|
|
23
|
+
kind: optionalString(entry['kind']),
|
|
24
|
+
running: typeof entry['running'] === 'boolean' ? entry['running'] : undefined,
|
|
25
|
+
projectName: optionalString(entry['projectName']),
|
|
26
|
+
},
|
|
27
|
+
previousKind: optionalString(value['previousKind']),
|
|
28
|
+
databasePath: optionalString(value['databasePath']),
|
|
29
|
+
docsRoot: optionalString(value['docsRoot']),
|
|
30
|
+
pageIds,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function eventSnapshot(event) {
|
|
34
|
+
return { title: event.entry.description, projectName: event.entry.projectName, kind: event.entry.kind, pageIds: event.pageIds ?? [] };
|
|
35
|
+
}
|
|
36
|
+
export function eventMode(event) {
|
|
37
|
+
return event.entry.kind === undefined ? undefined : MEETING_KINDS[event.entry.kind];
|
|
38
|
+
}
|
|
39
|
+
export function previousMode(event) {
|
|
40
|
+
return event.previousKind === undefined ? undefined : MEETING_KINDS[event.previousKind];
|
|
41
|
+
}
|
|
42
|
+
export function eventTarget(event) {
|
|
43
|
+
return { databasePath: event.databasePath, docsRoot: event.docsRoot };
|
|
44
|
+
}
|
|
45
|
+
export function describeAction(action) {
|
|
46
|
+
switch (action.type) {
|
|
47
|
+
case 'start':
|
|
48
|
+
return `start(${action.mode})`;
|
|
49
|
+
case 'ignore':
|
|
50
|
+
return `ignore(${action.reason})`;
|
|
51
|
+
default:
|
|
52
|
+
return action.type;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function planHook(event, activeEntryId) {
|
|
56
|
+
const isActive = activeEntryId === event.entry.id;
|
|
57
|
+
const mode = eventMode(event);
|
|
58
|
+
switch (event.event) {
|
|
59
|
+
case 'start':
|
|
60
|
+
if (!mode)
|
|
61
|
+
return { type: 'ignore', reason: `entry #${event.entry.id} is not a meeting` };
|
|
62
|
+
return activeEntryId === undefined ? { type: 'start', mode } : { type: 'ignore', reason: 'another meeting is already being recorded' };
|
|
63
|
+
case 'stop':
|
|
64
|
+
return isActive ? { type: 'stopAndProcess' } : { type: 'processIfRecorded' };
|
|
65
|
+
case 'cancel':
|
|
66
|
+
return { type: 'discard' };
|
|
67
|
+
case 'amend': {
|
|
68
|
+
const previous = previousMode(event);
|
|
69
|
+
if (mode && previous === undefined) {
|
|
70
|
+
if (event.entry.running === false)
|
|
71
|
+
return { type: 'ignore', reason: `entry #${event.entry.id} already stopped` };
|
|
72
|
+
return activeEntryId === undefined ? { type: 'start', mode } : { type: 'ignore', reason: 'another meeting is already being recorded' };
|
|
73
|
+
}
|
|
74
|
+
if (mode === undefined && previous !== undefined && isActive)
|
|
75
|
+
return { type: 'stopWithoutProcessing' };
|
|
76
|
+
return { type: 'ignore', reason: 'kind change does not affect the recording' };
|
|
77
|
+
}
|
|
78
|
+
default:
|
|
79
|
+
return { type: 'ignore', reason: `unknown event ${event.event}` };
|
|
80
|
+
}
|
|
81
|
+
}
|