create-pmos 0.77.0 → 0.78.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +32 -3
- package/workspace/lib/notes-audio.js +41 -11
- package/workspace/lib/notes-diarize.py +61 -0
- package/workspace/lib/notes-final.js +293 -0
- package/workspace/lib/notes.js +268 -29
- package/workspace/lib/routes-notes.js +175 -8
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +15 -0
- package/workspace/public/pm/view-notes.js +159 -7
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-timings.json +1 -0
|
@@ -60,6 +60,7 @@ const notes = require('./notes.js');
|
|
|
60
60
|
const whisper = require('./notes-whisper.js');
|
|
61
61
|
const notesRepo = require('./notes-repo.js');
|
|
62
62
|
const notesAudio = require('./notes-audio.js');
|
|
63
|
+
const notesFinal = require('./notes-final.js');
|
|
63
64
|
const notesLinks = require('./notes-links.js');
|
|
64
65
|
const jevNotes = require('./jev-notes.js');
|
|
65
66
|
const tsSettings = require('./typesafe-settings.js');
|
|
@@ -73,7 +74,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
73
74
|
const prefixes = ['/api/notes'];
|
|
74
75
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
75
76
|
'/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
76
|
-
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link', '/api/notes/bullet']);
|
|
77
|
+
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link', '/api/notes/bullet']);
|
|
77
78
|
|
|
78
79
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
79
80
|
const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
|
|
@@ -304,7 +305,8 @@ function engineInfo(ctx, w) {
|
|
|
304
305
|
const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
|
|
305
306
|
language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
|
|
306
307
|
model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })),
|
|
307
|
-
accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir)
|
|
308
|
+
accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir),
|
|
309
|
+
final: finalInfo(ctx), wispr: wisprOn(ctx) };
|
|
308
310
|
if (!w) return { whisper: out, jev: null };
|
|
309
311
|
const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
|
|
310
312
|
const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
|
|
@@ -312,6 +314,134 @@ function engineInfo(ctx, w) {
|
|
|
312
314
|
return { whisper: out, jev: { ...info, paused: jevNotes.pauseStatus(w.root) } };
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
/* ── the final pass and Wispr Flow's notes (meeting-notes Run 9) ───────────────────────────────────── */
|
|
318
|
+
/* AFTER STOP. The summary the page asks for after Stop (prefer: 'agent') queues the final pass (lib/notes-final.js) when an
|
|
319
|
+
* engine is set up and the note kept audio: the whole meeting is transcribed again, the speakers are told apart, and then
|
|
320
|
+
* the PM's agent session writes the notes from that transcript (startAgent). Without an engine or audio, the agent writes
|
|
321
|
+
* them from the live transcript at once, as before. Listen again drops a queued pass; the next Stop queues a new one.
|
|
322
|
+
* The audio of each source is written for the pass even when the PM keeps no audio for replay; then it is deleted when the
|
|
323
|
+
* pass ends. PMOS_NOTES_FINAL (or ctx.notesFinal) 'off' skips the pass.
|
|
324
|
+
*
|
|
325
|
+
* WISPR FLOW. When the PM uses Wispr Flow in the same meeting, its notes are fetched after Stop: WISPR_DELAY_MS later the
|
|
326
|
+
* PM's agent session is asked to find the meeting with the Wispr Flow connector and copy its summary (notes.wisprHandoff);
|
|
327
|
+
* while it is not ready, it is asked again every WISPR_RETRY_MS, up to WISPR_DEADLINE_MS after Stop. The workspace itself
|
|
328
|
+
* makes no request: the agent session does, with the connector the PM signed in to. On by default ("Fetch Wispr Flow
|
|
329
|
+
* notes" in the settings); PMOS_NOTES_WISPR (or ctx.notesWispr) 'off' turns it off on this machine. Never for a private note. */
|
|
330
|
+
const finalOff = (ctx) => String(ctx.notesFinal !== undefined ? ctx.notesFinal : process.env.PMOS_NOTES_FINAL || '') === 'off';
|
|
331
|
+
let finalRunner = null; let finalDir = null;
|
|
332
|
+
function finalFor(ctx) {
|
|
333
|
+
if (finalRunner && finalDir === ctx.runtimeDir) return finalRunner;
|
|
334
|
+
finalDir = ctx.runtimeDir;
|
|
335
|
+
finalRunner = notesFinal.createFinal({
|
|
336
|
+
runtimeDir: ctx.runtimeDir,
|
|
337
|
+
...(ctx.notesFinalRun ? { run: ctx.notesFinalRun } : {}),
|
|
338
|
+
...(ctx.notesFinalDetect ? { detect: ctx.notesFinalDetect } : {}),
|
|
339
|
+
onState: (id, st) => { if (st.state !== 'done') notes.setFinalState(ctx.runtimeDir, { id, state: st }); },
|
|
340
|
+
onDone: (id, final) => {
|
|
341
|
+
const dir = ctx.runtimeDir;
|
|
342
|
+
const r = notes.setFinal(dir, { id, final });
|
|
343
|
+
if (!notesAudio.getKeep(dir)) notesAudio.removeSources(dir, id);
|
|
344
|
+
if (!r.ok) return;
|
|
345
|
+
const cur = notes.read(dir, id);
|
|
346
|
+
const w = cur.ok && cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
347
|
+
startAgent(ctx, dir, id, w && w.id === cur.note.ws ? w : null);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return finalRunner;
|
|
351
|
+
}
|
|
352
|
+
function finalInfo(ctx) {
|
|
353
|
+
const d = ctx.notesFinalDetect ? ctx.notesFinalDetect() : notesFinal.detect(ctx.runtimeDir);
|
|
354
|
+
return { ready: !!d.ready && !finalOff(ctx), transcriber: d.transcriber || null, vad: !!d.vad, speakers: !!d.diarize, missing: d.missing || [], setup: d.setup || '' };
|
|
355
|
+
}
|
|
356
|
+
/** Queue the final pass of a note when it can run. → { queued, reason } */
|
|
357
|
+
function queueFinal(ctx, id) {
|
|
358
|
+
const dir = ctx.runtimeDir;
|
|
359
|
+
const cur = notes.read(dir, id);
|
|
360
|
+
if (!cur.ok || cur.note.private) return { queued: false, reason: 'this note is marked private' };
|
|
361
|
+
if (finalOff(ctx)) return { queued: false, reason: 'the final pass is switched off on this machine' };
|
|
362
|
+
const info = finalInfo(ctx);
|
|
363
|
+
if (!info.ready) return { queued: false, reason: 'local transcription is not set up' };
|
|
364
|
+
if (!notesAudio.sources(dir, id).length) return { queued: false, reason: 'no audio was kept for this note' };
|
|
365
|
+
const prompt = whisper.promptFor(vocabFor(ctx, id), '');
|
|
366
|
+
finalFor(ctx).queue(id, { prompt });
|
|
367
|
+
notes.setFinalState(dir, { id, state: { state: 'queued' } });
|
|
368
|
+
return { queued: true, reason: '' };
|
|
369
|
+
}
|
|
370
|
+
function cancelFinal(ctx, id) { if (finalRunner) finalRunner.cancel(id); }
|
|
371
|
+
|
|
372
|
+
const WISPR_DELAY_MS = 5 * 60 * 1000;
|
|
373
|
+
const WISPR_RETRY_MS = 2 * 60 * 1000;
|
|
374
|
+
const WISPR_DEADLINE_MS = 20 * 60 * 1000;
|
|
375
|
+
const numOf = (v, d, min = 50) => { const n = Number(v); return Number.isFinite(n) && n >= min ? n : d; };
|
|
376
|
+
const wisprDelay = (ctx) => numOf(ctx.notesWisprDelayMs !== undefined ? ctx.notesWisprDelayMs : process.env.PMOS_NOTES_WISPR_DELAY_MS, WISPR_DELAY_MS);
|
|
377
|
+
const wisprRetry = (ctx) => numOf(ctx.notesWisprRetryMs !== undefined ? ctx.notesWisprRetryMs : process.env.PMOS_NOTES_WISPR_RETRY_MS, WISPR_RETRY_MS);
|
|
378
|
+
const wisprDeadline = (ctx) => numOf(ctx.notesWisprDeadlineMs !== undefined ? ctx.notesWisprDeadlineMs : process.env.PMOS_NOTES_WISPR_DEADLINE_MS, WISPR_DEADLINE_MS);
|
|
379
|
+
const wisprSettingsFile = (dir) => path.join(dir, 'notes', 'wispr-settings.json');
|
|
380
|
+
function wisprOn(ctx) {
|
|
381
|
+
const env = String(ctx.notesWispr !== undefined ? ctx.notesWispr : process.env.PMOS_NOTES_WISPR || '');
|
|
382
|
+
if (env === 'off') return false;
|
|
383
|
+
try { return JSON.parse(fs.readFileSync(wisprSettingsFile(ctx.runtimeDir), 'utf8')).fetch !== false; } catch { return true; }
|
|
384
|
+
}
|
|
385
|
+
function setWisprOn(dir, on) {
|
|
386
|
+
const f = wisprSettingsFile(dir);
|
|
387
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
388
|
+
fs.writeFileSync(f, JSON.stringify({ fetch: !!on }), { mode: 0o600 });
|
|
389
|
+
}
|
|
390
|
+
const wisprTimers = new Map(); // note id → timer
|
|
391
|
+
function unwatchWispr(id) { const t = wisprTimers.get(id); if (t) { clearTimeout(t); wisprTimers.delete(id); } }
|
|
392
|
+
function wisprLater(ctx, id, ms, fn) {
|
|
393
|
+
unwatchWispr(id);
|
|
394
|
+
const t = setTimeout(() => { wisprTimers.delete(id); fn(); }, ms);
|
|
395
|
+
if (t.unref) t.unref();
|
|
396
|
+
wisprTimers.set(id, t);
|
|
397
|
+
}
|
|
398
|
+
/** Listening stopped: ask for Wispr Flow's notes WISPR_DELAY_MS from now. */
|
|
399
|
+
function scheduleWispr(ctx, id) {
|
|
400
|
+
const dir = ctx.runtimeDir;
|
|
401
|
+
const cur = notes.read(dir, id);
|
|
402
|
+
if (!cur.ok || cur.note.private || !cur.note.recorded_from || !wisprOn(ctx)) return;
|
|
403
|
+
if (cur.note.wispr && cur.note.wispr.state === 'found') return;
|
|
404
|
+
if (typeof ctx.spawnSession !== 'function') { notes.setWispr(dir, { id, patch: { state: 'off', reason: 'this workspace cannot start sessions' } }); return; }
|
|
405
|
+
const stop = Date.now();
|
|
406
|
+
notes.setWispr(dir, { id, patch: { state: 'waiting', due_at: new Date(stop + wisprDelay(ctx)).toISOString(), stopped_at: new Date(stop).toISOString(), tries: 0, reason: '' } });
|
|
407
|
+
wisprLater(ctx, id, wisprDelay(ctx), () => askWispr(ctx, id));
|
|
408
|
+
}
|
|
409
|
+
function askWispr(ctx, id) {
|
|
410
|
+
const dir = ctx.runtimeDir;
|
|
411
|
+
const cur = notes.read(dir, id);
|
|
412
|
+
if (!cur.ok || cur.note.private || !cur.note.wispr || listening(cur.note)) return;
|
|
413
|
+
const w0 = cur.note.wispr;
|
|
414
|
+
const stopped = Date.parse(w0.stopped_at) || Date.now();
|
|
415
|
+
const h = notes.wisprHandoff(dir, { id, until: stopped });
|
|
416
|
+
if (!h.ok) return;
|
|
417
|
+
const tries = (w0.tries || 0) + 1;
|
|
418
|
+
let session = w0.session || null;
|
|
419
|
+
const again = 'Try again: the Wispr Flow notes may be ready now. Read ' + h.handoff + ' again and write ' + h.out + ' as before.';
|
|
420
|
+
const sent = session && typeof ctx.sendToSession === 'function' && ctx.sendToSession(session, again + '\r');
|
|
421
|
+
if (!sent) {
|
|
422
|
+
const ws = cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
423
|
+
const s = ctx.spawnSession({ name: 'notes: Wispr Flow', agent: 'claude', ws: ws && ws.id === cur.note.ws ? ws.id : undefined, origin: 'notes-wispr:' + id, initialInput: h.prompt + '\r' });
|
|
424
|
+
if (!s || s.state === 'exited') { notes.setWispr(dir, { id, patch: { state: 'failed', reason: (s && s.note) || 'could not start a claude session', tries } }); return; }
|
|
425
|
+
session = s.id;
|
|
426
|
+
}
|
|
427
|
+
notes.setWispr(dir, { id, patch: { state: 'asking', tries, session, asked_at: new Date().toISOString() } });
|
|
428
|
+
wisprLater(ctx, id, wisprRetry(ctx), () => checkWispr(ctx, id));
|
|
429
|
+
}
|
|
430
|
+
function checkWispr(ctx, id) {
|
|
431
|
+
const dir = ctx.runtimeDir;
|
|
432
|
+
notes.importWispr(dir, { id });
|
|
433
|
+
const cur = notes.read(dir, id);
|
|
434
|
+
if (!cur.ok || !cur.note.wispr || cur.note.private) return;
|
|
435
|
+
const w = cur.note.wispr;
|
|
436
|
+
if (['found', 'not_found', 'no_connector', 'failed', 'off'].includes(w.state)) return;
|
|
437
|
+
const stopped = Date.parse(w.stopped_at) || 0;
|
|
438
|
+
if (Date.now() - stopped >= wisprDeadline(ctx)) {
|
|
439
|
+
notes.setWispr(dir, { id, patch: { state: 'failed', reason: 'Wispr Flow had no notes for this meeting ' + Math.round(wisprDeadline(ctx) / 60000) + ' minutes after Stop' } });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
askWispr(ctx, id);
|
|
443
|
+
}
|
|
444
|
+
|
|
315
445
|
const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
|
|
316
446
|
|
|
317
447
|
async function handle(req, res, url, ctx) {
|
|
@@ -325,6 +455,7 @@ async function handle(req, res, url, ctx) {
|
|
|
325
455
|
// the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
|
|
326
456
|
const imp = notes.importBullets(dir, { id: nid });
|
|
327
457
|
if (imp.added.length) enrich(ctx, nid, imp.added, 'bullets');
|
|
458
|
+
notes.importWispr(dir, { id: nid });
|
|
328
459
|
const r = notes.read(dir, nid);
|
|
329
460
|
if (r.ok) {
|
|
330
461
|
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid);
|
|
@@ -403,6 +534,10 @@ async function handle(req, res, url, ctx) {
|
|
|
403
534
|
// a new Listen tries the agent again when it could not start last time
|
|
404
535
|
const cur = notes.read(dir, id);
|
|
405
536
|
if (cur.ok && cur.note.minutes && cur.note.minutes.agent === 'failed') notes.setMinutesAgent(dir, { id, agent: null, reason: '' });
|
|
537
|
+
// listening again: the final pass and the Wispr Flow fetch wait for the next Stop
|
|
538
|
+
cancelFinal(ctx, id); unwatchWispr(id);
|
|
539
|
+
if (cur.ok && cur.note.final_state && cur.note.final_state.state !== 'done') notes.setFinalState(dir, { id, state: null });
|
|
540
|
+
if (cur.ok && cur.note.wispr && cur.note.wispr.state !== 'found') notes.setWispr(dir, { id, patch: null });
|
|
406
541
|
arm(ctx, id);
|
|
407
542
|
}
|
|
408
543
|
return done(r);
|
|
@@ -410,7 +545,7 @@ async function handle(req, res, url, ctx) {
|
|
|
410
545
|
if (p === '/api/notes/pause') {
|
|
411
546
|
const r = notes.pause(dir, { id });
|
|
412
547
|
// listening stopped: the minute timer stops, and what was said since the last minute is one more batch
|
|
413
|
-
if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); }
|
|
548
|
+
if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
|
|
414
549
|
return done(r);
|
|
415
550
|
}
|
|
416
551
|
if (p === '/api/notes/transcript') {
|
|
@@ -434,11 +569,21 @@ async function handle(req, res, url, ctx) {
|
|
|
434
569
|
const t = Math.max(0, notes.meetingMs(note.note) - ms);
|
|
435
570
|
// the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
|
|
436
571
|
arm(ctx, id);
|
|
437
|
-
|
|
572
|
+
// each source is also kept apart for the final pass after Stop, which deletes it when the PM keeps no audio (Run 9)
|
|
573
|
+
{ try { notesAudio.write(dir, { id, source, pcm, t, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
|
|
438
574
|
return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
|
|
439
575
|
}
|
|
440
576
|
if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
|
|
441
|
-
if (p === '/api/notes/speaker')
|
|
577
|
+
if (p === '/api/notes/speaker') {
|
|
578
|
+
if (body.key !== undefined) return done(notes.renameFinalSpeaker(dir, { id, key: String(body.key), name: body.name }));
|
|
579
|
+
return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
|
|
580
|
+
}
|
|
581
|
+
if (p === '/api/notes/final') {
|
|
582
|
+
// the PM asks for the final pass again, for example after setting up a better engine
|
|
583
|
+
if (!notes.read(dir, id).ok) return done(notes.read(dir, id));
|
|
584
|
+
const q = queueFinal(ctx, id);
|
|
585
|
+
return done(q.queued ? { ok: true, queued: true } : { ok: false, status: 409, error: q.reason });
|
|
586
|
+
}
|
|
442
587
|
if (p === '/api/notes/bullet') return done(notes.editBullet(dir, { id, bid: body.bullet, cat: body.cat, drop: body.drop, text: body.text }));
|
|
443
588
|
if (p === '/api/notes/line') {
|
|
444
589
|
const pick = (k) => (body[k] === undefined ? undefined : body[k]);
|
|
@@ -452,6 +597,13 @@ async function handle(req, res, url, ctx) {
|
|
|
452
597
|
if (p === '/api/notes/switch') {
|
|
453
598
|
const o = wsOf();
|
|
454
599
|
// the spoken language belongs to this machine, not to a repository
|
|
600
|
+
if (body.wispr !== undefined) {
|
|
601
|
+
if (typeof body.wispr !== 'boolean') { ctx.json(res, { ok: false, error: 'say wispr: true or wispr: false' }, 400); return true; }
|
|
602
|
+
setWisprOn(dir, body.wispr);
|
|
603
|
+
if (body.language === undefined && body.model === undefined && body.accuracy === undefined && body.keep_audio === undefined && body.jev === undefined && body.check === undefined) {
|
|
604
|
+
ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
455
607
|
if (body.language !== undefined || body.model !== undefined || body.accuracy !== undefined || body.keep_audio !== undefined) {
|
|
456
608
|
const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
|
|
457
609
|
const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
|
|
@@ -476,10 +628,21 @@ async function handle(req, res, url, ctx) {
|
|
|
476
628
|
ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
|
|
477
629
|
return true;
|
|
478
630
|
}
|
|
631
|
+
if (p === '/api/notes/final-setup') {
|
|
632
|
+
const o = wsOf();
|
|
633
|
+
if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
634
|
+
const command = notesFinal.setupCommand(dir);
|
|
635
|
+
if (typeof ctx.spawnSession !== 'function') { ctx.json(res, { ok: true, started: false, command, reason: 'this workspace cannot start sessions' }); return true; }
|
|
636
|
+
// typed, not run, as the Whisper setup is (D91)
|
|
637
|
+
const s = ctx.spawnSession({ name: 'notes: set up the final pass', agent: 'shell', ws: o.w ? o.w.id : undefined, origin: 'notes-final-setup', initialInput: command });
|
|
638
|
+
const started = !!s && s.state !== 'exited';
|
|
639
|
+
ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
479
642
|
if (p === '/api/notes/private') {
|
|
480
643
|
vocabCache.delete(id);
|
|
481
644
|
const r = notes.setPrivate(dir, { id, private: body.private });
|
|
482
|
-
if (r.ok && r.private) { disarm(id); unwatchLate(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
645
|
+
if (r.ok && r.private) { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
483
646
|
return done(r);
|
|
484
647
|
}
|
|
485
648
|
if (p === '/api/notes/summarize') {
|
|
@@ -498,6 +661,9 @@ async function handle(req, res, url, ctx) {
|
|
|
498
661
|
r.source = 'local';
|
|
499
662
|
if (body.prefer === 'agent') {
|
|
500
663
|
// the agent's summary is the default when an agent can be asked; the local one is already stored as the fallback
|
|
664
|
+
// with kept audio and an engine, the final pass runs first and the agent writes the notes from it (Run 9)
|
|
665
|
+
const q = cur.ok && !cur.note.private ? queueFinal(ctx, id) : { queued: false };
|
|
666
|
+
if (q.queued) { r.final = { state: 'queued' }; r.agent = { started: false, waiting: 'final', reason: '' }; r.source = 'agent'; return done(r); }
|
|
501
667
|
const ag = cur.ok && cur.note.private ? { ok: false, error: 'this note is marked private, so it is not handed to any agent or service' }
|
|
502
668
|
: startAgent(ctx, dir, id, ws.ok ? ws.w : null);
|
|
503
669
|
r.agent = ag.ok ? (({ ok, ...x }) => x)(ag) : { started: false, reason: ag.error || 'the agent could not be asked' };
|
|
@@ -505,7 +671,7 @@ async function handle(req, res, url, ctx) {
|
|
|
505
671
|
}
|
|
506
672
|
return done(r);
|
|
507
673
|
}
|
|
508
|
-
if (p === '/api/notes/delete') { disarm(id); unwatchLate(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
|
|
674
|
+
if (p === '/api/notes/delete') { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
|
|
509
675
|
// summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
|
|
510
676
|
let w = null;
|
|
511
677
|
if (body.ws != null) {
|
|
@@ -532,4 +698,5 @@ function startAgent(ctx, dir, id, w) {
|
|
|
532
698
|
return { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld };
|
|
533
699
|
}
|
|
534
700
|
|
|
535
|
-
module.exports = { prefixes, handle, enrich, runMinute, bulletsState,
|
|
701
|
+
module.exports = { prefixes, handle, enrich, runMinute, bulletsState, queueFinal, askWispr, checkWispr,
|
|
702
|
+
__idle: () => Promise.all([transcriber ? transcriber.idle() : null, finalRunner ? finalRunner.idle() : null, ...chains.values()]) };
|
package/workspace/package.json
CHANGED
|
@@ -6471,6 +6471,21 @@
|
|
|
6471
6471
|
color: var(--foreground);
|
|
6472
6472
|
font-weight: 500;
|
|
6473
6473
|
}
|
|
6474
|
+
.nt-para {
|
|
6475
|
+
margin: 0 0 8px;
|
|
6476
|
+
font-size: 14px;
|
|
6477
|
+
line-height: 20px;
|
|
6478
|
+
}
|
|
6479
|
+
.nt-quote {
|
|
6480
|
+
display: block;
|
|
6481
|
+
font-size: 12px;
|
|
6482
|
+
line-height: 18px;
|
|
6483
|
+
color: var(--muted-foreground);
|
|
6484
|
+
}
|
|
6485
|
+
.nt-topic {
|
|
6486
|
+
margin: 4px 0 8px;
|
|
6487
|
+
font-size: 14px;
|
|
6488
|
+
}
|
|
6474
6489
|
|
|
6475
6490
|
/* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
|
|
6476
6491
|
/* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
|
|
@@ -72,6 +72,8 @@ let pendingOpen = null; // a note another page asked to open (openNote), s
|
|
|
72
72
|
let transcriptOpen = { inline: false, full: true }; // the transcript is folded in the pop-up and open on the full screen
|
|
73
73
|
let inlineSwitch = null; // the note the pop-up is switching to (renderInline), so a redraw does not start it twice
|
|
74
74
|
let popped = null; // the note the pop-up shows (pm/note-dock.js), which the Notes tab then does not draw
|
|
75
|
+
let liveTranscript = false; // after the final pass, the PM can look at the live transcript instead (Run 9)
|
|
76
|
+
let wisprTimer = null;
|
|
75
77
|
const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
|
|
76
78
|
|
|
77
79
|
const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
|
|
@@ -213,7 +215,7 @@ async function loadList(ctx) {
|
|
|
213
215
|
if (!currentId && list.notes.length) currentId = list.notes[0].id;
|
|
214
216
|
ctx.setState({});
|
|
215
217
|
}
|
|
216
|
-
const sig = (n) => JSON.stringify([n.transcript, n.bullets, n.bullets_state && n.bullets_state.agent, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
|
|
218
|
+
const sig = (n) => JSON.stringify([n.final_state, n.final && n.final.at, n.final && n.final.names, n.final_notes && n.final_notes.at, n.wispr && [n.wispr.state, n.wispr.tries, n.wispr.reason], n.transcript, n.bullets, n.bullets_state && n.bullets_state.agent, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
|
|
217
219
|
async function loadNote(ctx, id) {
|
|
218
220
|
const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
|
|
219
221
|
if (r && r.ok) {
|
|
@@ -343,6 +345,7 @@ async function summarize(ctx, n, quiet = false) {
|
|
|
343
345
|
busy = '';
|
|
344
346
|
if (r && r.ok) {
|
|
345
347
|
n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null;
|
|
348
|
+
if (r.final) { n.final_state = r.final; const c = cache.get(n.id); if (c) c.final_state = r.final; pollAgent(ctx, n.id); }
|
|
346
349
|
if (r.agent && r.agent.started) { n.agent = { requested_at: new Date().toISOString(), started: true, reason: '', session: r.agent.session, withheld: r.agent.withheld, markdown: null, out: r.agent.out }; pollAgent(ctx, n.id); }
|
|
347
350
|
} else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
|
|
348
351
|
ctx.setState({});
|
|
@@ -367,11 +370,45 @@ function pollAgent(ctx, id) {
|
|
|
367
370
|
agentTimer = setTimeout(async () => {
|
|
368
371
|
agentTimer = null;
|
|
369
372
|
const n = cache.get(id);
|
|
370
|
-
if (!n || !n.agent
|
|
373
|
+
if (!n || currentId !== id || !(finalPending(n) || (n.agent && n.agent.started && !n.agent.markdown))) return;
|
|
371
374
|
const r = await loadNote(ctx, id);
|
|
372
|
-
if (r && r.ok &&
|
|
375
|
+
if (r && r.ok && (finalPending(r.note) || (r.note.agent && r.note.agent.started && !r.note.agent.markdown))) pollAgent(ctx, id);
|
|
373
376
|
}, AGENT_POLL_MS);
|
|
374
377
|
}
|
|
378
|
+
const finalPending = (n) => !!(n && n.final_state && (n.final_state.state === 'queued' || n.final_state.state === 'running'));
|
|
379
|
+
const wisprPending = (n) => !!(n && n.wispr && (n.wispr.state === 'waiting' || n.wispr.state === 'asking'));
|
|
380
|
+
/** Wispr Flow's notes come minutes after Stop: the note is read again every 20 seconds while they are awaited. */
|
|
381
|
+
function pollWispr(ctx, id) {
|
|
382
|
+
if (wisprTimer) return;
|
|
383
|
+
wisprTimer = setTimeout(async () => {
|
|
384
|
+
wisprTimer = null;
|
|
385
|
+
const n = cache.get(id);
|
|
386
|
+
if (!n || currentId !== id || !wisprPending(n)) return;
|
|
387
|
+
const r = await loadNote(ctx, id);
|
|
388
|
+
if (r && r.ok && wisprPending(r.note)) pollWispr(ctx, id);
|
|
389
|
+
}, 20000);
|
|
390
|
+
}
|
|
391
|
+
async function runFinal(ctx, n) {
|
|
392
|
+
const r = await pmPost('/api/notes/final', { id: n.id });
|
|
393
|
+
if (r && r.ok) { n.final_state = { state: 'queued' }; pollAgent(ctx, n.id); } else banner = { tone: 'bad', text: (r && r.error) || 'the final pass did not start' };
|
|
394
|
+
ctx.setState({});
|
|
395
|
+
}
|
|
396
|
+
async function finalSetup(ctx) {
|
|
397
|
+
const ws = wsOf(ctx);
|
|
398
|
+
const r = await pmPost('/api/notes/final-setup', ws ? { ws } : {});
|
|
399
|
+
banner = r && r.ok && r.started
|
|
400
|
+
? { tone: 'ok', text: 'A terminal opened with the setup commands typed in. Read them and press Enter there. The downloads are about 3 GB. Then open Settings again.' }
|
|
401
|
+
: { tone: 'bad', text: 'No terminal opened' + (r && r.reason ? ': ' + r.reason : '') + '. Run this in a terminal: ' + ((r && r.command) || '') };
|
|
402
|
+
ctx.setState({});
|
|
403
|
+
}
|
|
404
|
+
async function renameFinalSpeaker(ctx, n, key, now) {
|
|
405
|
+
const name = await askText({ title: 'Name this speaker', label: 'Name', value: now, placeholder: now, confirmLabel: 'Save' });
|
|
406
|
+
if (name == null) return;
|
|
407
|
+
const r = await pmPost('/api/notes/speaker', { id: n.id, key, name });
|
|
408
|
+
// the note may have been read again since this button was drawn, so read it once more rather than change a stale copy
|
|
409
|
+
if (r && r.ok) { await loadNote(ctx, n.id); } else banner = { tone: 'bad', text: (r && r.error) || 'not renamed' };
|
|
410
|
+
ctx.setState({});
|
|
411
|
+
}
|
|
375
412
|
async function copyMarkdown(ctx, n) {
|
|
376
413
|
const text = noteMarkdown({ ...n, title: editor ? editor.title.value : n.title, body: editor ? editor.body.value : n.body });
|
|
377
414
|
try { await navigator.clipboard.writeText(text); banner = { tone: 'ok', text: 'The note is on the clipboard as Markdown.' }; }
|
|
@@ -758,6 +795,22 @@ function openSettings(ctx, n) {
|
|
|
758
795
|
w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
|
|
759
796
|
? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
|
|
760
797
|
: 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null));
|
|
798
|
+
const fp = w && w.final;
|
|
799
|
+
if (fp) {
|
|
800
|
+
const have = [fp.transcriber === 'parakeet' ? 'Parakeet' : fp.transcriber === 'whisper' ? 'Whisper with beam search' : null,
|
|
801
|
+
fp.vad ? 'speech detection' : null, fp.speakers ? 'speaker separation' : null].filter(Boolean);
|
|
802
|
+
parts.push(el('div', { class: 'nt-set', 'data-nt-final-set': fp.ready ? 'ready' : 'missing' },
|
|
803
|
+
el('div', { class: 'nt-set__head' }, 'Final notes after Stop'),
|
|
804
|
+
el('p', { class: 'nt-hint' }, fp.ready
|
|
805
|
+
? 'After Stop, the whole meeting is transcribed again on this machine (' + have.join(', ') + '), and your agent session writes the notes from it.'
|
|
806
|
+
: 'Set up local transcription first. After Stop, the whole meeting is then transcribed again on this machine, and your agent session writes the notes from it.'),
|
|
807
|
+
fp.missing.length ? el('p', { class: 'nt-hint', 'data-nt-final-missing': '' }, 'For better notes, set up ' + fp.missing.map((m) => (m === 'parakeet' ? 'Parakeet (more accurate in English)' : m === 'vad' ? 'speech detection' : 'speaker separation')).join(', ')
|
|
808
|
+
+ '. The setup installs them into the workspace\'s own folder; the downloads are about 3 GB.') : null,
|
|
809
|
+
fp.missing.length ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); finalSetup(ctx); }, attrs: { 'data-nt-final-setup': '' } }) : null,
|
|
810
|
+
field({ label: 'Fetch Wispr Flow notes after the meeting', kind: 'check', value: w.wispr !== false,
|
|
811
|
+
onCommit: (v) => setSwitch(ctx, { wispr: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-wispr-switch': '' } }),
|
|
812
|
+
el('p', { class: 'nt-hint' }, 'Five minutes after Stop, your agent session looks for the same meeting in Wispr Flow and copies its notes into this note. It needs the Wispr Flow connector in Claude Code.')));
|
|
813
|
+
}
|
|
761
814
|
if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
|
|
762
815
|
else {
|
|
763
816
|
const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
|
|
@@ -772,6 +825,7 @@ function openSettings(ctx, n) {
|
|
|
772
825
|
el('div', { class: 'nt-row' },
|
|
773
826
|
button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
|
|
774
827
|
button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
|
|
828
|
+
n.__recording || (n.final_state && n.final_state.state === 'failed') ? button({ label: 'Transcribe again', disabled: n.private || finalPending(n), onClick: () => { h.close(); runFinal(ctx, n); }, attrs: { 'data-nt-final-again': '' } }) : null,
|
|
775
829
|
n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
|
|
776
830
|
button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
|
|
777
831
|
return parts;
|
|
@@ -1035,6 +1089,75 @@ function itemRow(ctx, n, key, title, x) {
|
|
|
1035
1089
|
: key === 'fact' || key === 'tagged' || key === 'point' ? null
|
|
1036
1090
|
: iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } }));
|
|
1037
1091
|
}
|
|
1092
|
+
/* ── the final notes, the final transcript and Wispr Flow's notes (Run 9) ────────────────────────────── */
|
|
1093
|
+
function finalSpeakerOf(n, key) {
|
|
1094
|
+
const nm = n.final && n.final.names && n.final.names[key];
|
|
1095
|
+
if (nm) return nm;
|
|
1096
|
+
if (key === 'mic' || key === 'meeting') return speakerOf(n, key);
|
|
1097
|
+
const m = /^(?:mic|meeting)-(\d+)$/.exec(String(key || ''));
|
|
1098
|
+
return m ? 'Speaker ' + m[1] : 'Speaker';
|
|
1099
|
+
}
|
|
1100
|
+
function timeOf(ctx, n, t, k) {
|
|
1101
|
+
if (typeof t !== 'number') return el('span', { class: 'nt-at' }, '');
|
|
1102
|
+
return n.__recording && !listening(n)
|
|
1103
|
+
? button({ label: fmtTime(t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, t), attrs: { 'data-nt-seek-item': k, title: 'Play from here' } })
|
|
1104
|
+
: el('span', { class: 'nt-at' }, fmtTime(t));
|
|
1105
|
+
}
|
|
1106
|
+
function finalItem(ctx, n, sec, x, i) {
|
|
1107
|
+
const extra = [x.owner ? ' · ' + x.owner : '', x.due ? ' · ' + x.due : ''].join('');
|
|
1108
|
+
return el('li', { class: 'nt-item', 'data-nt-final-item': sec + '-' + i }, timeOf(ctx, n, x.t, sec + '-' + i),
|
|
1109
|
+
el('span', { class: 'nt-item__text' }, x.text, extra ? el('span', { class: 'nt-who' }, extra) : null, el('span', { class: 'nt-quote', 'data-nt-quote': '' }, 'Said: ' + x.quote)));
|
|
1110
|
+
}
|
|
1111
|
+
function finalNotesSections(ctx, n) {
|
|
1112
|
+
const f = n.final_notes;
|
|
1113
|
+
const from = f.from === 'final' ? ' · by your agent session, from the whole recording' : ' · by your agent session';
|
|
1114
|
+
const out = [el('section', { class: 'nt-sec', 'data-nt-summary': 'final' }, el('h3', { class: 'nt-sec__h' }, 'Overview', el('span', { class: 'nt-src' }, from)),
|
|
1115
|
+
f.purpose ? el('p', { class: 'nt-para', 'data-nt-purpose': '' }, f.purpose) : null, f.overview ? el('p', { class: 'nt-para' }, f.overview) : null)];
|
|
1116
|
+
const list = (key, title, items) => el('section', { class: 'nt-sec', 'data-nt-sec': key }, el('h3', { class: 'nt-sec__h' }, title),
|
|
1117
|
+
items.length ? el('ul', { class: 'nt-items' }, items.map((x, i) => finalItem(ctx, n, key, x, i))) : el('p', { class: 'nt-hint' }, 'None.'));
|
|
1118
|
+
out.push(list('decision', 'Decisions', f.decisions || []), list('action', 'Next steps', f.actions || []), list('question', 'Open questions', f.questions || []));
|
|
1119
|
+
if ((f.topics || []).length) {
|
|
1120
|
+
out.push(el('details', { class: 'nt-sec', 'data-nt-topics': '' }, el('summary', { class: 'nt-sec__h nt-fold' }, 'Topics', el('span', { class: 'nt-src' }, ' · ' + f.topics.length)),
|
|
1121
|
+
f.topics.map((tp) => el('div', { class: 'nt-topic' }, el('b', null, tp.title), el('ul', { class: 'nt-items' }, tp.points.map((p) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, p))))))));
|
|
1122
|
+
}
|
|
1123
|
+
if (f.dropped) out.push(el('p', { class: 'nt-hint', 'data-nt-dropped': '' }, f.dropped + (f.dropped === 1 ? ' item was' : ' items were') + ' left out: no quote from the transcript supports ' + (f.dropped === 1 ? 'it.' : 'them.')));
|
|
1124
|
+
return out;
|
|
1125
|
+
}
|
|
1126
|
+
function finalStateHint(ctx, n) {
|
|
1127
|
+
const st = n.final_state;
|
|
1128
|
+
if (!st || listening(n)) return null;
|
|
1129
|
+
if (st.state === 'queued' || st.state === 'running') {
|
|
1130
|
+
return el('p', { class: 'nt-hint', 'data-nt-final-wait': st.state, role: 'status' }, 'Writing the final notes: ' + (st.step || 'waiting to transcribe the meeting again') + '. Until then, these are the notes taken live.');
|
|
1131
|
+
}
|
|
1132
|
+
if (st.state === 'failed') return el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-final-failed': '' }, 'The final notes were not written: ' + (st.reason || 'no reason given') + '. These are the notes taken live.');
|
|
1133
|
+
if (st.state === 'done' && n.agent && n.agent.started && !n.agent.markdown) return el('p', { class: 'nt-hint', 'data-nt-agent-wait': '' }, 'The meeting is transcribed again. Your agent session is writing the notes.');
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
const WISPR_WORDS = {
|
|
1137
|
+
waiting: 'PMOS asks Wispr Flow for its notes of this meeting five minutes after Stop.',
|
|
1138
|
+
asking: 'Your agent session is looking for this meeting in Wispr Flow.',
|
|
1139
|
+
not_ready: 'Wispr Flow has no notes for this meeting yet. PMOS asks again.',
|
|
1140
|
+
not_found: 'Wispr Flow has no meeting at this time.',
|
|
1141
|
+
no_connector: 'Your agent session has no Wispr Flow connector. Add it in Claude Code to fetch Wispr Flow notes.',
|
|
1142
|
+
};
|
|
1143
|
+
function wisprSection(ctx, n) {
|
|
1144
|
+
const w = n.wispr;
|
|
1145
|
+
if (!w || w.state === 'off' || n.private) return null;
|
|
1146
|
+
if (wisprPending(n)) pollWispr(ctx, n.id);
|
|
1147
|
+
const body = [];
|
|
1148
|
+
if (w.state === 'found') {
|
|
1149
|
+
if (w.meeting && w.meeting.title) body.push(el('p', { class: 'nt-hint' }, w.meeting.title + (w.meeting.start ? ' · ' + dayOf(w.meeting.start) + ' ' + nowClock(w.meeting.start) : '')));
|
|
1150
|
+
let blocks = [];
|
|
1151
|
+
try { blocks = mdRender(w.summary || '').blocks || []; } catch { blocks = [el('div', { class: 'nt-pre' }, w.summary || '')]; }
|
|
1152
|
+
body.push(el('div', { class: 'nt-md', 'data-nt-wispr-summary': '' }, blocks));
|
|
1153
|
+
if (w.meeting && w.meeting.share_link) {
|
|
1154
|
+
const link = w.meeting.share_link;
|
|
1155
|
+
body.push(button({ label: 'Open in Wispr Flow', variant: 'ghost', size: 'sm', onClick: () => { try { window.open(link, '_blank', 'noopener'); } catch { /* blocked */ } }, attrs: { 'data-nt-wispr-open': '' } }));
|
|
1156
|
+
}
|
|
1157
|
+
} else body.push(el('p', { class: 'nt-hint', 'data-nt-wispr-state': w.state }, WISPR_WORDS[w.state] || ('Wispr Flow notes were not fetched: ' + (w.reason || 'no reason given') + '.')));
|
|
1158
|
+
return el('details', { class: 'nt-sec', 'data-nt-part': 'wispr', open: w.state === 'found' ? null : '' },
|
|
1159
|
+
el('summary', { class: 'nt-sec__h nt-fold' }, 'Wispr Flow notes', el('span', { class: 'nt-src' }, w.state === 'found' ? ' · fetched by your agent session' : '')), body);
|
|
1160
|
+
}
|
|
1038
1161
|
function sectionOf(ctx, n, key, title, items, extra) {
|
|
1039
1162
|
if (!items.length && !extra) return null;
|
|
1040
1163
|
return el('section', { class: 'nt-sec', 'data-nt-sec': key },
|
|
@@ -1106,7 +1229,12 @@ function noteColumn(ctx, n, ed) {
|
|
|
1106
1229
|
const bs = n.bullets_state;
|
|
1107
1230
|
const parts = [];
|
|
1108
1231
|
if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
|
|
1109
|
-
if (live
|
|
1232
|
+
if (!live && n.final_notes) {
|
|
1233
|
+
// the notes written after Stop replace the live draft (Run 9)
|
|
1234
|
+
parts.push(...finalNotesSections(ctx, n), myNotes(ctx, n, ed), wisprSection(ctx, n));
|
|
1235
|
+
return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
|
|
1236
|
+
}
|
|
1237
|
+
if (live) parts.push(myNotes(ctx, n, ed)); else parts.push(finalStateHint(ctx, n), summarySection(ctx, n));
|
|
1110
1238
|
const each = bs && bs.agent === 'on' ? el('span', { class: 'nt-src', 'data-nt-each-minute': '' }, ' · each minute') : null;
|
|
1111
1239
|
const secs = [sectionOf(ctx, n, 'point', 'Key points', c.point, c.point.length || each ? each : null)];
|
|
1112
1240
|
for (const [k, t] of SECTIONS) secs.push(sectionOf(ctx, n, k, t, c[k]));
|
|
@@ -1117,12 +1245,35 @@ function noteColumn(ctx, n, ed) {
|
|
|
1117
1245
|
else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening … Key points, decisions, next steps and questions appear here as they are said.' : (n.transcript || []).length ? 'Nothing was caught as a decision, next step or question.'
|
|
1118
1246
|
: 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
|
|
1119
1247
|
if (bs && bs.agent === 'failed' && bs.reason) parts.push(el('p', { class: 'nt-hint', 'data-nt-bullets-failed': '' }, 'Key points are not written each minute: ' + bs.reason + '. Lines are labelled on this machine.'));
|
|
1120
|
-
if (!live) parts.push(myNotes(ctx, n, ed));
|
|
1248
|
+
if (!live) parts.push(myNotes(ctx, n, ed), wisprSection(ctx, n));
|
|
1121
1249
|
return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
|
|
1122
1250
|
}
|
|
1251
|
+
function finalTranscript(ctx, n, opts) {
|
|
1252
|
+
const segs = n.final.segments;
|
|
1253
|
+
const typed = (n.transcript || []).filter((x) => x.source === 'typed' && !x.dropped).map((x) => ({ t: x.t, text: x.text, typed: true }));
|
|
1254
|
+
const rows = [...segs.map((g, i) => ({ ...g, i })), ...typed].sort((a, b) => (a.t || 0) - (b.t || 0));
|
|
1255
|
+
const lines = rows.map((x) => {
|
|
1256
|
+
const who = x.typed ? 'You, typed' : finalSpeakerOf(n, x.spk);
|
|
1257
|
+
const spk = x.typed ? el('span', { class: 'nt-spk nt-spk--typed', 'data-nt-spk': 'typed' }, who)
|
|
1258
|
+
: button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameFinalSpeaker(ctx, n, x.spk, who), attrs: { 'data-nt-final-spk': x.spk, title: 'Name this speaker' } });
|
|
1259
|
+
const at = n.__recording && typeof x.t === 'number'
|
|
1260
|
+
? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek-final': String(x.i), title: 'Play from here' } })
|
|
1261
|
+
: el('span', { class: 'nt-at' }, fmtTime(x.t));
|
|
1262
|
+
return el('div', { class: 'nt-ln', 'data-nt-final-seg': x.typed ? null : String(x.i) }, el('div', { class: 'nt-ln__meta' }, at, spk), el('div', null, x.text));
|
|
1263
|
+
});
|
|
1264
|
+
const box = el('div', { class: 'nt-lines', 'data-nt-transcript': 'final' }, lines);
|
|
1265
|
+
const where = opts.inline ? 'inline' : 'full';
|
|
1266
|
+
const how = [n.final.engine === 'parakeet' ? 'Parakeet' : 'Whisper', n.final.diarized ? 'speakers told apart' : null].filter(Boolean).join(', ');
|
|
1267
|
+
const d = el('details', { class: 'nt-trans', 'data-nt-part': 'transcript', open: transcriptOpen[where] },
|
|
1268
|
+
el('summary', { class: 'nt-sec__h nt-fold' }, 'Transcript', el('span', { class: 'nt-src' }, ' · from the whole recording (' + how + '), ' + rows.length + (rows.length === 1 ? ' line' : ' lines'))),
|
|
1269
|
+
button({ label: 'Show the live transcript', variant: 'ghost', size: 'sm', onClick: () => { liveTranscript = true; ctx.setState({}); }, attrs: { 'data-nt-live-transcript': '' } }), box);
|
|
1270
|
+
d.addEventListener('toggle', () => { transcriptOpen[where] = d.open; });
|
|
1271
|
+
return d;
|
|
1272
|
+
}
|
|
1123
1273
|
function transcriptColumn(ctx, n, opts = {}) {
|
|
1124
1274
|
const tr = n.transcript || [];
|
|
1125
1275
|
const live = listening(n);
|
|
1276
|
+
if (!live && !liveTranscript && n.final && (n.final.segments || []).length) return finalTranscript(ctx, n, opts);
|
|
1126
1277
|
const lines = tr.map((x, i) => {
|
|
1127
1278
|
if (x.source === 'typed' && x.dropped) return null; // a note line the PM removed
|
|
1128
1279
|
const lab = x.cat && x.cat !== 'small talk' ? el('span', { class: 'nt-lab', 'data-nt-cat': x.cat }, x.cat) : null;
|
|
@@ -1148,7 +1299,8 @@ function transcriptColumn(ctx, n, opts = {}) {
|
|
|
1148
1299
|
const shownN = tr.filter((x) => !(x.source === 'typed' && x.dropped)).length;
|
|
1149
1300
|
const where = opts.inline ? 'inline' : 'full';
|
|
1150
1301
|
const d = el('details', { class: 'nt-trans', 'data-nt-part': 'transcript', open: transcriptOpen[where] },
|
|
1151
|
-
el('summary', { class: 'nt-sec__h nt-fold' }, 'Transcript', el('span', { class: 'nt-src' }, ' · ' + shownN + (shownN === 1 ? ' line' : ' lines') + (live ? ' so far' : ''))),
|
|
1302
|
+
el('summary', { class: 'nt-sec__h nt-fold' }, 'Transcript', el('span', { class: 'nt-src' }, ' · ' + shownN + (shownN === 1 ? ' line' : ' lines') + (live ? ' so far' : ''))),
|
|
1303
|
+
!live && n.final && (n.final.segments || []).length ? button({ label: 'Show the final transcript', variant: 'ghost', size: 'sm', onClick: () => { liveTranscript = false; ctx.setState({}); }, attrs: { 'data-nt-final-transcript': '' } }) : null, box);
|
|
1152
1304
|
d.addEventListener('toggle', () => { transcriptOpen[where] = d.open; if (d.open) box.scrollTop = box.scrollHeight; });
|
|
1153
1305
|
if (live && transcriptOpen[where]) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
|
|
1154
1306
|
return d;
|
|
@@ -1190,7 +1342,7 @@ export function renderBoard(inner, ctx) {
|
|
|
1190
1342
|
el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
|
|
1191
1343
|
wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
|
|
1192
1344
|
if (pl) queueMicrotask(paintNow);
|
|
1193
|
-
if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
|
|
1345
|
+
if (((n.agent && n.agent.started && !n.agent.markdown) || finalPending(n)) && !agentTimer) pollAgent(ctx, n.id);
|
|
1194
1346
|
}
|
|
1195
1347
|
inner.appendChild(wrap);
|
|
1196
1348
|
keep();
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "meeting-notes Runs 1
|
|
2
|
+
"_comment": "meeting-notes Runs 1 to 9: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), and the Notes view in a browser (pm-notes-test.js).",
|
|
3
3
|
"modules": [
|
|
4
4
|
"notes.test.js",
|
|
5
|
-
"notes-listen.test.js"
|
|
5
|
+
"notes-listen.test.js",
|
|
6
|
+
"notes-final.test.js"
|
|
6
7
|
],
|
|
7
8
|
"panels": [
|
|
8
9
|
"pm-notes-test.js"
|
|
@@ -10,6 +11,7 @@
|
|
|
10
11
|
"seconds": {
|
|
11
12
|
"notes.test.js": 12,
|
|
12
13
|
"pm-notes-test.js": 40,
|
|
13
|
-
"notes-listen.test.js": 10
|
|
14
|
+
"notes-listen.test.js": 10,
|
|
15
|
+
"notes-final.test.js": 6
|
|
14
16
|
}
|
|
15
17
|
}
|