@kikedealba/recap 0.9.1 → 0.11.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.
@@ -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, suffix, trimmed } from "../core/text.js";
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, docsClient, meetingTarget, requireBita } from "./client.js";
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, config, client) {
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 send to bita`);
138
- const note = path.join(dir, 'bita-note.md');
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
- const result = await bita.run(['note', 'save', String(entryId), '--note-md', note, '--section', NOTE_SECTION, '--json', ...bita.targetArgs()]);
141
- if (result.status !== 0) {
142
- throw new RecapError('BITA_FAILED', `bita note save ${entryId} failed: ${suffix(trimmed(result.stdout + result.stderr), 400)}`);
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 callBita(docs, ['docs', 'page', 'show', String(pageId)]);
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 ?? snapshot.pageIds[0];
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, 'bita-page.md');
231
+ const pageFile = path.join(dir, 'wrapup-page.md');
213
232
  if (existingPageId !== undefined) {
214
233
  writeAtomic(pageFile, demoteHeadings(plan.pageMarkdown));
215
- await callBita(docs, ['docs', 'page', 'write', String(existingPageId), '--md', pageFile, '--section', `Reunión ${meetingDay(meeting)}`]);
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 = ['docs', 'page', 'new', plan.pageTitle, '--from-entry', String(entryId)];
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 callBita(docs, args);
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('BITA_FAILED', 'bita docs page new did not return the page id');
228
- await callBita(docs, ['docs', 'page', 'write', String(pageId), '--md', pageFile]);
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, 'bita-backlog-item.md');
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 callBita(docs, args);
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, config, bita);
269
+ await saveMinutes(meeting, dir, dependencies?.notes, dependencies?.log);
251
270
  updateMeeting(dir, (current) => {
252
271
  current.wrapup = { ...wrapup, backlogKeys: { ...wrapup.backlogKeys } };
253
272
  });
@@ -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, meetingProject, meetingProjectName } from "../../live/context.js";
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 docs = await docsClient(config, meetingTarget(found.meeting));
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 docs = found ? await docsClient(config, meetingTarget(found.meeting)) : await docsClient(config, {});
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, found?.meeting.bitaDocsRoot, docs ?? (await BitaClient.create(config, {})));
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 { docsClient, meetingTarget } from "../../bita/client.js";
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 noBita = { invoke: async () => ({ ok: false, errorCode: 'DEPENDENCY_MISSING', errorMessage: 'bita not found' }) };
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 docs = await docsClient(config, meetingTarget(found.meeting));
28
- if (!docs && required)
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
- if (meeting.bitaEntryId !== undefined)
110
- await saveMinutes(meeting, dir, config);
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}`);
@@ -0,0 +1,109 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig, rootDir } from "../../core/config.js";
4
+ import { MEETING_FILE, loadMeeting } from "../../core/meeting.js";
5
+ import { sleep } from "../../core/proc.js";
6
+ import { suffix, trimmed } from "../../core/text.js";
7
+ import { usageError } from "../../errors.js";
8
+ import { parseArgs } from "../args.js";
9
+ import { output } from "../output.js";
10
+ import { failedStage, isSettled } from "./query.js";
11
+ export const WATCH_OPT_OUT = 'RECAP_MONITOR';
12
+ const NOTICE = ' (FYI: act on it only if this session is handling that meeting)';
13
+ export function watchDisabled(env = process.env) {
14
+ const value = trimmed(env[WATCH_OPT_OUT] ?? '').toLowerCase();
15
+ return ['0', 'off', 'false', 'no'].includes(value);
16
+ }
17
+ export function describeSettled(meeting, dir) {
18
+ const failed = failedStage(meeting);
19
+ const base = { id: meeting.id, title: meeting.title, status: meeting.status, dir };
20
+ if (meeting.status !== 'processed') {
21
+ const where = failed ? ` at ${failed[0]}` : '';
22
+ const reason = suffix(trimmed((failed ? failed[1].error : meeting.error) ?? 'no reason given').replace(/\s+/g, ' '), 200);
23
+ return { ...base, failed: true, message: `recap: meeting "${meeting.title}" failed${where}: ${reason} (${meeting.id}); run \`recap process ${meeting.id}\` to resume${NOTICE}` };
24
+ }
25
+ const parts = [`recap: meeting "${meeting.title}" finished processing (${meeting.id})`, `minutes at ${path.join(dir, 'summary.md')}`];
26
+ const wrapup = meeting.wrapup;
27
+ if (wrapup?.pageId !== undefined)
28
+ parts.push(`inkwell page #${wrapup.pageId}`);
29
+ if (wrapup && !wrapup.projectResolved)
30
+ parts.push('project unresolved');
31
+ return { ...base, failed: false, message: `${parts.join('; ')}${NOTICE}` };
32
+ }
33
+ export class MeetingWatcher {
34
+ root;
35
+ seen = new Map();
36
+ primed = false;
37
+ constructor(root) {
38
+ this.root = root;
39
+ }
40
+ tick() {
41
+ let names;
42
+ try {
43
+ names = readdirSync(this.root);
44
+ }
45
+ catch {
46
+ names = [];
47
+ }
48
+ const events = [];
49
+ const present = new Set();
50
+ for (const name of names) {
51
+ const dir = path.join(this.root, name);
52
+ let mtimeMs;
53
+ try {
54
+ mtimeMs = statSync(path.join(dir, MEETING_FILE)).mtimeMs;
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ present.add(name);
60
+ const before = this.seen.get(name);
61
+ if (before && before.mtimeMs === mtimeMs)
62
+ continue;
63
+ let meeting;
64
+ try {
65
+ meeting = loadMeeting(dir);
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ const settled = isSettled(meeting);
71
+ this.seen.set(name, { mtimeMs, settled });
72
+ const wasSettled = before ? before.settled : !this.primed;
73
+ if (settled && !wasSettled)
74
+ events.push(describeSettled(meeting, dir));
75
+ }
76
+ for (const name of [...this.seen.keys()])
77
+ if (!present.has(name))
78
+ this.seen.delete(name);
79
+ this.primed = true;
80
+ return events;
81
+ }
82
+ }
83
+ export async function watchCommand(argv) {
84
+ const json = argv.includes('--json');
85
+ let interval;
86
+ let root;
87
+ try {
88
+ const args = parseArgs(argv, { values: ['interval'], maxPositionals: 0 });
89
+ interval = args.number('interval') ?? 5;
90
+ if (interval < 1 || interval > 300)
91
+ throw usageError('--interval takes seconds between 1 and 300');
92
+ root = rootDir(loadConfig());
93
+ }
94
+ catch (error) {
95
+ return output('watch', json, () => {
96
+ throw error;
97
+ });
98
+ }
99
+ if (watchDisabled())
100
+ return 0;
101
+ process.stdout.on('error', () => process.exit(0));
102
+ const watcher = new MeetingWatcher(root);
103
+ watcher.tick();
104
+ for (;;) {
105
+ await sleep(interval * 1000);
106
+ for (const event of watcher.tick())
107
+ process.stdout.write(`${json ? JSON.stringify(event) : event.message}\n`);
108
+ }
109
+ }
@@ -12,6 +12,7 @@ import { proposalsCommand } from "./commands/proposals.js";
12
12
  import { listCommand, processCommand, promptCommand, saveSummaryCommand, showCommand, waitCommand } from "./commands/query.js";
13
13
  import { discardCommand, importCommand, startCommand, statusCommand, stopCommand } from "./commands/recording.js";
14
14
  import { setupCommand } from "./commands/setup.js";
15
+ import { watchCommand } from "./commands/watch.js";
15
16
  import { output } from "./output.js";
16
17
  async function capabilitiesCommand(argv) {
17
18
  return output('capabilities', argv.includes('--json'), async () => {
@@ -42,6 +43,7 @@ const COMMANDS = {
42
43
  ask: askCommand,
43
44
  proposals: proposalsCommand,
44
45
  config: configCommand,
46
+ watch: watchCommand,
45
47
  'live-worker': liveWorkerCommand,
46
48
  capabilities: capabilitiesCommand,
47
49
  };
@@ -64,6 +66,7 @@ export function help() {
64
66
  ' recap proposals ls|show|accept|reject <meeting> [n] [--bita-entry id] [--md file]',
65
67
  ' recap compress-video <meeting> --preset light|medium|max | strip-video <meeting> | prune <meeting> --intermediates | delete <meeting>',
66
68
  ' recap config get [key] | config set <key> <value>',
69
+ ' recap watch [--interval s]: one line per meeting that finishes processing; RECAP_MONITOR=off turns it off',
67
70
  ' recap setup [--install-deps] [--skip-models] [--skip-permissions] [--skip-bita] [--skip-app] [--agents detected|all|none|claude,opencode,codex,gemini]',
68
71
  ' recap capabilities',
69
72
  '',
@@ -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, docsRoot, bita) {
56
- const context = emptyContext({ project: project && trimmed(project).length > 0 ? project : null, docsRoot: docsRoot ?? null });
57
- if (!bita)
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
- const pages = await bita.invoke(['docs', 'page', 'ls', '--project', context.project]).catch(() => null);
61
- if (pages?.ok) {
62
- context.pages = parsePages(pages.data);
63
- context.docsRoot ??= metaRoot(pages.meta);
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 bita.invoke(['docs', 'tree']).catch(() => null);
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, bita) {
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
- const pageId = meeting.wrapup?.pageId ?? meeting.bitaEntry?.pageIds[0];
84
- if (!bita || pageId === undefined)
85
- return null;
86
- const response = await bita.invoke(['docs', 'page', 'show', String(pageId), '--no-markdown']).catch(() => null);
87
- if (!response?.ok || !isRecord(response.data))
99
+ if (!inkwell)
88
100
  return null;
89
- return typeof response.data['projectName'] === 'string' ? response.data['projectName'] : null;
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
  }
@@ -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 { loadProjectContext, meetingProject } from "./context.js";
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: async () => {
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 { docsClient, meetingTarget, requireBita } from "../bita/client.js";
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 target = meetingTarget(meeting);
272
- const docs = (await docsClient(this.config, target)) ?? (await requireBita(this.config, target));
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
  }