create-pmos 0.76.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.
@@ -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,5 +1,5 @@
1
1
  /* lucide-sprite.js — GENERATED by workspace/third-party/build-lucide-sprite.js from lucide-static 1.52.0.
2
- * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock, bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud, share-2.
2
+ * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock, bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud, share-2, house, target, square-kanban, file-text, square-terminal, calendar, chart-column.
3
3
  *
4
4
  * ISC License
5
5
  *
@@ -94,5 +94,12 @@ export const SPRITE = '<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true
94
94
  + '<symbol id="lucide-git-compare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M13 6h3a2 2 0 0 1 2 2v7" /><path d="M11 18H8a2 2 0 0 1-2-2V9" /></symbol>'
95
95
  + '<symbol id="lucide-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>'
96
96
  + '<symbol id="lucide-share-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /><line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>'
97
+ + '<symbol id="lucide-house" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" /><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></symbol>'
98
+ + '<symbol id="lucide-target" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><circle cx="12" cy="12" r="6" /><circle cx="12" cy="12" r="2" /></symbol>'
99
+ + '<symbol id="lucide-square-kanban" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M8 7v7" /><path d="M12 7v4" /><path d="M16 7v9" /></symbol>'
100
+ + '<symbol id="lucide-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /><path d="M14 2v5a1 1 0 0 0 1 1h5" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" /></symbol>'
101
+ + '<symbol id="lucide-square-terminal" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 11 2-2-2-2" /><path d="M11 13h4" /><rect width="18" height="18" x="3" y="3" rx="2" ry="2" /></symbol>'
102
+ + '<symbol id="lucide-calendar" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>'
103
+ + '<symbol id="lucide-chart-column" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v16a2 2 0 0 0 2 2h16" /><path d="M18 17V9" /><path d="M13 17V5" /><path d="M8 17v-3" /></symbol>'
97
104
  + '</svg>';
98
- export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock","bold","italic","code","link","type","heading","list","list-checks","text-quote","file-code","git-compare","cloud","share-2"];
105
+ export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock","bold","italic","code","link","type","heading","list","list-checks","text-quote","file-code","git-compare","cloud","share-2","house","target","square-kanban","file-text","square-terminal","calendar","chart-column"];
@@ -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,
@@ -12,7 +12,7 @@ The workspace runs with no network (D62). Two third-party browser files are comm
12
12
  | Package | Version | Licence | Tarball | Tarball sha256 | Shipped file |
13
13
  |---|---|---|---|---|---|
14
14
  | sortablejs | 1.15.7 | MIT | https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz | `66cbfc8fa18860783584fe0c346baf6cbf3399da72d23d21053425ba232abed4` | `public/vendor/Sortable.min.js`: an unmodified copy of `package/Sortable.min.js` (45 478 bytes, sha256 `bf4241bc73fef7f11c59a283a69fe8051cdd31c6d8ff5a2b9ba219e7831fcf76`; npm integrity `sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==`) |
15
- | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (17 352 bytes, sha256 `7f9e952399a3c1c91659a527bc1363a008b531ed6e296c2b3c819554ffb0a56f`). It holds 48 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2), then bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud and share-2 for the document tab's in-place editor, its Confluence copy and its connections |
15
+ | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (19 585 bytes, sha256 `405c8046d0954e3c942a4897eab7dcaca4cd3e2632b40788e4dbd4d9e5475461`). It holds 55 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2), then bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud and share-2 for the document tab's in-place editor, its Confluence copy and its connections, then house, target, square-kanban, file-text, square-terminal, calendar and chart-column for the phone's tab bar and view switches |
16
16
 
17
17
  The licences are in this folder: [sortablejs-1.15.7.LICENSE](sortablejs-1.15.7.LICENSE) and [lucide-static-1.52.0.LICENSE](lucide-static-1.52.0.LICENSE). The sprite also carries the lucide licence text as its first comment.
18
18