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.
@@ -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
- if (notesAudio.getKeep(dir)) { try { notesAudio.write(dir, { id, source, pcm, t }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
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') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
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, __idle: () => Promise.all([transcriber ? transcriber.idle() : null, ...chains.values()]) };
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()]) };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.77.0",
3
+ "version": "0.78.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 || n.agent.markdown || currentId !== id) return;
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 && !(r.note.agent && r.note.agent.markdown)) pollAgent(ctx, id);
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) parts.push(myNotes(ctx, n, ed)); else parts.push(summarySection(ctx, n));
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' : ''))), box);
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 and 2: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), and the Notes view in a browser (pm-notes-test.js).",
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
  }
@@ -51,6 +51,7 @@
51
51
  "measurements.test.js": 1,
52
52
  "next-step-actions.test.js": 11,
53
53
  "no-network.test.js": 1,
54
+ "notes-final.test.js": 2,
54
55
  "notes-listen.test.js": 2,
55
56
  "notes.test.js": 5,
56
57
  "online-mode.test.js": 1,