@kikedealba/recap 0.9.0 → 0.10.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/README.md +25 -20
- package/Resources/ask-prompt.md +1 -1
- package/Resources/wrapup-prompt.md +1 -1
- package/commands/recap-proposals.md +4 -2
- package/commands/recap-stop.md +20 -16
- package/dist/bita/client.js +39 -51
- package/dist/bita/hook.js +6 -0
- package/dist/bita/proposals.js +30 -32
- package/dist/bita/wrapup.js +41 -22
- package/dist/capture/capture.js +15 -7
- package/dist/cli/commands/ask.js +4 -6
- package/dist/cli/commands/hook.js +15 -0
- package/dist/cli/commands/proposals.js +4 -6
- package/dist/cli/commands/query.js +11 -3
- package/dist/core/self.js +5 -1
- package/dist/live/context.js +47 -20
- package/dist/live/worker.js +2 -6
- package/dist/pipeline/pipeline.js +11 -7
- package/dist/setup/integration.js +12 -8
- package/package.json +3 -3
- package/skills/recap/SKILL.md +42 -20
package/README.md
CHANGED
|
@@ -51,9 +51,11 @@ recap import llamada.mov --remote # video con dos pistas
|
|
|
51
51
|
|
|
52
52
|
Los archivos en disco, los comandos, las opciones y el sobre `--json` son los
|
|
53
53
|
mismos que los del `recap` en Swift, así que las dos versiones leen las mismas
|
|
54
|
-
reuniones.
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
reuniones. bita solo lleva el tiempo: las páginas, las propuestas, el backlog
|
|
55
|
+
y la nota de cada entrada viven en [inkwell](https://github.com/KikeDeAlba/inkwell),
|
|
56
|
+
y el volcado del tiempo a Jira lo hace [tally](https://github.com/KikeDeAlba/tally).
|
|
57
|
+
Sin inkwell la reunión se procesa igual: las etapas `proposals` y `wrapup` se
|
|
58
|
+
saltan con un aviso y la minuta se queda en la carpeta de la reunión.
|
|
57
59
|
|
|
58
60
|
Para trabajar en el paquete: `pnpm install`, `pnpm test`, `pnpm typecheck` y
|
|
59
61
|
`node src/bin/recap.ts <comando>`.
|
|
@@ -147,7 +149,7 @@ The repository is also a Claude Code plugin marketplace:
|
|
|
147
149
|
| `/recap-status` | Shows whether a recording is running and how far processing got |
|
|
148
150
|
| `/recap-list [meeting]` | Lists meetings or shows one meeting's minutes |
|
|
149
151
|
| `/recap-summarize [meeting] [instructions]` | Rewrites the minutes inside the session, following extra instructions, and stores them with `recap save-summary` |
|
|
150
|
-
| `/recap-ask [question]` | Answers the last question of the meeting being recorded, or the one given, from the
|
|
152
|
+
| `/recap-ask [question]` | Answers the last question of the meeting being recorded, or the one given, from the inkwell pages and the project repositories |
|
|
151
153
|
| `/recap-proposals [meeting]` | Reviews the documentation changes proposed by a meeting and accepts, edits or rejects them |
|
|
152
154
|
|
|
153
155
|
The `recap` skill lets Claude answer questions such as "¿qué acordamos en la reunión de ayer?" from the stored minutes and transcripts. The commands call `recap`, so it must be on the `PATH` of the shell Claude Code runs.
|
|
@@ -160,7 +162,7 @@ While a meeting is being recorded, recap also transcribes it live:
|
|
|
160
162
|
- A detached `recap live-worker` transcribes each chunk with `whisper-cli` (same model and VAD as the pipeline, 4 threads, the previous text as prompt), drops hallucinations and microphone echo, and appends `{startMs, endMs, channel, text}` lines to `live/transcript.jsonl`, with offsets from the start of the recording. It exits once the recording stops and the queue is empty.
|
|
161
163
|
- The transcript after `stop` is still the source of truth; the live one is for answering during the meeting.
|
|
162
164
|
|
|
163
|
-
`recap ask` answers a question with Claude Code (headless, streaming), reading only the
|
|
165
|
+
`recap ask` answers a question with Claude Code (headless, streaming), reading only the inkwell pages of the project (`inkwell page ls --project`) and the repositories registered for it in bita (`bita project repo ls`). Without inkwell it answers from the repositories alone:
|
|
164
166
|
|
|
165
167
|
```sh
|
|
166
168
|
recap ask --active # the last question in the live transcript
|
|
@@ -191,9 +193,9 @@ With `live.autoAsk` on (the default), the live worker also looks for questions o
|
|
|
191
193
|
|
|
192
194
|
### Proposed documentation changes
|
|
193
195
|
|
|
194
|
-
For meetings linked to a bita entry, the `proposals` stage (before `wrapup`) asks Claude Code (`Resources/proposals-prompt.md`) for the explicit, firm changes said about existing pages: the project pages and the pages linked to the entry, never the meeting's own page. Ideas, doubts and statements corrected later are left out, and the text follows
|
|
196
|
+
For meetings linked to a bita entry, when inkwell is installed, the `proposals` stage (before `wrapup`) asks Claude Code (`Resources/proposals-prompt.md`) for the explicit, firm changes said about existing pages: the project pages and the pages linked to the entry in inkwell (`inkwell page ls --entry`), never the meeting's own page. Ideas, doubts and statements corrected later are left out, and the text follows the documentation writing rule: nothing that reveals the conversation.
|
|
195
197
|
|
|
196
|
-
Each change becomes a commit on the docs branch `proposal/meeting-<entry>` through `
|
|
198
|
+
Each change becomes a commit on the docs branch `proposal/meeting-<entry>` through `inkwell git propose`, and is listed in `proposals.json` (its markdown in `proposals/<n>.md`). Nothing reaches `main`, or Confluence, until it is accepted:
|
|
197
199
|
|
|
198
200
|
```sh
|
|
199
201
|
recap proposals ls <meeting> --json # or --bita-entry <id>
|
|
@@ -202,41 +204,44 @@ recap proposals accept <meeting> <n> [--md edited.md]
|
|
|
202
204
|
recap proposals reject <meeting> <n>
|
|
203
205
|
```
|
|
204
206
|
|
|
205
|
-
`accept` runs `
|
|
207
|
+
`accept` runs `inkwell branch apply` (and needs inkwell); with `--md` it first proposes the edited text again. If the page changed since the proposal, the merge conflicts and the proposal turns `stale` (the command still succeeds). When no proposal is pending, the branch is dropped. A failure in this stage is recorded in `stages.proposals` and never stops the wrap-up; without inkwell the stage is `skipped` with the reason in `stages.proposals.reason`. Turn it off with `recap config set live.proposals false`.
|
|
206
208
|
|
|
207
209
|
## bita integration
|
|
208
210
|
|
|
209
|
-
With [bita](https://github.com/KikeDeAlba/bita-cli)
|
|
211
|
+
With [bita](https://github.com/KikeDeAlba/bita-cli), a meeting timer records the meeting while it runs:
|
|
210
212
|
|
|
211
213
|
```sh
|
|
212
214
|
bita start "Planeación sprint 42" --kind remote-meeting # recap starts recording the screen, system audio and mic
|
|
213
215
|
bita start "1:1 con Ana" --kind in-person-meeting # recap records the mic only
|
|
214
|
-
bita stop # recap stops, processes, and writes the minutes into the entry
|
|
216
|
+
bita stop # recap stops, processes, and writes the minutes into the entry note in inkwell
|
|
215
217
|
```
|
|
216
218
|
|
|
217
|
-
`recap setup`
|
|
219
|
+
`recap setup` subscribes `recap bita-hook` to bita's `start`, `stop`, `cancel` and `amend` events through the kit registry. It works the same whether the timer is started from the terminal, from Claude Code or from bita-desktop.
|
|
218
220
|
|
|
219
221
|
| bita event | recap |
|
|
220
222
|
|---|---|
|
|
221
223
|
| `start` of a meeting kind | starts recording in the matching mode, linked to the entry |
|
|
222
|
-
| `stop` | stops, processes in the background and wraps the meeting up
|
|
224
|
+
| `stop` | stops, processes in the background and wraps the meeting up (see below) |
|
|
223
225
|
| `cancel` | discards the recording |
|
|
224
226
|
| `amend --kind <meeting kind>` on a running entry | starts recording |
|
|
225
227
|
| `amend --kind none` while recording | stops without processing; the recording is kept |
|
|
228
|
+
| `amend` of the title or project only | updates the meeting's title and entry data; the recording is not touched |
|
|
226
229
|
|
|
227
230
|
### Wrap-up
|
|
228
231
|
|
|
229
|
-
After the summary, the `wrapup` stage asks Claude Code (headless, `Resources/wrapup-prompt.md`) for a title, a project, a documentation page and backlog items, and applies them through the
|
|
232
|
+
After the summary, the `wrapup` stage asks Claude Code (headless, `Resources/wrapup-prompt.md`) for a title, a project, a documentation page and backlog items, and applies them: title and project through `bita amend`, everything else through inkwell (found in the kit registry by its capabilities):
|
|
230
233
|
|
|
231
234
|
- the timer gets the meeting's real topic as its title when the current one is generic ("Reunión presencial", "Junta", …);
|
|
232
|
-
- if the timer has no project, it gets one only when the conversation makes it clear and the name exists in `bita projects`; otherwise `wrapup.projectResolved` is false and `/
|
|
233
|
-
- a page is created with `
|
|
234
|
-
- action items and
|
|
235
|
-
- the minutes
|
|
235
|
+
- if the timer has no project, it gets one only when the conversation makes it clear and the name exists in `bita projects`; otherwise `wrapup.projectResolved` is false and `/recap-stop` asks;
|
|
236
|
+
- a page is created with `inkwell page new --from-entry` in that project and written as formal documentation (no pending sections); if the entry already has a page in inkwell (`inkwell page ls --entry`), a `## Reunión <date>` section is added to it instead;
|
|
237
|
+
- action items and findings become `inkwell backlog add` items on that page;
|
|
238
|
+
- the minutes land in the `Reunión` section of the entry note (`inkwell note save <entry> --section Reunión --md …`, capability `docs.entry-notes`).
|
|
239
|
+
|
|
240
|
+
Without inkwell the stage is `skipped` (reason and hint `npm i -g @kikedealba/inkwell && inkwell setup` in `stages.wrapup`) and the meeting is still `processed`; the minutes stay in `summary.md`. With an inkwell lacking `docs.entry-notes` the page and backlog are written and `process.log` notes that the minutes were not saved. Run `recap process <id> --from proposals` after installing it. Time goes to Jira later through tally, never from recap.
|
|
236
241
|
|
|
237
242
|
`recap wait --bita-entry <id>` blocks until all of that is done and prints the result (`data.wrapup` with `--json`).
|
|
238
243
|
|
|
239
|
-
The hook output goes to `hooks.log` beside the bita database. Minutes regenerated with `/recap-summarize` or `recap save-summary` are
|
|
244
|
+
The hook output goes to `hooks.log` beside the bita database. Minutes regenerated with `/recap-summarize` or `recap save-summary` are saved in the entry note again when inkwell is installed.
|
|
240
245
|
|
|
241
246
|
## Pipeline
|
|
242
247
|
|
|
@@ -246,8 +251,8 @@ The hook output goes to `hooks.log` beside the bita database. Minutes regenerate
|
|
|
246
251
|
| `transcribe` | per channel, then merged | mic | `transcript.json`, `transcript.md` |
|
|
247
252
|
| `frames` | scene changes, at least 20 s apart, at most 40 | skipped | `frames/hh-mm-ss.jpg`, `frames.json` |
|
|
248
253
|
| `summarize` | `claude -p` with transcript and frames | `claude -p` with transcript | `summary.md` |
|
|
249
|
-
| `proposals` | only when linked to a bita entry and `live.proposals` is on | same | `proposals.json`, `proposals/<n>.md` and the docs branch `proposal/meeting-<entry>` |
|
|
250
|
-
| `wrapup` | only when linked to a bita entry | same | entry title and project,
|
|
254
|
+
| `proposals` | only when linked to a bita entry and `live.proposals` is on; skipped without inkwell | same | `proposals.json`, `proposals/<n>.md` and the docs branch `proposal/meeting-<entry>` |
|
|
255
|
+
| `wrapup` | only when linked to a bita entry; skipped without inkwell | same | entry title and project (bita), an inkwell page, backlog items and the `Reunión` section of the entry note |
|
|
251
256
|
|
|
252
257
|
- Transcription runs locally with `whisper-cli`, `large-v3-turbo` and Silero VAD. Known whisper hallucinations on silence are dropped.
|
|
253
258
|
- In remote meetings the microphone is labelled **Sala** and the call audio **Remotos**. When the microphone picks up the speakers, segments that repeat the call audio within a few seconds are removed as echo; headphones avoid the problem entirely.
|
package/Resources/ask-prompt.md
CHANGED
|
@@ -10,7 +10,7 @@ Estás acompañando en vivo una reunión. Quien te consulta necesita una respues
|
|
|
10
10
|
|
|
11
11
|
Responde solo con lo que encuentres en estas fuentes. Puedes leerlas con Read, Grep y Glob. Para el historial de un repositorio usa solo `git -C <ruta del repo> log`, `git -C <ruta del repo> show` o `git -C <ruta del repo> diff`, con la ruta exacta de la lista: un comando por llamada, sin `cd`, sin `&&` y sin tuberías; cualquier otra forma se rechaza.
|
|
12
12
|
|
|
13
|
-
- Raíz de la documentación
|
|
13
|
+
- Raíz de la documentación (inkwell): {{docsRoot}}
|
|
14
14
|
- Páginas del proyecto (ruta relativa a la raíz):
|
|
15
15
|
{{pages}}
|
|
16
16
|
- Repositorios del proyecto:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Vas a cerrar
|
|
1
|
+
Vas a cerrar 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
2
|
|
|
3
3
|
- Título actual del contador: «{{currentTitle}}»
|
|
4
4
|
- Proyecto actual del contador: {{currentProject}}
|
|
@@ -5,8 +5,10 @@ allowed-tools: Bash(recap proposals:*), Bash(recap show:*), Read, Write
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
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
|
|
9
|
-
|
|
8
|
+
páginas existentes de inkwell como propuestas en la rama
|
|
9
|
+
`proposal/meeting-<entrada>` de sus docs. Nada llega a `main` sin aceptarse.
|
|
10
|
+
Aceptar o rechazar necesita inkwell instalado (`npm i -g @kikedealba/inkwell &&
|
|
11
|
+
inkwell setup`).
|
|
10
12
|
|
|
11
13
|
1. La reunión es `$ARGUMENTS`, o `last` si viene vacío. Lista sus propuestas:
|
|
12
14
|
|
package/commands/recap-stop.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Termina la grabación y deja la reunión documentada
|
|
3
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(
|
|
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(inkwell page move:*), Bash(inkwell backlog edit:*)
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Detén la grabación activa y deja la reunión documentada.
|
|
@@ -12,23 +12,28 @@ Grabación actual:
|
|
|
12
12
|
|
|
13
13
|
Si no hay grabación activa (`NOT_RECORDING`), dilo en una línea y termina.
|
|
14
14
|
|
|
15
|
-
## Si la grabación sigue a un
|
|
15
|
+
## Si la grabación sigue a un cronómetro de bita
|
|
16
16
|
|
|
17
|
-
Cuando `data.active.bitaEntryId` trae un id, para **el
|
|
18
|
-
|
|
17
|
+
Cuando `data.active.bitaEntryId` trae un id, para **el cronómetro**, no recap,
|
|
18
|
+
sin escribir la página:
|
|
19
19
|
|
|
20
20
|
```
|
|
21
21
|
bita stop <id> --json
|
|
22
22
|
recap wait --bita-entry <id> --json
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
El
|
|
25
|
+
El evento de bita detiene la grabación y recap hace lo demás:
|
|
26
26
|
|
|
27
27
|
- transcribe y escribe la minuta;
|
|
28
|
-
- le pone al
|
|
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.
|
|
28
|
+
- le pone al cronómetro un título real y, si no tenía, su proyecto (`bita amend`);
|
|
29
|
+
- crea o completa la página en inkwell;
|
|
30
|
+
- pasa los pendientes y hallazgos al backlog de inkwell;
|
|
31
|
+
- deja la minuta en la sección «Reunión» de la nota de la entrada (`inkwell note save`).
|
|
32
|
+
|
|
33
|
+
Sin inkwell, las etapas `proposals` y `wrapup` quedan en `skipped` con su
|
|
34
|
+
motivo (`stages.wrapup.reason`) y la minuta se queda en la carpeta de la
|
|
35
|
+
reunión; sugiere `npm i -g @kikedealba/inkwell && inkwell setup` y después
|
|
36
|
+
`recap process <id> --from proposals`.
|
|
32
37
|
|
|
33
38
|
`recap wait` tarda de uno a cinco minutos en una reunión de una hora: córrelo con
|
|
34
39
|
un timeout amplio (15 min). Si después de un minuto `recap status` sigue
|
|
@@ -46,9 +51,9 @@ recap wait --json
|
|
|
46
51
|
Con el resultado de `recap wait` responde:
|
|
47
52
|
|
|
48
53
|
- título y duración;
|
|
49
|
-
- con bita: el proyecto, la página (`wrapup.pageId`) y cuántos
|
|
50
|
-
hallazgos quedaron en el backlog;
|
|
51
|
-
- sin bita: los **Acuerdos** y **Pendientes** de la minuta, tal cual.
|
|
54
|
+
- con bita e inkwell: el proyecto, la página (`wrapup.pageId`) y cuántos
|
|
55
|
+
pendientes y hallazgos quedaron en el backlog;
|
|
56
|
+
- sin bita o sin inkwell: los **Acuerdos** y **Pendientes** de la minuta, tal cual.
|
|
52
57
|
|
|
53
58
|
Si una etapa falló, muestra su error y sugiere `recap process <id>`: retoma
|
|
54
59
|
desde la etapa que falló.
|
|
@@ -58,12 +63,11 @@ y, con la respuesta:
|
|
|
58
63
|
|
|
59
64
|
```
|
|
60
65
|
bita amend <entryId> --project <X>
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
inkwell page move <pageId> --project <X>
|
|
67
|
+
inkwell backlog edit <CLAVE> --project <X> # una por cada valor de wrapup.backlogKeys
|
|
63
68
|
```
|
|
64
69
|
|
|
65
|
-
|
|
66
|
-
`inkwell backlog edit <CLAVE> --project <X>` en lugar de los dos últimos.
|
|
70
|
+
El tiempo de la reunión se vuelca a Jira después con tally, no desde aquí.
|
|
67
71
|
|
|
68
72
|
Con `--no-wait` en `$ARGUMENTS`, solo para y responde en una línea que el
|
|
69
73
|
procesamiento sigue en segundo plano.
|
package/dist/bita/client.js
CHANGED
|
@@ -53,8 +53,6 @@ export class BitaClient {
|
|
|
53
53
|
const args = [];
|
|
54
54
|
if (this.target.databasePath)
|
|
55
55
|
args.push('--db-path', this.target.databasePath);
|
|
56
|
-
if (this.target.docsRoot)
|
|
57
|
-
args.push('--docs-dir', this.target.docsRoot);
|
|
58
56
|
return args;
|
|
59
57
|
}
|
|
60
58
|
async run(args) {
|
|
@@ -69,36 +67,49 @@ export class BitaClient {
|
|
|
69
67
|
return parseResponse(result.stdout, result.stderr, result.status);
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
|
-
export
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
let inkwellCache = null;
|
|
82
|
-
export function resetInkwellCache() {
|
|
83
|
-
inkwellCache = null;
|
|
84
|
-
}
|
|
85
|
-
async function discoverInkwell() {
|
|
70
|
+
export const INKWELL_HINT = 'npm i -g @kikedealba/inkwell && inkwell setup';
|
|
71
|
+
export const INKWELL_CAPABILITIES = {
|
|
72
|
+
pages: ['docs.page.read'],
|
|
73
|
+
wrapup: ['docs.page.read', 'docs.page.write', 'docs.backlog'],
|
|
74
|
+
proposals: ['docs.page.read', 'docs.propose'],
|
|
75
|
+
notes: ['docs.entry-notes'],
|
|
76
|
+
};
|
|
77
|
+
export async function lookupInkwell(capabilities = []) {
|
|
86
78
|
if (process.env['RECAP_NO_INKWELL'] === '1')
|
|
87
|
-
return null;
|
|
88
|
-
const tool = await findTool('inkwell'
|
|
79
|
+
return { client: null, reason: 'inkwell is turned off (RECAP_NO_INKWELL=1)' };
|
|
80
|
+
const tool = await findTool('inkwell').catch(() => null);
|
|
89
81
|
if (!tool)
|
|
90
|
-
return null;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
82
|
+
return { client: null, reason: 'inkwell is not installed' };
|
|
83
|
+
const missing = capabilities.filter((capability) => !tool.manifest.capabilities.includes(capability));
|
|
84
|
+
if (missing.length > 0)
|
|
85
|
+
return { client: null, reason: `inkwell ${tool.manifest.version} lacks ${missing.join(', ')}` };
|
|
86
|
+
return { client: new InkwellClient(tool) };
|
|
87
|
+
}
|
|
88
|
+
export async function findInkwell(capabilities = []) {
|
|
89
|
+
return (await lookupInkwell(capabilities)).client;
|
|
90
|
+
}
|
|
91
|
+
export async function requireInkwell(capabilities, purpose) {
|
|
92
|
+
const found = await lookupInkwell(capabilities);
|
|
93
|
+
if (found.client)
|
|
94
|
+
return found.client;
|
|
95
|
+
throw new RecapError('DEPENDENCY_MISSING', `${purpose} needs inkwell: ${found.reason}`, { hint: INKWELL_HINT });
|
|
96
|
+
}
|
|
97
|
+
export class StageSkipped extends RecapError {
|
|
98
|
+
constructor(message, hint) {
|
|
99
|
+
super('STAGE_SKIPPED', message, hint === undefined ? {} : { hint });
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
|
-
export function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
export async function inkwellForStage(capabilities) {
|
|
103
|
+
const found = await lookupInkwell(capabilities);
|
|
104
|
+
if (found.client)
|
|
105
|
+
return found.client;
|
|
106
|
+
throw new StageSkipped(found.reason, INKWELL_HINT);
|
|
107
|
+
}
|
|
108
|
+
export async function callInkwell(inkwell, args) {
|
|
109
|
+
const response = await inkwell.invoke(args);
|
|
110
|
+
if (!response.ok)
|
|
111
|
+
throw new RecapError('INKWELL_FAILED', `inkwell ${args.slice(0, 2).join(' ')} failed: ${response.errorMessage ?? 'no reason given'}`);
|
|
112
|
+
return response.data;
|
|
102
113
|
}
|
|
103
114
|
export class InkwellClient {
|
|
104
115
|
tool;
|
|
@@ -121,32 +132,9 @@ export class InkwellClient {
|
|
|
121
132
|
}
|
|
122
133
|
}
|
|
123
134
|
}
|
|
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
135
|
export function meetingTarget(meeting) {
|
|
141
136
|
return { databasePath: meeting.bitaDatabasePath, docsRoot: meeting.bitaDocsRoot };
|
|
142
137
|
}
|
|
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
138
|
export async function requireBita(config, target) {
|
|
151
139
|
const client = await BitaClient.create(config, target);
|
|
152
140
|
if (!client)
|
package/dist/bita/hook.js
CHANGED
|
@@ -14,6 +14,8 @@ export function decodeHookEvent(text) {
|
|
|
14
14
|
const entry = value['entry'];
|
|
15
15
|
if (typeof entry['id'] !== 'number' || !Number.isInteger(entry['id']) || typeof entry['description'] !== 'string')
|
|
16
16
|
throw new Error('the entry has no id or description');
|
|
17
|
+
const reportsEveryChange = 'previousTitle' in value || 'previousProjectId' in value;
|
|
18
|
+
const kindChanged = !reportsEveryChange || (optionalString(value['previousKind']) ?? null) !== (optionalString(entry['kind']) ?? null);
|
|
17
19
|
const pageIds = Array.isArray(value['pageIds']) ? value['pageIds'].filter((id) => typeof id === 'number' && Number.isInteger(id)) : undefined;
|
|
18
20
|
return {
|
|
19
21
|
event: value['event'],
|
|
@@ -25,9 +27,11 @@ export function decodeHookEvent(text) {
|
|
|
25
27
|
projectName: optionalString(entry['projectName']),
|
|
26
28
|
},
|
|
27
29
|
previousKind: optionalString(value['previousKind']),
|
|
30
|
+
previousTitle: optionalString(value['previousTitle']),
|
|
28
31
|
databasePath: optionalString(value['databasePath']),
|
|
29
32
|
docsRoot: optionalString(value['docsRoot']),
|
|
30
33
|
pageIds,
|
|
34
|
+
kindChanged,
|
|
31
35
|
};
|
|
32
36
|
}
|
|
33
37
|
export function eventSnapshot(event) {
|
|
@@ -65,6 +69,8 @@ export function planHook(event, activeEntryId) {
|
|
|
65
69
|
case 'cancel':
|
|
66
70
|
return { type: 'discard' };
|
|
67
71
|
case 'amend': {
|
|
72
|
+
if (!event.kindChanged)
|
|
73
|
+
return { type: 'refresh' };
|
|
68
74
|
const previous = previousMode(event);
|
|
69
75
|
if (mode && previous === undefined) {
|
|
70
76
|
if (event.entry.running === false)
|
package/dist/bita/proposals.js
CHANGED
|
@@ -7,10 +7,10 @@ import { withDirectoryLockAsync } from "../core/lock.js";
|
|
|
7
7
|
import { home } from "../core/paths.js";
|
|
8
8
|
import { fold, prefix, replaceAll, trimmed } from "../core/text.js";
|
|
9
9
|
import { RecapError, errorMessage } from "../errors.js";
|
|
10
|
-
import { loadProjectContext, meetingProject } from "../live/context.js";
|
|
10
|
+
import { entryPages, loadProjectContext, meetingProject } from "../live/context.js";
|
|
11
11
|
import { resultText, runClaude } from "../pipeline/claude.js";
|
|
12
12
|
import { loadResource } from "../pipeline/resources.js";
|
|
13
|
-
import {
|
|
13
|
+
import { callInkwell } from "./client.js";
|
|
14
14
|
import { meetingDay } from "./wrapup.js";
|
|
15
15
|
export const REVEALING_PHRASES = [
|
|
16
16
|
'se acordo',
|
|
@@ -190,45 +190,42 @@ export function saveProposals(file, dir) {
|
|
|
190
190
|
discarded: file.discarded.map((item) => ({ index: item.index, pageId: item.pageId, reason: item.reason })),
|
|
191
191
|
}));
|
|
192
192
|
}
|
|
193
|
-
export async function propose(
|
|
194
|
-
const args = ['
|
|
193
|
+
export async function propose(inkwell, branch, pageId, section, file, reason, entryId) {
|
|
194
|
+
const args = ['git', 'propose', '--branch', branch, String(pageId), '--md', file];
|
|
195
195
|
if (section !== null)
|
|
196
196
|
args.push('--section', section);
|
|
197
197
|
args.push('--reason', reason, '--source', `meeting:${entryId}`);
|
|
198
|
-
const data = await
|
|
198
|
+
const data = await callInkwell(inkwell, args);
|
|
199
199
|
const sha = isRecord(data) && typeof data['sha'] === 'string' ? data['sha'] : '';
|
|
200
200
|
if (sha.length === 0)
|
|
201
|
-
throw new RecapError('
|
|
201
|
+
throw new RecapError('INKWELL_FAILED', 'inkwell git propose did not return the commit sha');
|
|
202
202
|
return sha;
|
|
203
203
|
}
|
|
204
|
-
export function ownPageId(meeting) {
|
|
205
|
-
return meeting.wrapup?.pageId ??
|
|
204
|
+
export function ownPageId(meeting, linked) {
|
|
205
|
+
return meeting.wrapup?.pageId ?? linked[0]?.pageId;
|
|
206
206
|
}
|
|
207
207
|
export class ProposalGenerator {
|
|
208
208
|
config;
|
|
209
|
-
|
|
209
|
+
inkwell;
|
|
210
210
|
log;
|
|
211
211
|
claude;
|
|
212
|
-
constructor(config,
|
|
212
|
+
constructor(config, inkwell, log, claude) {
|
|
213
213
|
this.config = config;
|
|
214
|
-
this.
|
|
214
|
+
this.inkwell = inkwell;
|
|
215
215
|
this.log = log;
|
|
216
216
|
this.claude =
|
|
217
217
|
claude ??
|
|
218
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
219
|
}
|
|
220
|
-
|
|
221
|
-
const own = ownPageId(meeting);
|
|
220
|
+
candidates(meeting, context, linked) {
|
|
221
|
+
const own = ownPageId(meeting, linked);
|
|
222
222
|
const pages = [...context.pages];
|
|
223
223
|
const known = new Set(pages.map((page) => page.pageId));
|
|
224
|
-
for (const
|
|
225
|
-
if (known.has(pageId) || pageId === own)
|
|
224
|
+
for (const page of linked) {
|
|
225
|
+
if (known.has(page.pageId) || page.pageId === own)
|
|
226
226
|
continue;
|
|
227
|
-
|
|
228
|
-
|
|
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);
|
|
227
|
+
pages.push({ ...page, depth: 0 });
|
|
228
|
+
known.add(page.pageId);
|
|
232
229
|
}
|
|
233
230
|
return pages.filter((page) => page.pageId !== own);
|
|
234
231
|
}
|
|
@@ -244,10 +241,11 @@ export class ProposalGenerator {
|
|
|
244
241
|
return existing;
|
|
245
242
|
}
|
|
246
243
|
if (existing.proposals.length > 0)
|
|
247
|
-
await this.
|
|
244
|
+
await this.inkwell.invoke(['branch', 'drop', branch]).catch(() => null);
|
|
248
245
|
}
|
|
249
|
-
const
|
|
250
|
-
const
|
|
246
|
+
const linked = await entryPages(this.inkwell, entryId);
|
|
247
|
+
const context = await loadProjectContext(await meetingProject(meeting, this.inkwell, linked), { inkwell: this.inkwell, bita: null });
|
|
248
|
+
const pages = this.candidates(meeting, context, linked);
|
|
251
249
|
const file = { entryId, branch, generatedAt: isoNow(), branchDropped: false, proposals: [], discarded: [] };
|
|
252
250
|
if (pages.length === 0 || context.docsRoot === null) {
|
|
253
251
|
this.log('proposals: no candidate pages');
|
|
@@ -276,7 +274,7 @@ export class ProposalGenerator {
|
|
|
276
274
|
const markdownFile = proposalStore.markdownFile(dir, n);
|
|
277
275
|
writeAtomic(markdownFile, `${draft.markdown}\n`);
|
|
278
276
|
try {
|
|
279
|
-
const sha = await propose(this.
|
|
277
|
+
const sha = await propose(this.inkwell, branch, draft.pageId, draft.section, markdownFile, draft.title, entryId);
|
|
280
278
|
file.proposals.push({
|
|
281
279
|
n,
|
|
282
280
|
pageId: draft.pageId,
|
|
@@ -323,10 +321,10 @@ export class ProposalGenerator {
|
|
|
323
321
|
}
|
|
324
322
|
export class ProposalReview {
|
|
325
323
|
dir;
|
|
326
|
-
|
|
327
|
-
constructor(dir,
|
|
324
|
+
inkwell;
|
|
325
|
+
constructor(dir, inkwell) {
|
|
328
326
|
this.dir = dir;
|
|
329
|
-
this.
|
|
327
|
+
this.inkwell = inkwell;
|
|
330
328
|
}
|
|
331
329
|
list() {
|
|
332
330
|
return loadProposals(this.dir)?.proposals ?? [];
|
|
@@ -341,7 +339,7 @@ export class ProposalReview {
|
|
|
341
339
|
return found;
|
|
342
340
|
}
|
|
343
341
|
async diff(proposal) {
|
|
344
|
-
const response = await this.
|
|
342
|
+
const response = await this.inkwell.invoke(['branch', 'diff', proposal.branch, '--commit', proposal.sha]).catch(() => null);
|
|
345
343
|
return response?.ok ? response.data : null;
|
|
346
344
|
}
|
|
347
345
|
load() {
|
|
@@ -361,7 +359,7 @@ export class ProposalReview {
|
|
|
361
359
|
if (index !== -1)
|
|
362
360
|
file.proposals[index] = proposal;
|
|
363
361
|
if (!file.branchDropped && !file.proposals.some((item) => item.status === 'pending')) {
|
|
364
|
-
const response = await this.
|
|
362
|
+
const response = await this.inkwell.invoke(['branch', 'drop', file.branch]).catch(() => null);
|
|
365
363
|
file.branchDropped = response?.ok === true;
|
|
366
364
|
}
|
|
367
365
|
saveProposals(file, this.dir);
|
|
@@ -384,10 +382,10 @@ export class ProposalReview {
|
|
|
384
382
|
writeAtomic(target, text);
|
|
385
383
|
}
|
|
386
384
|
if (editedMarkdown !== undefined || file.branchDropped) {
|
|
387
|
-
proposal.sha = await propose(this.
|
|
385
|
+
proposal.sha = await propose(this.inkwell, proposal.branch, proposal.pageId, proposal.section, target, proposal.title, file.entryId);
|
|
388
386
|
file.branchDropped = false;
|
|
389
387
|
}
|
|
390
|
-
const response = await this.
|
|
388
|
+
const response = await this.inkwell.invoke(['branch', 'apply', proposal.branch, '--commit', proposal.sha]);
|
|
391
389
|
if (response.ok) {
|
|
392
390
|
proposal.status = 'accepted';
|
|
393
391
|
const data = isRecord(response.data) ? response.data : {};
|
|
@@ -397,7 +395,7 @@ export class ProposalReview {
|
|
|
397
395
|
proposal.status = 'stale';
|
|
398
396
|
}
|
|
399
397
|
else {
|
|
400
|
-
throw new RecapError('
|
|
398
|
+
throw new RecapError('INKWELL_FAILED', `inkwell branch apply failed: ${response.errorMessage ?? 'no reason given'}`);
|
|
401
399
|
}
|
|
402
400
|
proposal.updatedAt = isoNow();
|
|
403
401
|
await this.store(proposal, file);
|
package/dist/bita/wrapup.js
CHANGED
|
@@ -3,11 +3,12 @@ import { dayString, parseDate } from "../core/dates.js";
|
|
|
3
3
|
import { readText, writeAtomic } from "../core/fsutil.js";
|
|
4
4
|
import { extractBetween, isRecord, swiftDefault } from "../core/json.js";
|
|
5
5
|
import { durationSeconds, emptyWrapup, updateMeeting } from "../core/meeting.js";
|
|
6
|
-
import { fold, formatDuration, prefix, replaceAll,
|
|
6
|
+
import { fold, formatDuration, prefix, replaceAll, trimmed } from "../core/text.js";
|
|
7
7
|
import { RecapError } from "../errors.js";
|
|
8
8
|
import { resultText, runSummaryClaude } from "../pipeline/claude.js";
|
|
9
9
|
import { loadResource } from "../pipeline/resources.js";
|
|
10
|
-
import { callBita,
|
|
10
|
+
import { INKWELL_CAPABILITIES, INKWELL_HINT, StageSkipped, callBita, callInkwell, lookupInkwell, meetingTarget, requireBita } from "./client.js";
|
|
11
|
+
import { entryPages } from "../live/context.js";
|
|
11
12
|
export const FORBIDDEN_SECTIONS = ['pendiente', 'proximos pasos', 'hallazgo', 'preguntas abiertas', 'lo que falta'];
|
|
12
13
|
export function stripForbiddenSections(markdown) {
|
|
13
14
|
const kept = [];
|
|
@@ -127,20 +128,30 @@ export function noteBody(summary, meeting, dir) {
|
|
|
127
128
|
lines.push('', `Archivos de la reunión: \`${dir}\` (\`summary.md\`, \`transcript.md\`${meeting.mode === 'remote' ? ', `frames/`' : ''}).`);
|
|
128
129
|
return `${lines.join('\n')}\n`;
|
|
129
130
|
}
|
|
130
|
-
export async function saveMinutes(meeting, dir,
|
|
131
|
+
export async function saveMinutes(meeting, dir, notes, log) {
|
|
131
132
|
const entryId = meeting.bitaEntryId;
|
|
132
133
|
if (entryId === undefined)
|
|
133
|
-
return;
|
|
134
|
-
const bita = client ?? (await requireBita(config, meetingTarget(meeting)));
|
|
134
|
+
return { saved: false, reason: 'the meeting has no bita entry' };
|
|
135
135
|
const summary = readText(path.join(dir, 'summary.md'));
|
|
136
136
|
if (summary === null)
|
|
137
|
-
throw new RecapError('NO_SUMMARY', `"${meeting.title}" has no summary to
|
|
138
|
-
const note = path.join(dir, '
|
|
137
|
+
throw new RecapError('NO_SUMMARY', `"${meeting.title}" has no summary to save in the entry note`);
|
|
138
|
+
const note = path.join(dir, 'entry-note.md');
|
|
139
139
|
writeAtomic(note, noteBody(summary, meeting, dir));
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
let client = notes;
|
|
141
|
+
let reason = 'no inkwell with docs.entry-notes was given';
|
|
142
|
+
if (client === undefined) {
|
|
143
|
+
const found = await lookupInkwell(INKWELL_CAPABILITIES.notes);
|
|
144
|
+
client = found.client;
|
|
145
|
+
if (found.reason !== undefined)
|
|
146
|
+
reason = found.reason;
|
|
147
|
+
}
|
|
148
|
+
if (!client) {
|
|
149
|
+
log?.(`minutes: not saved in the note of entry #${entryId}, ${reason}; they stay in ${dir} (${INKWELL_HINT})`);
|
|
150
|
+
return { saved: false, reason };
|
|
143
151
|
}
|
|
152
|
+
await callInkwell(client, ['note', 'save', String(entryId), '--section', NOTE_SECTION, '--md', note]);
|
|
153
|
+
log?.(`minutes: saved in the note of entry #${entryId}`);
|
|
154
|
+
return { saved: true };
|
|
144
155
|
}
|
|
145
156
|
export function meetingDay(meeting) {
|
|
146
157
|
return dayString(parseDate(meeting.startedAt) ?? parseDate(meeting.createdAt) ?? new Date());
|
|
@@ -155,7 +166,7 @@ export function encodeWrapupPlan(plan) {
|
|
|
155
166
|
});
|
|
156
167
|
}
|
|
157
168
|
async function pageText(docs, pageId) {
|
|
158
|
-
const page = await
|
|
169
|
+
const page = await callInkwell(docs, ['page', 'show', String(pageId)]);
|
|
159
170
|
const doc = isRecord(page) && isRecord(page['doc']) ? page['doc'] : undefined;
|
|
160
171
|
const file = typeof doc?.['path'] === 'string' ? doc['path'] : undefined;
|
|
161
172
|
return file ? readText(file) : null;
|
|
@@ -182,8 +193,16 @@ export async function runWrapup(meeting, dir, config, dependencies) {
|
|
|
182
193
|
const entryId = meeting.bitaEntryId;
|
|
183
194
|
if (entryId === undefined)
|
|
184
195
|
return;
|
|
196
|
+
let docs = dependencies?.docs;
|
|
197
|
+
if (!docs) {
|
|
198
|
+
const found = await lookupInkwell(INKWELL_CAPABILITIES.wrapup);
|
|
199
|
+
if (!found.client) {
|
|
200
|
+
await saveMinutes(meeting, dir, undefined, dependencies?.log).catch((error) => dependencies?.log?.(`minutes: ${String(error)}`));
|
|
201
|
+
throw new StageSkipped(found.reason, INKWELL_HINT);
|
|
202
|
+
}
|
|
203
|
+
docs = found.client;
|
|
204
|
+
}
|
|
185
205
|
const bita = dependencies?.bita ?? (await requireBita(config, meetingTarget(meeting)));
|
|
186
|
-
const docs = dependencies?.docs ?? (await docsClient(config, meetingTarget(meeting))) ?? bita;
|
|
187
206
|
const claude = dependencies?.claude ?? (async (prompt) => resultText(await runSummaryClaude(prompt, dir, config)));
|
|
188
207
|
const snapshot = meeting.bitaEntry ?? { title: meeting.title, pageIds: [] };
|
|
189
208
|
const wrapup = { ...emptyWrapup(), ...(meeting.wrapup ?? {}) };
|
|
@@ -192,7 +211,7 @@ export async function runWrapup(meeting, dir, config, dependencies) {
|
|
|
192
211
|
.filter(isRecord)
|
|
193
212
|
.filter((project) => project['active'] !== false)
|
|
194
213
|
.flatMap((project) => (typeof project['name'] === 'string' ? [project['name']] : []));
|
|
195
|
-
const existingPageId = wrapup.pageId ??
|
|
214
|
+
const existingPageId = wrapup.pageId ?? (await entryPages(docs, entryId, true))[0]?.pageId;
|
|
196
215
|
const existingPage = existingPageId === undefined ? null : await pageText(docs, existingPageId);
|
|
197
216
|
const plan = await planWrapup(meeting, dir, snapshot, projects, existingPage, claude);
|
|
198
217
|
writeAtomic(path.join(dir, 'wrapup.json'), encodeWrapupPlan(plan));
|
|
@@ -209,23 +228,23 @@ export async function runWrapup(meeting, dir, config, dependencies) {
|
|
|
209
228
|
await callBita(bita, ['amend', String(entryId), '--project', choice.apply]);
|
|
210
229
|
wrapup.project = choice.apply ?? snapshot.projectName;
|
|
211
230
|
wrapup.projectResolved = choice.resolved;
|
|
212
|
-
const pageFile = path.join(dir, '
|
|
231
|
+
const pageFile = path.join(dir, 'wrapup-page.md');
|
|
213
232
|
if (existingPageId !== undefined) {
|
|
214
233
|
writeAtomic(pageFile, demoteHeadings(plan.pageMarkdown));
|
|
215
|
-
await
|
|
234
|
+
await callInkwell(docs, ['page', 'write', String(existingPageId), '--md', pageFile, '--section', `Reunión ${meetingDay(meeting)}`]);
|
|
216
235
|
wrapup.pageId = existingPageId;
|
|
217
236
|
}
|
|
218
237
|
else {
|
|
219
238
|
writeAtomic(pageFile, plan.pageMarkdown);
|
|
220
|
-
const args = ['
|
|
239
|
+
const args = ['page', 'new', plan.pageTitle, '--from-entry', String(entryId)];
|
|
221
240
|
if (wrapup.project)
|
|
222
241
|
args.push('--project', wrapup.project);
|
|
223
|
-
const created = await
|
|
242
|
+
const created = await callInkwell(docs, args);
|
|
224
243
|
const page = isRecord(created) && isRecord(created['page']) ? created['page'] : undefined;
|
|
225
244
|
const pageId = typeof page?.['pageId'] === 'number' ? page['pageId'] : undefined;
|
|
226
245
|
if (pageId === undefined)
|
|
227
|
-
throw new RecapError('
|
|
228
|
-
await
|
|
246
|
+
throw new RecapError('INKWELL_FAILED', 'inkwell page new did not return the page id');
|
|
247
|
+
await callInkwell(docs, ['page', 'write', String(pageId), '--md', pageFile]);
|
|
229
248
|
wrapup.pageId = pageId;
|
|
230
249
|
wrapup.pageCreated = true;
|
|
231
250
|
}
|
|
@@ -237,17 +256,17 @@ export async function runWrapup(meeting, dir, config, dependencies) {
|
|
|
237
256
|
continue;
|
|
238
257
|
const args = ['backlog', 'add', '--kind', item.kind, '--title', item.title, '--page', String(wrapup.pageId ?? 0)];
|
|
239
258
|
if (item.body) {
|
|
240
|
-
const bodyFile = path.join(dir, '
|
|
259
|
+
const bodyFile = path.join(dir, 'wrapup-backlog-item.md');
|
|
241
260
|
writeAtomic(bodyFile, item.body);
|
|
242
261
|
args.push('--md', bodyFile);
|
|
243
262
|
}
|
|
244
|
-
const added = await
|
|
263
|
+
const added = await callInkwell(docs, args);
|
|
245
264
|
wrapup.backlogKeys[item.title] = isRecord(added) && typeof added['key'] === 'string' ? added['key'] : '?';
|
|
246
265
|
updateMeeting(dir, (current) => {
|
|
247
266
|
current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
|
|
248
267
|
});
|
|
249
268
|
}
|
|
250
|
-
await saveMinutes(meeting, dir,
|
|
269
|
+
await saveMinutes(meeting, dir, dependencies?.notes, dependencies?.log);
|
|
251
270
|
updateMeeting(dir, (current) => {
|
|
252
271
|
current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
|
|
253
272
|
});
|
package/dist/capture/capture.js
CHANGED
|
@@ -6,7 +6,7 @@ import { exists, readText } from "../core/fsutil.js";
|
|
|
6
6
|
import { isRecord } from "../core/json.js";
|
|
7
7
|
import { home } from "../core/paths.js";
|
|
8
8
|
import { exec, spawnDetached } from "../core/proc.js";
|
|
9
|
-
import { selfCommand } from "../core/self.js";
|
|
9
|
+
import { selfCommand, stableCommand } from "../core/self.js";
|
|
10
10
|
import { trimmed } from "../core/text.js";
|
|
11
11
|
import { RecapError } from "../errors.js";
|
|
12
12
|
import { parseEnvelope } from '@kikedealba/kit/envelope';
|
|
@@ -35,16 +35,24 @@ export function captureUnavailable() {
|
|
|
35
35
|
: 'Recording is only available on macOS for now. Record with any app and process the file with `recap import <file>`.';
|
|
36
36
|
return new RecapError('CAPTURE_UNAVAILABLE', 'The recorder (recap-capture) is not available on this machine', { hint });
|
|
37
37
|
}
|
|
38
|
-
export function liveWorkerCommand() {
|
|
39
|
-
return
|
|
38
|
+
export async function liveWorkerCommand(ctx) {
|
|
39
|
+
return [...(await stableCommand(ctx)), 'live-worker'];
|
|
40
|
+
}
|
|
41
|
+
export function workerPath(command, env = process.env) {
|
|
42
|
+
if (command[0] === selfCommand()[0])
|
|
43
|
+
return null;
|
|
44
|
+
return [path.dirname(process.execPath), ...(env['PATH'] ?? '').split(path.delimiter)].filter((dir, index, all) => dir.length > 0 && all.indexOf(dir) === index).join(path.delimiter);
|
|
40
45
|
}
|
|
41
46
|
export function hasCaptureBinary(app) {
|
|
42
47
|
return exists(path.join(app, 'Contents', 'MacOS', 'recap-capture'));
|
|
43
48
|
}
|
|
44
|
-
export function launchArguments(app, dir, log, modern = hasCaptureBinary(app)) {
|
|
49
|
+
export async function launchArguments(app, dir, log, modern = hasCaptureBinary(app), ctx) {
|
|
45
50
|
if (!modern)
|
|
46
51
|
return ['-g', '-n', '-a', app, '--stdout', log, '--stderr', log, '--args', 'record', dir];
|
|
47
|
-
|
|
52
|
+
const worker = await liveWorkerCommand(ctx);
|
|
53
|
+
const searchPath = workerPath(worker, ctx?.env);
|
|
54
|
+
const env = ['--env', `RECAP_LIVE_WORKER=${JSON.stringify(worker)}`, ...(searchPath === null ? [] : ['--env', `PATH=${searchPath}`])];
|
|
55
|
+
return ['-g', '-n', '-a', app, ...env, '--stdout', log, '--stderr', log, '--args', 'capture', 'record', dir];
|
|
48
56
|
}
|
|
49
57
|
export async function launchRecorder(dir) {
|
|
50
58
|
const launcher = await findCapture();
|
|
@@ -52,12 +60,12 @@ export async function launchRecorder(dir) {
|
|
|
52
60
|
throw captureUnavailable();
|
|
53
61
|
const log = path.join(dir, 'recorder.log');
|
|
54
62
|
if (launcher.kind === 'app') {
|
|
55
|
-
const result = await exec('/usr/bin/open', launchArguments(launcher.app, dir, log));
|
|
63
|
+
const result = await exec('/usr/bin/open', await launchArguments(launcher.app, dir, log));
|
|
56
64
|
if (result.status !== 0)
|
|
57
65
|
throw new RecapError('LAUNCH_FAILED', `Cannot launch ${path.basename(launcher.app)}: ${trimmed(result.stderr)}`);
|
|
58
66
|
return;
|
|
59
67
|
}
|
|
60
|
-
spawnDetached(launcher.binary, ['record', dir, '--live-worker', liveWorkerCommand()], log);
|
|
68
|
+
spawnDetached(launcher.binary, ['record', dir, '--live-worker', JSON.stringify(await liveWorkerCommand())], log);
|
|
61
69
|
}
|
|
62
70
|
function readReport(text) {
|
|
63
71
|
if (text === null)
|
package/dist/cli/commands/ask.js
CHANGED
|
@@ -5,10 +5,9 @@ import { MeetingStore, loadMeeting, resolveTarget } from "../../core/meeting.js"
|
|
|
5
5
|
import { signalNumber, signalTree } from "../../core/proc.js";
|
|
6
6
|
import { trimmed } from "../../core/text.js";
|
|
7
7
|
import { RecapError, asRecapError, usageError } from "../../errors.js";
|
|
8
|
-
import { BitaClient, docsClient, meetingTarget } from "../../bita/client.js";
|
|
9
8
|
import { runAsk } from "../../live/ask.js";
|
|
10
9
|
import { isValidAskId, makeAskId, performAsk, removeBoard } from "../../live/asking.js";
|
|
11
|
-
import { loadProjectContext,
|
|
10
|
+
import { contextSources, loadProjectContext, meetingContext, meetingProjectName } from "../../live/context.js";
|
|
12
11
|
import { jsonLine } from "../../live/files.js";
|
|
13
12
|
import { encodeAnswer, encodeAskEvent } from "../../live/stream.js";
|
|
14
13
|
import { parseArgs } from "../args.js";
|
|
@@ -77,8 +76,7 @@ async function answer(args, emit) {
|
|
|
77
76
|
let childPid = null;
|
|
78
77
|
installCleanup(found.dir, id, () => childPid);
|
|
79
78
|
return performAsk({ dir: found.dir, id, question, auto: args.has('auto'), now, questionMs, channel }, async () => {
|
|
80
|
-
const
|
|
81
|
-
const context = await loadProjectContext(await meetingProject(found.meeting, docs), found.meeting.bitaDocsRoot, docs);
|
|
79
|
+
const context = await meetingContext(found.meeting, config);
|
|
82
80
|
return runAsk(config, { meeting: found.meeting, dir: found.dir, question, windowSeconds: args.int('window') ?? 180, now, auto: args.has('auto'), questionMs, channel, askId: id }, context, emit, (pid) => {
|
|
83
81
|
childPid = pid;
|
|
84
82
|
});
|
|
@@ -105,9 +103,9 @@ export async function askCommand(argv) {
|
|
|
105
103
|
if (args.has('sources')) {
|
|
106
104
|
const config = loadConfig();
|
|
107
105
|
const found = args.has('active') || args.value('meeting') !== undefined || args.value('bita-entry') !== undefined ? target(args, config) : null;
|
|
108
|
-
const
|
|
106
|
+
const sources = await contextSources(config, found?.meeting ?? null);
|
|
109
107
|
const name = args.value('project') ?? (found ? meetingProjectName(found.meeting) : null);
|
|
110
|
-
const context = await loadProjectContext(name,
|
|
108
|
+
const context = await loadProjectContext(name, sources);
|
|
111
109
|
const lines = [`Project: ${context.project ?? '(none)'}`, `Docs: ${context.docsRoot ?? '(unknown)'}`, ...context.repos.map((repo) => `Repo: ${repo.slug} ${repo.path}${repo.exists ? '' : ' (missing)'}`)];
|
|
112
110
|
return { data: { project: context.project ?? undefined, docsRoot: context.docsRoot ?? undefined, repos: context.repos }, text: lines.join('\n') };
|
|
113
111
|
}
|
|
@@ -62,6 +62,21 @@ export async function handleHookEvent(event) {
|
|
|
62
62
|
log(`discarded ${record.dir}`);
|
|
63
63
|
break;
|
|
64
64
|
}
|
|
65
|
+
case 'refresh': {
|
|
66
|
+
const found = new MeetingStore(loadConfig()).find(event.entry.id);
|
|
67
|
+
if (!found) {
|
|
68
|
+
log(`nothing to do: no meeting for entry #${event.entry.id}`);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
updateMeeting(found.dir, (meeting) => {
|
|
72
|
+
meeting.bitaEntry = { ...eventSnapshot(event), pageIds: meeting.bitaEntry?.pageIds ?? [] };
|
|
73
|
+
const renamed = event.previousTitle !== undefined && event.previousTitle !== event.entry.description;
|
|
74
|
+
if (renamed && event.entry.description.trim().length > 0)
|
|
75
|
+
meeting.title = event.entry.description;
|
|
76
|
+
});
|
|
77
|
+
log(`updated ${found.dir}`);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
65
80
|
case 'ignore':
|
|
66
81
|
log(`nothing to do: ${action.reason}`);
|
|
67
82
|
break;
|
|
@@ -3,7 +3,7 @@ import { expandTilde } from "../../core/paths.js";
|
|
|
3
3
|
import { readText } from "../../core/fsutil.js";
|
|
4
4
|
import { MeetingStore, resolveTarget } from "../../core/meeting.js";
|
|
5
5
|
import { RecapError, usageError } from "../../errors.js";
|
|
6
|
-
import {
|
|
6
|
+
import { INKWELL_CAPABILITIES, INKWELL_HINT, findInkwell, requireInkwell } from "../../bita/client.js";
|
|
7
7
|
import { ProposalReview, loadProposals, proposalJson } from "../../bita/proposals.js";
|
|
8
8
|
import { parseArgs } from "../args.js";
|
|
9
9
|
import { output } from "../output.js";
|
|
@@ -21,13 +21,11 @@ function number(args) {
|
|
|
21
21
|
throw new RecapError('PROPOSAL_REQUIRED', 'Pass the proposal number');
|
|
22
22
|
return Number(raw);
|
|
23
23
|
}
|
|
24
|
-
const
|
|
24
|
+
const noInkwell = { invoke: async () => ({ ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: `inkwell not found (${INKWELL_HINT})` }) };
|
|
25
25
|
async function review(args, config, required) {
|
|
26
26
|
const found = meeting(args, config);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
throw new RecapError('DEPENDENCY_MISSING', 'bita not found. Install it with `npm install -g @kikedealba/bita`');
|
|
30
|
-
return new ProposalReview(found.dir, docs ?? noBita);
|
|
27
|
+
const inkwell = required ? await requireInkwell(INKWELL_CAPABILITIES.proposals, 'Reviewing proposals') : await findInkwell(INKWELL_CAPABILITIES.proposals);
|
|
28
|
+
return new ProposalReview(found.dir, inkwell ?? noInkwell);
|
|
31
29
|
}
|
|
32
30
|
export async function proposalsCommand(argv) {
|
|
33
31
|
const [sub, ...rest] = argv;
|
|
@@ -10,6 +10,7 @@ import { processInBackground } from "../../core/self.js";
|
|
|
10
10
|
import { formatDuration, padEnd } from "../../core/text.js";
|
|
11
11
|
import { RecapError, usageError } from "../../errors.js";
|
|
12
12
|
import { saveMinutes } from "../../bita/wrapup.js";
|
|
13
|
+
import { INKWELL_HINT } from "../../bita/client.js";
|
|
13
14
|
import { Pipeline } from "../../pipeline/pipeline.js";
|
|
14
15
|
import { STAGES, allStagesSatisfied, isStage } from "../../pipeline/stages.js";
|
|
15
16
|
import { saveSummary, summaryPromptFor } from "../../pipeline/summary.js";
|
|
@@ -106,14 +107,17 @@ export async function saveSummaryCommand(argv) {
|
|
|
106
107
|
throw new RecapError('FILE_UNREADABLE', `Cannot read ${file}: ${error.message}`);
|
|
107
108
|
}
|
|
108
109
|
saveSummary(body, meeting, dir);
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
let minutes = null;
|
|
111
|
+
if (meeting.bitaEntryId !== undefined) {
|
|
112
|
+
const result = await saveMinutes(meeting, dir);
|
|
113
|
+
minutes = result.saved ? `Minutes saved in the note of entry #${meeting.bitaEntryId}` : `Minutes not saved in the entry note: ${result.reason ?? 'no reason given'} (${INKWELL_HINT})`;
|
|
114
|
+
}
|
|
111
115
|
const updated = updateMeeting(dir, (current) => {
|
|
112
116
|
current.stages['summarize'] = { status: 'done', updatedAt: isoNow() };
|
|
113
117
|
if (allStagesSatisfied({ ...meeting, stages: current.stages }))
|
|
114
118
|
current.status = 'processed';
|
|
115
119
|
});
|
|
116
|
-
return { data: meetingRecord(updated, dir), text: path.join(dir, 'summary.md') };
|
|
120
|
+
return { data: meetingRecord(updated, dir), text: minutes === null ? path.join(dir, 'summary.md') : `${path.join(dir, 'summary.md')}\n${minutes}` };
|
|
117
121
|
});
|
|
118
122
|
}
|
|
119
123
|
export function failedStage(meeting) {
|
|
@@ -131,6 +135,10 @@ export function describeWait(meeting, dir) {
|
|
|
131
135
|
const failed = failedStage(meeting);
|
|
132
136
|
if (failed)
|
|
133
137
|
lines.push(`Failed at ${failed[0]}: ${failed[1].error ?? 'no reason given'}`);
|
|
138
|
+
for (const [stage, state] of Object.entries(meeting.stages)) {
|
|
139
|
+
if (state.status === 'skipped' && state.reason !== undefined)
|
|
140
|
+
lines.push(`Skipped ${stage}: ${state.reason}${state.hint !== undefined ? ` (${state.hint})` : ''}`);
|
|
141
|
+
}
|
|
134
142
|
const wrapup = meeting.wrapup;
|
|
135
143
|
if (wrapup) {
|
|
136
144
|
lines.push(`Title : ${wrapup.title ?? meeting.title}`);
|
package/dist/core/self.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { stableBin } from '@kikedealba/kit/registry';
|
|
4
|
+
import { PACKAGE_ROOT, VERSION } from "../version.js";
|
|
4
5
|
import { spawnDetached } from "./proc.js";
|
|
5
6
|
export function binEntry() {
|
|
6
7
|
const built = path.join(PACKAGE_ROOT, 'dist', 'bin', 'recap.js');
|
|
@@ -9,6 +10,9 @@ export function binEntry() {
|
|
|
9
10
|
export function selfCommand() {
|
|
10
11
|
return [process.execPath, binEntry()];
|
|
11
12
|
}
|
|
13
|
+
export function stableCommand(ctx) {
|
|
14
|
+
return stableBin('recap', VERSION, selfCommand(), ctx);
|
|
15
|
+
}
|
|
12
16
|
export function spawnSelf(args, logPath) {
|
|
13
17
|
const [node, entry] = selfCommand();
|
|
14
18
|
return spawnDetached(node ?? process.execPath, [entry ?? binEntry(), ...args], logPath);
|
package/dist/live/context.js
CHANGED
|
@@ -2,6 +2,8 @@ import path from 'node:path';
|
|
|
2
2
|
import { isDirectory } from "../core/fsutil.js";
|
|
3
3
|
import { isRecord } from "../core/json.js";
|
|
4
4
|
import { trimmed } from "../core/text.js";
|
|
5
|
+
import { BitaClient, INKWELL_CAPABILITIES, findInkwell, meetingTarget } from "../bita/client.js";
|
|
6
|
+
import { RecapError } from "../errors.js";
|
|
5
7
|
export function emptyContext(overrides = {}) {
|
|
6
8
|
return { project: null, docsRoot: null, pages: [], repos: [], ...overrides };
|
|
7
9
|
}
|
|
@@ -24,6 +26,7 @@ export function parsePages(data) {
|
|
|
24
26
|
title,
|
|
25
27
|
relPath: typeof item['relPath'] === 'string' ? item['relPath'] : '',
|
|
26
28
|
depth: typeof item['depth'] === 'number' && Number.isInteger(item['depth']) ? item['depth'] : depth,
|
|
29
|
+
...(typeof item['projectName'] === 'string' ? { projectName: item['projectName'] } : {}),
|
|
27
30
|
});
|
|
28
31
|
if (Array.isArray(item['children']))
|
|
29
32
|
walk(item['children'], depth + 1);
|
|
@@ -52,39 +55,63 @@ export function parseRepos(data) {
|
|
|
52
55
|
function metaRoot(meta) {
|
|
53
56
|
return typeof meta?.['root'] === 'string' ? meta['root'] : null;
|
|
54
57
|
}
|
|
55
|
-
export async function loadProjectContext(project,
|
|
56
|
-
const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null
|
|
57
|
-
|
|
58
|
-
return context;
|
|
58
|
+
export async function loadProjectContext(project, sources) {
|
|
59
|
+
const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null });
|
|
60
|
+
const { inkwell, bita } = sources;
|
|
59
61
|
if (context.project) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
if (inkwell) {
|
|
63
|
+
const pages = await inkwell.invoke(['page', 'ls', '--project', context.project]).catch(() => null);
|
|
64
|
+
if (pages?.ok) {
|
|
65
|
+
context.pages = parsePages(pages.data);
|
|
66
|
+
context.docsRoot = metaRoot(pages.meta);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (bita) {
|
|
70
|
+
const repos = await bita.invoke(['project', 'repo', 'ls', '--project', context.project]).catch(() => null);
|
|
71
|
+
if (repos?.ok)
|
|
72
|
+
context.repos = parseRepos(repos.data);
|
|
64
73
|
}
|
|
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
74
|
}
|
|
69
|
-
if (context.docsRoot === null) {
|
|
70
|
-
const tree = await
|
|
75
|
+
if (context.docsRoot === null && inkwell) {
|
|
76
|
+
const tree = await inkwell.invoke(['tree']).catch(() => null);
|
|
71
77
|
if (tree?.ok)
|
|
72
78
|
context.docsRoot = metaRoot(tree.meta);
|
|
73
79
|
}
|
|
74
80
|
return context;
|
|
75
81
|
}
|
|
82
|
+
export async function entryPages(inkwell, entryId, strict = false) {
|
|
83
|
+
if (!inkwell || entryId === undefined)
|
|
84
|
+
return [];
|
|
85
|
+
const response = await inkwell.invoke(['page', 'ls', '--entry', String(entryId)]).catch(() => null);
|
|
86
|
+
if (response?.ok)
|
|
87
|
+
return parsePages(response.data);
|
|
88
|
+
if (strict)
|
|
89
|
+
throw new RecapError('INKWELL_FAILED', `inkwell page ls --entry ${entryId} failed: ${response?.errorMessage ?? 'no reason given'}`);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
76
92
|
export function meetingProjectName(meeting) {
|
|
77
93
|
return meeting.bitaEntry?.projectName ?? meeting.wrapup?.project ?? null;
|
|
78
94
|
}
|
|
79
|
-
export async function meetingProject(meeting,
|
|
95
|
+
export async function meetingProject(meeting, inkwell, linked) {
|
|
80
96
|
const name = meetingProjectName(meeting);
|
|
81
97
|
if (name && trimmed(name).length > 0)
|
|
82
98
|
return name;
|
|
83
|
-
|
|
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))
|
|
99
|
+
if (!inkwell)
|
|
88
100
|
return null;
|
|
89
|
-
|
|
101
|
+
const pageId = meeting.wrapup?.pageId;
|
|
102
|
+
if (pageId !== undefined) {
|
|
103
|
+
const response = await inkwell.invoke(['page', 'show', String(pageId), '--no-markdown']).catch(() => null);
|
|
104
|
+
if (response?.ok && isRecord(response.data) && typeof response.data['projectName'] === 'string')
|
|
105
|
+
return response.data['projectName'];
|
|
106
|
+
}
|
|
107
|
+
const pages = linked ?? (await entryPages(inkwell, meeting.bitaEntryId));
|
|
108
|
+
return pages.find((page) => page.projectName !== undefined)?.projectName ?? null;
|
|
109
|
+
}
|
|
110
|
+
export async function contextSources(config, meeting) {
|
|
111
|
+
const [inkwell, bita] = await Promise.all([findInkwell(INKWELL_CAPABILITIES.pages), BitaClient.create(config, meeting ? meetingTarget(meeting) : {})]);
|
|
112
|
+
return { inkwell, bita };
|
|
113
|
+
}
|
|
114
|
+
export async function meetingContext(meeting, config) {
|
|
115
|
+
const sources = await contextSources(config, meeting);
|
|
116
|
+
return loadProjectContext(await meetingProject(meeting, sources.inkwell), sources);
|
|
90
117
|
}
|
package/dist/live/worker.js
CHANGED
|
@@ -10,12 +10,11 @@ import { selfCommand } from "../core/self.js";
|
|
|
10
10
|
import { suffix, trimmed } from "../core/text.js";
|
|
11
11
|
import { requireTool } from "../core/tools.js";
|
|
12
12
|
import { RecapError, errorMessage } from "../errors.js";
|
|
13
|
-
import { docsClient, meetingTarget } from "../bita/client.js";
|
|
14
13
|
import { resultText, runClaude } from "../pipeline/claude.js";
|
|
15
14
|
import { whisperArguments } from "../pipeline/pipeline.js";
|
|
16
15
|
import { parseWhisper } from "../pipeline/transcript.js";
|
|
17
16
|
import { pruneBoard } from "./asking.js";
|
|
18
|
-
import {
|
|
17
|
+
import { meetingContext } from "./context.js";
|
|
19
18
|
import { AutoAskProcess, AutoAskQueue, QuestionDetector } from "./detector.js";
|
|
20
19
|
import { appendLines, liveFiles, readLines } from "./files.js";
|
|
21
20
|
import { LiveMerger } from "./merger.js";
|
|
@@ -177,10 +176,7 @@ export function makeLiveDetector(dir, meeting, config, log) {
|
|
|
177
176
|
const runner = new AutoAskProcess(dir, selfCommand());
|
|
178
177
|
const asks = new AutoAskQueue({ dir, concurrency: settings.autoAskConcurrency, run: (job) => runner.run(job), cancelRunning: () => runner.cancel(), log });
|
|
179
178
|
return new QuestionDetector(dir, meeting, settings.autoAskMinSeconds, {
|
|
180
|
-
context:
|
|
181
|
-
const docs = await docsClient(config, meetingTarget(meeting));
|
|
182
|
-
return loadProjectContext(await meetingProject(meeting, docs), meeting.bitaDocsRoot, docs);
|
|
183
|
-
},
|
|
179
|
+
context: () => meetingContext(meeting, config),
|
|
184
180
|
complete: async (prompt) => resultText(await runClaude({ prompt, cwd: dir, config, tools: [], addDirs: [], model: settings.autoAskModel, restrictTools: [] })),
|
|
185
181
|
enqueue: (question, origin) => {
|
|
186
182
|
asks.enqueue(question, origin);
|
|
@@ -11,7 +11,7 @@ import { exec } from "../core/proc.js";
|
|
|
11
11
|
import { suffix, trimmed } from "../core/text.js";
|
|
12
12
|
import { requireTool } from "../core/tools.js";
|
|
13
13
|
import { RecapError, errorMessage } from "../errors.js";
|
|
14
|
-
import {
|
|
14
|
+
import { INKWELL_CAPABILITIES, StageSkipped, inkwellForStage } from "../bita/client.js";
|
|
15
15
|
import { ProposalGenerator } from "../bita/proposals.js";
|
|
16
16
|
import { runWrapup } from "../bita/wrapup.js";
|
|
17
17
|
import { runSummaryClaude } from "./claude.js";
|
|
@@ -78,9 +78,9 @@ export class Pipeline {
|
|
|
78
78
|
recording(meeting) {
|
|
79
79
|
return recordingPath(this.dir, meeting.mode);
|
|
80
80
|
}
|
|
81
|
-
setStage(stage, status, error, extra) {
|
|
81
|
+
setStage(stage, status, error, extra, details = {}) {
|
|
82
82
|
return updateMeeting(this.dir, (current) => {
|
|
83
|
-
current.stages[stage] = { status, updatedAt: isoNow(), ...(error !== undefined ? { error } : {}) };
|
|
83
|
+
current.stages[stage] = { status, updatedAt: isoNow(), ...(error !== undefined ? { error } : {}), ...details };
|
|
84
84
|
extra?.(current);
|
|
85
85
|
});
|
|
86
86
|
}
|
|
@@ -131,6 +131,11 @@ export class Pipeline {
|
|
|
131
131
|
}
|
|
132
132
|
catch (error) {
|
|
133
133
|
const message = error instanceof RecapError ? error.message : errorMessage(error);
|
|
134
|
+
if (error instanceof StageSkipped) {
|
|
135
|
+
meeting = this.setStage(stage, 'skipped', undefined, undefined, { reason: message, ...(error.hint !== undefined ? { hint: error.hint } : {}) });
|
|
136
|
+
this.log(`${stage}: skipped, ${message}${error.hint !== undefined ? ` (${error.hint})` : ''}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
134
139
|
if (stageIsOptional(stage)) {
|
|
135
140
|
meeting = this.setStage(stage, 'failed', message);
|
|
136
141
|
this.log(`${stage}: failed, continuing: ${message}`);
|
|
@@ -165,7 +170,7 @@ export class Pipeline {
|
|
|
165
170
|
case 'proposals':
|
|
166
171
|
return this.proposals(meeting);
|
|
167
172
|
case 'wrapup':
|
|
168
|
-
return runWrapup(meeting, this.dir, this.config);
|
|
173
|
+
return runWrapup(meeting, this.dir, this.config, { log: this.log });
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
async extractAudio(meeting) {
|
|
@@ -268,8 +273,7 @@ export class Pipeline {
|
|
|
268
273
|
saveSummary(extractSummary(output), meeting, this.dir);
|
|
269
274
|
}
|
|
270
275
|
async proposals(meeting) {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
await new ProposalGenerator(this.config, docs, this.log).run(meeting, this.dir);
|
|
276
|
+
const inkwell = await inkwellForStage(INKWELL_CAPABILITIES.proposals);
|
|
277
|
+
await new ProposalGenerator(this.config, inkwell, this.log).run(meeting, this.dir);
|
|
274
278
|
}
|
|
275
279
|
}
|
|
@@ -6,7 +6,7 @@ import { bitaCommand } from "../bita/client.js";
|
|
|
6
6
|
import { readText } from "../core/fsutil.js";
|
|
7
7
|
import { isRecord } from "../core/json.js";
|
|
8
8
|
import { home } from "../core/paths.js";
|
|
9
|
-
import {
|
|
9
|
+
import { stableCommand } from "../core/self.js";
|
|
10
10
|
import { HOOK_EVENTS, MEETING_KINDS } from "../bita/hook.js";
|
|
11
11
|
import { PACKAGE_ROOT, VERSION } from "../version.js";
|
|
12
12
|
export const INSTALL_HINT = 'npm install -g @kikedealba/recap && recap setup';
|
|
@@ -14,28 +14,32 @@ export const BASE_CAPABILITIES = ['meeting.process', 'meeting.import', 'meeting.
|
|
|
14
14
|
export function capabilities(captureAvailable) {
|
|
15
15
|
return captureAvailable ? ['meeting.record', ...BASE_CAPABILITIES] : [...BASE_CAPABILITIES];
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
function subscription(bin) {
|
|
18
18
|
return {
|
|
19
19
|
tool: 'bita',
|
|
20
20
|
events: [...HOOK_EVENTS],
|
|
21
21
|
filter: { kind: Object.keys(MEETING_KINDS).sort() },
|
|
22
|
-
command: [...
|
|
22
|
+
command: [...bin, 'bita-hook'],
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
|
-
export function
|
|
25
|
+
export async function bitaSubscription(ctx) {
|
|
26
|
+
return subscription(await stableCommand(ctx));
|
|
27
|
+
}
|
|
28
|
+
export async function manifest(captureAvailable, subscribe = captureAvailable, ctx) {
|
|
29
|
+
const bin = await stableCommand(ctx);
|
|
26
30
|
return defineManifest({
|
|
27
31
|
name: 'recap',
|
|
28
32
|
version: VERSION,
|
|
29
33
|
description: 'Meeting recorder: local transcription, minutes, live answers',
|
|
30
|
-
bin
|
|
34
|
+
bin,
|
|
31
35
|
capabilities: capabilities(captureAvailable),
|
|
32
|
-
subscribes: subscribe ? [
|
|
36
|
+
subscribes: subscribe ? [subscription(bin)] : [],
|
|
33
37
|
homepage: 'https://github.com/KikeDeAlba/recap',
|
|
34
38
|
install: INSTALL_HINT,
|
|
35
39
|
});
|
|
36
40
|
}
|
|
37
|
-
export function register(captureAvailable, subscribe = captureAvailable) {
|
|
38
|
-
return registerTool(manifest(captureAvailable, subscribe));
|
|
41
|
+
export async function register(captureAvailable, subscribe = captureAvailable) {
|
|
42
|
+
return registerTool(await manifest(captureAvailable, subscribe));
|
|
39
43
|
}
|
|
40
44
|
export async function bitaLinked(config) {
|
|
41
45
|
const own = await readManifest('recap').catch(() => null);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kikedealba/recap",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Meeting recorder CLI: local transcription with whisper.cpp, minutes written by Claude Code, live answers and
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Meeting recorder CLI: local transcription with whisper.cpp, minutes written by Claude Code, live answers, bita timers and inkwell docs.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jesus Enrique De Alba Gaytan",
|
|
7
7
|
"homepage": "https://github.com/KikeDeAlba/recap#readme",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"recap": "node src/bin/recap.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@kikedealba/kit": "^0.
|
|
53
|
+
"@kikedealba/kit": "^0.2.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"@types/node": "^24.0.0",
|
package/skills/recap/SKILL.md
CHANGED
|
@@ -74,37 +74,57 @@ Errores típicos y qué hacer:
|
|
|
74
74
|
| `DEPENDENCY_MISSING`, `MODEL_MISSING` | Falta ffmpeg, whisper-cli, claude o el modelo | `recap setup --install-deps` |
|
|
75
75
|
| `CAPTURE_UNAVAILABLE` | No hay grabador en este equipo (Windows, Linux, o falta Recap.app) | Graba con otra app y usa `recap import <archivo>`; en mac, `recap setup` |
|
|
76
76
|
|
|
77
|
-
## Con bita
|
|
77
|
+
## Con bita e inkwell
|
|
78
|
+
|
|
79
|
+
Cada herramienta hace una sola cosa:
|
|
80
|
+
|
|
81
|
+
- **bita** lleva el tiempo (cronómetros). recap solo le pide `projects`,
|
|
82
|
+
`project repo ls`, `entries get` y `amend` (título y proyecto).
|
|
83
|
+
- **inkwell** guarda la documentación: páginas, backlog, propuestas, historial
|
|
84
|
+
y la nota de cada entrada de bita (`inkwell note …`).
|
|
85
|
+
- **tally** vuelca a Jira el tiempo de bita (`tally summary --pending`); recap
|
|
86
|
+
no toca Jira.
|
|
78
87
|
|
|
79
88
|
Si `recap status --json` trae `data.bitaLinked: true` (recap está suscrito a
|
|
80
89
|
los eventos de bita desde `recap setup`), **arranca y para las reuniones desde
|
|
81
90
|
bita**, no desde recap: así el tiempo queda medido y la grabación sigue al
|
|
82
|
-
|
|
91
|
+
cronómetro.
|
|
83
92
|
|
|
84
93
|
```sh
|
|
85
94
|
bita start "<título>" --kind remote-meeting
|
|
86
95
|
bita start "<título>" --kind in-person-meeting
|
|
87
|
-
bita stop <id> --json # sin
|
|
96
|
+
bita stop <id> --json # sin escribir la página: eso lo hace recap
|
|
88
97
|
recap wait --bita-entry <id> --json # espera a que todo quede listo
|
|
89
98
|
```
|
|
90
99
|
|
|
100
|
+
Cambiar el título o el proyecto del cronómetro mientras se graba (`bita amend`)
|
|
101
|
+
no corta ni duplica la grabación: recap solo actualiza los datos de la reunión.
|
|
102
|
+
Solo un cambio de tipo (`--kind`) que cruce la línea de reunión la arranca o la
|
|
103
|
+
detiene.
|
|
104
|
+
|
|
91
105
|
Al parar, recap hace todo lo demás sin que nadie lo pida:
|
|
92
106
|
|
|
93
107
|
- transcribe y escribe la minuta;
|
|
94
|
-
- le pone al
|
|
95
|
-
- le asigna el proyecto si no tenía y la conversación lo deja claro;
|
|
96
|
-
- crea la página en
|
|
97
|
-
|
|
98
|
-
-
|
|
108
|
+
- le pone al cronómetro un título real, si el que tenía era genérico (`bita amend`);
|
|
109
|
+
- le asigna el proyecto si no tenía y la conversación lo deja claro (`bita amend`);
|
|
110
|
+
- crea la página en inkwell, ligada a la entrada (`inkwell page new --from-entry`),
|
|
111
|
+
o agrega una sección a la que ya tenía (`inkwell page ls --entry`), y la escribe;
|
|
112
|
+
- pasa pendientes y hallazgos al backlog de inkwell (`inkwell backlog add`);
|
|
113
|
+
- deja la minuta en la sección «Reunión» de la nota de la entrada
|
|
114
|
+
(`inkwell note save <entrada> --section Reunión --md …`).
|
|
99
115
|
|
|
100
116
|
El resultado está en `data.wrapup` de `recap wait`. Si `wrapup.projectResolved`
|
|
101
|
-
es false, pregunta el proyecto
|
|
102
|
-
|
|
103
|
-
`inkwell backlog edit --project
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
es false, pregunta el proyecto, aplícalo con `bita amend <id> --project …` y
|
|
118
|
+
mueve la página y el backlog con `inkwell page move --project` e
|
|
119
|
+
`inkwell backlog edit --project`.
|
|
120
|
+
|
|
121
|
+
**Sin inkwell** la reunión igual queda procesada: las etapas `proposals` y
|
|
122
|
+
`wrapup` quedan en `skipped` con el motivo en `stages.<etapa>.reason` y la pista
|
|
123
|
+
`npm i -g @kikedealba/inkwell && inkwell setup`; la minuta se queda en
|
|
124
|
+
`summary.md` de la carpeta de la reunión. Si inkwell es viejo y no tiene la
|
|
125
|
+
capacidad `docs.entry-notes`, se escribe la página y el backlog pero la minuta
|
|
126
|
+
no pasa a la nota (queda anotado en `process.log`). Después de instalar o
|
|
127
|
+
actualizar inkwell, `recap process <id> --from proposals` lo completa.
|
|
108
128
|
|
|
109
129
|
## En vivo
|
|
110
130
|
|
|
@@ -113,8 +133,9 @@ Mientras se graba, recap transcribe por tramos de 5 a 20 s en
|
|
|
113
133
|
Sala y `system` Remotos). Es aproximada: la transcripción de después del stop
|
|
114
134
|
(`transcript.md`) sigue siendo la fuente de verdad.
|
|
115
135
|
|
|
116
|
-
Para responder una pregunta que hicieron en la reunión, con las páginas de
|
|
117
|
-
y los repos del proyecto (`bita project repo ls`)
|
|
136
|
+
Para responder una pregunta que hicieron en la reunión, con las páginas de
|
|
137
|
+
inkwell y los repos del proyecto (`bita project repo ls`); sin inkwell responde
|
|
138
|
+
solo con los repos:
|
|
118
139
|
|
|
119
140
|
```sh
|
|
120
141
|
recap ask --active --json # la última pregunta de la transcripción
|
|
@@ -138,9 +159,10 @@ solo corre una respuesta a la vez y una manual detiene a la automática.
|
|
|
138
159
|
|
|
139
160
|
## Cambios propuestos a la documentación
|
|
140
161
|
|
|
141
|
-
Si la reunión está ligada a bita, la etapa `proposals`
|
|
142
|
-
detecta los cambios explícitos y firmes a páginas
|
|
143
|
-
la rama `proposal/meeting-<entrada>` de
|
|
162
|
+
Si la reunión está ligada a bita e inkwell está instalado, la etapa `proposals`
|
|
163
|
+
(antes de `wrapup`) detecta los cambios explícitos y firmes a páginas de inkwell
|
|
164
|
+
que ya existen y los deja en la rama `proposal/meeting-<entrada>` de sus docs
|
|
165
|
+
(`inkwell git propose`), sin tocar `main`. Ideas,
|
|
144
166
|
dudas y lo que se corrigió después no entran.
|
|
145
167
|
|
|
146
168
|
```sh
|