create-pmos 0.62.0 → 0.63.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.
@@ -0,0 +1,723 @@
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 and 2).
2
+ *
3
+ * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
+ * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
5
+ * when the meeting is over, Copy as Markdown and New meeting.
6
+ * left the PM's own notes, then what was caught: Decisions, Actions and Questions as they are said, each checked
7
+ * against the repository; after Stop a Summary at the top and Facts mentioned at the end.
8
+ * right a quiet transcript: who (click to name), when, and the line's label.
9
+ * All notes and Settings are one press away and stay out of the way.
10
+ *
11
+ * LISTENING (Run 2, D91). Listen asks what to listen to: the microphone, the meeting (a tab or window the PM shares, with its
12
+ * audio), or both. Each source goes to the server on the loopback address as 16 kHz mono samples, one second at a time, and
13
+ * the server transcribes it with the PM's own whisper.cpp. No audio leaves the machine. When Whisper is not set up, the
14
+ * microphone falls back to the browser's on-device recognition (processLocally, D85) and the meeting cannot be heard;
15
+ * Settings opens a terminal with the setup commands typed in.
16
+ *
17
+ * LABELS AND CHECKS. The server labels each line locally; behind the D91 gates Jev labels it and compares a captured line
18
+ * with the closest repository passage. The page shows the result in words and reads it by polling the note.
19
+ *
20
+ * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
21
+ * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=…
22
+ * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
23
+ * · summarize · summarize-agent · delete
24
+ */
25
+ import { el } from './dom.js';
26
+ import { askConfirm, askText } from './dialogs.js';
27
+ import { pmGet, pmPost } from './http.js';
28
+ import { mdRender } from './markdown.js';
29
+ import { icon } from './icons.js';
30
+ import { button, iconButton, field, row, sheet, empty, ensureComponentsCss } from './primitives.js';
31
+
32
+ const SAVE_MS = 700; // a pause this long in typing saves the note
33
+ const POLL_MS = 1500; // while listening, and until the last line is labelled, the note is read this often
34
+ const AGENT_POLL_MS = 3000;
35
+ const CHUNK_SAMPLES = 16000; // one second at 16 kHz
36
+ const RATE = 16000;
37
+ const SECTIONS = [['decision', 'Decisions'], ['action', 'Actions'], ['question', 'Questions']];
38
+
39
+ /* ── module state: survives the redraw every setState causes ─────────────────────────────── */
40
+ let list = null; // { state, notes, error }
41
+ let currentId = null;
42
+ const cache = new Map(); // id -> note, as last read or written
43
+ let editor = null; // { id, title, body, … } — the inputs, kept across redraws so typing is not lost
44
+ let saveTimer = null; let saving = false; let savedAt = null; let saveError = null; let dirty = false;
45
+ let banner = null; // { tone, text }
46
+ let speech = null; // the browser recogniser: { mode: 'dictate'|'record', id, state, rec, interim, lang, msg, last }
47
+ let audio = null; // local Whisper capture: { id, state, sources, ctx, streams, started, clock, msg }
48
+ let engine = null; // { ws, whisper, jev, at }
49
+ let showList = false;
50
+ let pollTimer = null; let agentTimer = null;
51
+ let busy = '';
52
+
53
+ const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
54
+ const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
55
+ export const fmtTime = (ms) => {
56
+ if (ms == null || !Number.isFinite(ms)) return '';
57
+ const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60;
58
+ const p = (n) => String(n).padStart(2, '0');
59
+ return h ? h + ':' + p(m) + ':' + p(r) : p(m) + ':' + p(r);
60
+ };
61
+ const wsOf = (ctx) => (ctx.state && ctx.state.wsId) || null;
62
+ const SPEAKER = { mic: 'You', meeting: 'Meeting', speech: 'You' };
63
+ export const speakerOf = (n, source) => ((n.speakers && n.speakers[source]) || SPEAKER[source] || 'You');
64
+ const baseName = (p) => String(p || '').split('/').pop();
65
+
66
+ /** Insert one dictated piece at [start, end] of `value`. Returns { value, start, end } of the inserted text. Pure, for the suite. */
67
+ export function insertAt(value, start, end, text) {
68
+ const before = value.slice(0, start); const after = value.slice(end);
69
+ const tail = before.replace(/[ \t]+$/, '');
70
+ const sentenceStart = !tail || /[.?!]$/.test(tail) || /\n$/.test(before);
71
+ let t = text;
72
+ // a phrase dictated into the middle of a sentence continues it: no capital, unless it is "I" or an acronym
73
+ if (!sentenceStart && /^[A-Z][a-z]/.test(t) && !/^I\b/.test(t)) t = t[0].toLowerCase() + t.slice(1);
74
+ const lead = !before || /\s$/.test(before) || /^[,.;:?!]/.test(t) ? '' : ' ';
75
+ const trail = after && !/^\s/.test(after) && !/^[,.;:?!]/.test(after) ? ' ' : '';
76
+ const ins = lead + t + trail;
77
+ return { value: before + ins + after, start: before.length + lead.length, end: before.length + lead.length + t.length };
78
+ }
79
+ /** Apply one dictated phrase's operations at the cursor. `last` is the range the previous phrase inserted, so a "scratch that"
80
+ * at the start of this phrase removes it; the range this phrase inserted comes back as `last`. Pure, for the suite. */
81
+ export function applyOps(value, sel, ops, last) {
82
+ if (!ops.length) return { value, sel, last };
83
+ let v = value; let [s, e] = sel; let from = null;
84
+ for (const o of ops) {
85
+ if (o.op === 'text') { const r = insertAt(v, s, e, o.text); v = r.value; if (from == null) from = r.start; s = e = r.end; }
86
+ else if (o.op === 'newline' || o.op === 'paragraph') {
87
+ const nl = o.op === 'paragraph' ? '\n\n' : '\n';
88
+ const before = v.slice(0, s).replace(/[ \t]+$/, '');
89
+ v = before + nl + v.slice(e); if (from == null) from = before.length; s = e = before.length + nl.length;
90
+ } else if (o.op === 'scratch' && last) {
91
+ const [a, b] = last;
92
+ const left = v.slice(0, a).replace(/[ \t]+$/, ''); const right = v.slice(b).replace(/^[ \t]+/, '');
93
+ v = left + (left && right && !/\n$/.test(left) && !/^\n/.test(right) ? ' ' : '') + right; s = e = left.length; last = null; from = null;
94
+ }
95
+ }
96
+ return { value: v, sel: [s, e], last: from == null ? null : [from, s] };
97
+ }
98
+
99
+ /** The captured items of a note, by section. A statement is a fact when it has a number or a repository check. Pure. */
100
+ export function caught(n) {
101
+ const out = { decision: [], action: [], question: [], fact: [] };
102
+ (n.transcript || []).forEach((x, index) => {
103
+ if (!x || !x.text || x.dropped) return;
104
+ if (out[x.cat]) out[x.cat].push({ ...x, index });
105
+ else if (x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || (x.check && x.check.doc))) out.fact.push({ ...x, index });
106
+ });
107
+ return out;
108
+ }
109
+ const checkWords = (c) => (!c ? '' : c.verdict === 'differs' ? 'differs from ' + c.doc : c.verdict === 'agrees' ? 'consistent with ' + c.doc : 'see ' + c.doc);
110
+ /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
111
+ export function noteMarkdown(n) {
112
+ const out = ['# ' + (n.title || 'Untitled note'), ''];
113
+ if (n.created_at) out.push('_' + dayOf(n.created_at) + ', ' + nowClock(n.created_at) + '_', '');
114
+ if (n.agent && n.agent.markdown) out.push('## Summary', '', n.agent.markdown.trim(), '');
115
+ else if (n.summary && n.summary.sections && n.summary.sections.overview && n.summary.sections.overview.length) out.push('## Summary', '', ...n.summary.sections.overview.map((x) => '- ' + x.text), '');
116
+ if (String(n.body || '').trim()) out.push('## Notes', '', n.body.trim(), '');
117
+ const c = caught(n);
118
+ for (const [k, title] of [...SECTIONS, ['fact', 'Facts mentioned']]) {
119
+ if (!c[k].length) continue;
120
+ out.push('## ' + title, '', ...c[k].map((x) => '- ' + (k === 'action' && x.source && x.source !== 'speech' ? speakerOf(n, x.source) + ': ' : '') + x.text
121
+ + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
122
+ }
123
+ if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text), '');
124
+ return out.join('\n');
125
+ }
126
+ /** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
127
+ export function toInt16(input, inRate) {
128
+ const ratio = inRate / RATE;
129
+ const n = Math.floor(input.length / ratio);
130
+ const out = new Int16Array(n);
131
+ for (let i = 0; i < n; i++) {
132
+ const a = Math.floor(i * ratio); const b = Math.max(a + 1, Math.floor((i + 1) * ratio));
133
+ let s = 0; for (let k = a; k < b && k < input.length; k++) s += input[k];
134
+ const v = Math.max(-1, Math.min(1, s / (b - a)));
135
+ out[i] = v < 0 ? v * 0x8000 : v * 0x7fff;
136
+ }
137
+ return out;
138
+ }
139
+ function b64(int16) {
140
+ const u = new Uint8Array(int16.buffer, int16.byteOffset, int16.byteLength);
141
+ let s = '';
142
+ for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000));
143
+ return btoa(s);
144
+ }
145
+
146
+ /* ── reads ────────────────────────────────────────────────────────────────────────────────────── */
147
+ async function loadList(ctx) {
148
+ list = { ...(list || {}), state: list ? list.state : 'loading' };
149
+ const r = await pmGet('/api/notes');
150
+ list = r && r.ok ? { state: 'ok', notes: r.notes } : { state: 'error', notes: (list && list.notes) || [], error: (r && r.error) || 'the notes could not be read' };
151
+ if (!currentId && list.notes.length) currentId = list.notes[0].id;
152
+ ctx.setState({});
153
+ }
154
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title]);
155
+ async function loadNote(ctx, id) {
156
+ const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
157
+ if (r && r.ok) {
158
+ const had = cache.get(id);
159
+ // what the PM is typing wins over what the server last saved
160
+ if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
161
+ r.note.__audio = r.audio || null;
162
+ cache.set(id, r.note);
163
+ if (!had || sig(had) !== sig(r.note)) ctx.setState({});
164
+ } else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
165
+ return r;
166
+ }
167
+ async function loadEngine(ctx) {
168
+ const ws = wsOf(ctx);
169
+ engine = { ...(engine || {}), ws, loading: true };
170
+ const r = await pmGet('/api/notes/engine' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
171
+ engine = r && r.ok ? { ws, whisper: r.whisper, jev: r.jev, at: Date.now() } : { ws, whisper: null, jev: null, error: (r && r.error) || 'not read', at: Date.now() };
172
+ ctx.setState({});
173
+ return engine;
174
+ }
175
+ const rowOf = (n) => ({ id: n.id, title: n.title || 'Untitled note', updated_at: n.updated_at, private: !!n.private, segments: (n.transcript || []).length });
176
+ function touchRow(n) {
177
+ if (!list || !list.notes) return;
178
+ list.notes = [rowOf(n), ...list.notes.filter((x) => x.id !== n.id)];
179
+ }
180
+ /** Read the note again while it is listening or a line still waits for its answer. */
181
+ function poll(ctx, id, quietRounds = 0) {
182
+ clearTimeout(pollTimer);
183
+ pollTimer = setTimeout(async () => {
184
+ pollTimer = null;
185
+ if (currentId !== id) return;
186
+ const before = cache.get(id); const was = before ? sig(before) : '';
187
+ const r = await loadNote(ctx, id);
188
+ const n = cache.get(id);
189
+ const live = (audio && audio.id === id) || (speech && speech.id === id && speech.mode === 'record' && speech.state === 'on');
190
+ const pending = n && n.__audio && n.__audio.pending > 0;
191
+ const same = n && sig(n) === was;
192
+ // a failed read while listening is tried again; a note that is gone (404) ends the loop in loadNote
193
+ if (live && !(r && r.status === 404)) poll(ctx, id, 0);
194
+ else if (r && r.ok && (pending || !same || quietRounds < 3)) poll(ctx, id, pending || !same ? 0 : quietRounds + 1);
195
+ }, POLL_MS);
196
+ }
197
+
198
+ /* ── writes ───────────────────────────────────────────────────────────────────────────────────── */
199
+ let inflight = null; // the save in flight, so a flush waits for it and then saves what was typed since
200
+ async function flushSave(ctx) {
201
+ clearTimeout(saveTimer); saveTimer = null;
202
+ if (inflight) await inflight;
203
+ if (!editor || !dirty) return;
204
+ const ed = editor;
205
+ const id = ed.id; const title = ed.title.value; const body = ed.body.value;
206
+ saving = true; dirty = false;
207
+ let done; inflight = new Promise((r) => { done = r; });
208
+ const r = await pmPost('/api/notes/save', { id, title, body });
209
+ saving = false; inflight = null; done();
210
+ if (r && r.ok) {
211
+ savedAt = r.updated_at; saveError = null;
212
+ const n = cache.get(id); if (n) { n.title = title; n.body = body; n.updated_at = r.updated_at; touchRow(n); }
213
+ } else { dirty = true; saveError = (r && r.error) || 'not saved'; }
214
+ if (dirty && !saveError) scheduleSave(ctx); // typed while the save was in flight
215
+ paintSaveState();
216
+ }
217
+ function scheduleSave(ctx) { dirty = true; clearTimeout(saveTimer); saveTimer = setTimeout(() => flushSave(ctx), SAVE_MS); paintSaveState(); }
218
+ function saveWords() {
219
+ if (saveError) return 'Not saved: ' + saveError;
220
+ if (saving || (dirty && saveTimer)) return 'Saving …';
221
+ return savedAt ? 'Saved ' + nowClock(savedAt) : '';
222
+ }
223
+ function paintSaveState() { const n = typeof document !== 'undefined' && document.querySelector('[data-nt-saved]'); if (n) n.textContent = saveWords(); }
224
+
225
+ async function stopAll(ctx, opts) { stopSpeech(); await stopAudio(ctx, opts); }
226
+ async function newNote(ctx) {
227
+ await stopAll(ctx); await flushSave(ctx);
228
+ busy = 'new'; ctx.setState({});
229
+ const r = await pmPost('/api/notes/new', {});
230
+ busy = '';
231
+ if (r && r.ok) { cache.set(r.note.id, r.note); currentId = r.note.id; editor = null; savedAt = null; showList = false; if (list) list.notes = [rowOf(r.note), ...(list.notes || [])]; banner = null; }
232
+ else banner = { tone: 'bad', text: (r && r.error) || 'the note was not created' };
233
+ ctx.setState({});
234
+ requestAnimationFrame(() => { if (editor && editor.title) editor.title.focus(); });
235
+ }
236
+ async function selectNote(ctx, id) {
237
+ showList = false;
238
+ if (id === currentId) { ctx.setState({}); return; }
239
+ await stopAll(ctx); await flushSave(ctx);
240
+ currentId = id; editor = null; savedAt = null; saveError = null; banner = null;
241
+ ctx.setState({});
242
+ loadNote(ctx, id);
243
+ }
244
+ async function deleteNote(ctx, n) {
245
+ const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
246
+ if (!sure) return;
247
+ await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false;
248
+ const r = await pmPost('/api/notes/delete', { id: n.id });
249
+ if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
250
+ else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
251
+ ctx.setState({});
252
+ }
253
+ async function setPrivate(ctx, n, priv) {
254
+ // a private press drops what is still buffered: nothing more is sent, transcribed or summarized
255
+ if (priv) await stopAll(ctx, { discard: true });
256
+ const r = await pmPost('/api/notes/private', { id: n.id, private: priv });
257
+ if (r && r.ok) { n.private = r.private; touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this note goes to Jev or to any agent.' } : null; }
258
+ else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
259
+ ctx.setState({});
260
+ }
261
+ async function summarize(ctx, n, quiet = false) {
262
+ await flushSave(ctx);
263
+ busy = 'summary'; ctx.setState({});
264
+ const r = await pmPost('/api/notes/summarize', { id: n.id });
265
+ busy = '';
266
+ if (r && r.ok) { n.summary = r.summary; banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
267
+ ctx.setState({});
268
+ }
269
+ async function summarizeAgent(ctx, n) {
270
+ await flushSave(ctx);
271
+ busy = 'agent'; ctx.setState({});
272
+ const ws = wsOf(ctx) || undefined;
273
+ const r = await pmPost('/api/notes/summarize-agent', ws ? { id: n.id, ws } : { id: n.id });
274
+ busy = '';
275
+ if (r && r.ok) {
276
+ n.agent = { requested_at: new Date().toISOString(), started: r.started, reason: r.reason, session: r.session, withheld: r.withheld, markdown: null, out: r.out };
277
+ banner = r.started
278
+ ? { tone: 'ok', text: 'An agent session started to write the summary' + (r.withheld ? ' (' + r.withheld + ' secret-shaped line' + (r.withheld === 1 ? ' was' : 's were') + ' withheld from it)' : '') + '. It appears here when the session writes it.' }
279
+ : { tone: 'bad', text: 'No agent session started: ' + (r.reason || 'the workspace gave no reason') + '. The local summary still works.' };
280
+ pollAgent(ctx, n.id);
281
+ } else banner = { tone: 'bad', text: (r && r.error) || 'the agent summary was not started' };
282
+ ctx.setState({});
283
+ }
284
+ function pollAgent(ctx, id) {
285
+ clearTimeout(agentTimer);
286
+ agentTimer = setTimeout(async () => {
287
+ agentTimer = null;
288
+ const n = cache.get(id);
289
+ if (!n || !n.agent || n.agent.markdown || currentId !== id) return;
290
+ const r = await loadNote(ctx, id);
291
+ if (r && r.ok && !(r.note.agent && r.note.agent.markdown)) pollAgent(ctx, id);
292
+ }, AGENT_POLL_MS);
293
+ }
294
+ async function copyMarkdown(ctx, n) {
295
+ const text = noteMarkdown({ ...n, title: editor ? editor.title.value : n.title, body: editor ? editor.body.value : n.body });
296
+ try { await navigator.clipboard.writeText(text); banner = { tone: 'ok', text: 'The note is on the clipboard as Markdown.' }; }
297
+ catch { banner = { tone: 'bad', text: 'The clipboard was not available. Select the text and copy it instead.' }; }
298
+ ctx.setState({});
299
+ }
300
+ async function unpick(ctx, n, x) {
301
+ const r = await pmPost('/api/notes/label', { id: n.id, index: x.index, cat: x.cat, drop: true });
302
+ if (r && r.ok) { n.transcript[x.index] = r.segment; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
303
+ ctx.setState({});
304
+ }
305
+ async function renameSpeaker(ctx, n, source) {
306
+ const name = await askText({ title: 'Name this speaker', label: 'Name', value: n.speakers && n.speakers[source] ? n.speakers[source] : '', placeholder: SPEAKER[source], confirmLabel: 'Save' });
307
+ if (name == null) return;
308
+ const r = await pmPost('/api/notes/speaker', { id: n.id, source, name });
309
+ if (r && r.ok) n.speakers = r.speakers; else banner = { tone: 'bad', text: (r && r.error) || 'not renamed' };
310
+ ctx.setState({});
311
+ }
312
+
313
+ /* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
314
+ async function openSource(kind) {
315
+ const md = navigator.mediaDevices;
316
+ if (kind === 'mic') return md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 } });
317
+ const s = await md.getDisplayMedia({ video: true, audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, systemAudio: 'include' });
318
+ const tracks = s.getAudioTracks();
319
+ for (const t of s.getVideoTracks()) t.stop(); // only the sound is used
320
+ if (!tracks.length) throw Object.assign(new Error('The shared tab or window has no audio. Share it again and tick "Share tab audio".'), { noAudio: true });
321
+ return new MediaStream(tracks);
322
+ }
323
+ async function startAudio(ctx, n, want) {
324
+ const a = { id: n.id, state: 'starting', sources: [], streams: [], ctx: null, started: Date.now(), clock: null, msg: null, queue: {}, buf: {}, ended: 0 };
325
+ const opened = [];
326
+ // the meeting first, straight from the press: the browser lets a page share a screen only right after a click
327
+ for (const kind of want.slice().sort((x, y) => (x === 'meeting' ? -1 : y === 'meeting' ? 1 : 0))) {
328
+ try { opened.push([kind, await openSource(kind)]); }
329
+ catch (e) { a.msg = (a.msg ? a.msg + ' ' : '') + (kind === 'mic' ? 'The microphone was not opened' : 'The meeting audio was not opened') + ': ' + ((e && e.noAudio) ? e.message : ((e && e.message) || 'permission was refused') + '.'); }
330
+ }
331
+ await stopAll(ctx); await flushSave(ctx);
332
+ audio = a; ctx.setState({});
333
+ if (!opened.length) { audio = null; banner = { tone: 'bad', text: a.msg || 'Nothing to listen to.' }; ctx.setState({}); return; }
334
+ const r = await pmPost('/api/notes/record', wsOf(ctx) ? { id: n.id, ws: wsOf(ctx) } : { id: n.id });
335
+ if (!r || !r.ok) { for (const [, s] of opened) for (const t of s.getTracks()) t.stop(); audio = null; banner = { tone: 'bad', text: (r && r.error) || 'listening did not start' }; ctx.setState({}); return; }
336
+ n.recorded_from = r.recorded_from;
337
+ // the clock counts from the note's first Listen, as the line times do
338
+ a.started = Date.parse(r.recorded_from) || a.started;
339
+ let ac;
340
+ try { ac = new AudioContext({ sampleRate: RATE }); } catch { ac = new AudioContext(); }
341
+ a.ctx = ac;
342
+ const sink = ac.createGain(); sink.gain.value = 0; sink.connect(ac.destination);
343
+ for (const [kind, stream] of opened) {
344
+ a.streams.push(stream); a.sources.push(kind); a.buf[kind] = []; a.queue[kind] = Promise.resolve();
345
+ const src = ac.createMediaStreamSource(stream);
346
+ const proc = ac.createScriptProcessor(4096, 1, 1);
347
+ proc.onaudioprocess = (e) => {
348
+ if (audio !== a) return;
349
+ const s = toInt16(e.inputBuffer.getChannelData(0), ac.sampleRate);
350
+ const b = a.buf[kind]; for (let i = 0; i < s.length; i++) b.push(s[i]);
351
+ if (b.length >= CHUNK_SAMPLES) sendChunk(ctx, a, kind, false);
352
+ };
353
+ src.connect(proc); proc.connect(sink);
354
+ for (const t of stream.getTracks()) t.addEventListener('ended', () => { if (audio !== a) return; a.ended++; if (a.ended >= a.streams.length) stopAudio(ctx); });
355
+ }
356
+ a.state = 'on';
357
+ a.clock = setInterval(() => { const c = typeof document !== 'undefined' && document.querySelector('[data-nt-clock]'); if (c) c.textContent = fmtTime(Date.now() - a.started); }, 1000);
358
+ ctx.setState({});
359
+ poll(ctx, n.id);
360
+ }
361
+ function sendChunk(ctx, a, kind, final) {
362
+ const b = a.buf[kind] || []; a.buf[kind] = [];
363
+ const pcm = b64(Int16Array.from(b));
364
+ a.queue[kind] = a.queue[kind].then(async () => {
365
+ const r = await pmPost('/api/notes/audio', { id: a.id, source: kind, pcm, final });
366
+ if (r && !r.ok && (r.status === 409 || r.status === 404) && audio === a) { banner = { tone: 'bad', text: 'Listening stopped: ' + r.error + '.' }; stopAudio(ctx); }
367
+ }).catch(() => {});
368
+ return a.queue[kind];
369
+ }
370
+ async function stopAudio(ctx, { discard = false } = {}) {
371
+ const a = audio;
372
+ if (!a) return;
373
+ audio = null;
374
+ clearInterval(a.clock);
375
+ for (const s of a.streams) for (const t of s.getTracks()) { try { t.stop(); } catch { /* stopped */ } }
376
+ try { if (a.ctx) await a.ctx.close(); } catch { /* closed */ }
377
+ if (discard) { ctx.setState({}); return; }
378
+ // the rest of what was said, then the summary once the last line is transcribed
379
+ await Promise.all(a.sources.map((k) => sendChunk(ctx, a, k, true)));
380
+ ctx.setState({});
381
+ const n = cache.get(a.id);
382
+ if (!n || n.private) return;
383
+ const t0 = Date.now();
384
+ for (;;) {
385
+ const r = await loadNote(ctx, a.id);
386
+ if (!r || !r.ok || !r.audio || !r.audio.pending || Date.now() - t0 > 60000) break;
387
+ await new Promise((res) => setTimeout(res, 800));
388
+ }
389
+ const fresh = cache.get(a.id);
390
+ if (fresh && (fresh.transcript || []).length) await summarize(ctx, fresh, true);
391
+ poll(ctx, a.id);
392
+ }
393
+
394
+ /* ── listening with the browser's on-device recognition: the microphone only, when Whisper is not set up ─────────── */
395
+ const SRClass = () => (typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null);
396
+ function stopSpeech() {
397
+ if (!speech) return;
398
+ const s = speech; speech = null;
399
+ clearInterval(s.clock);
400
+ if (s.rec) { s.rec.onend = null; try { s.rec.stop(); } catch { /* already stopped */ } }
401
+ }
402
+ async function onFinal(ctx, s, text) {
403
+ if (s.mode === 'record') {
404
+ const r = await pmPost('/api/notes/transcript', { id: s.id, text });
405
+ const n = cache.get(s.id);
406
+ if (r && r.ok && n) { n.transcript = (n.transcript || []).concat((r.added || []).map(({ index, ...x }) => x)); touchRow(n); poll(ctx, s.id); }
407
+ else if (r && !r.ok) { banner = { tone: 'bad', text: 'A phrase was not added to the transcript: ' + r.error }; if (r.status === 409) stopSpeech(); }
408
+ ctx.setState({});
409
+ return;
410
+ }
411
+ // dictation: the server cleans the phrase and resolves the spoken commands; the page applies them at the cursor
412
+ const r = await pmPost('/api/notes/dictate', { text });
413
+ if (!r || !r.ok || !editor || editor.id !== s.id) return;
414
+ const ta = editor.body;
415
+ const focused = document.activeElement === ta;
416
+ const sel = focused ? [ta.selectionStart, ta.selectionEnd] : (editor.sel || [ta.value.length, ta.value.length]);
417
+ const out = applyOps(ta.value, sel, r.ops, s.last);
418
+ ta.value = out.value; editor.sel = out.sel; s.last = out.last; fitBody();
419
+ if (focused) { try { ta.setSelectionRange(out.sel[0], out.sel[1]); } catch { /* detached */ } }
420
+ scheduleSave(ctx);
421
+ }
422
+ async function startSpeech(ctx, n, mode, install = false) {
423
+ await stopAll(ctx);
424
+ await flushSave(ctx);
425
+ const C = SRClass();
426
+ const lang = (typeof navigator !== 'undefined' && navigator.language) || 'en-US';
427
+ const s = { mode, id: n.id, state: 'checking', rec: null, interim: '', lang, msg: null, last: null, queue: Promise.resolve(), started: Date.now() };
428
+ speech = s; ctx.setState({});
429
+ if (!C || typeof C.available !== 'function') {
430
+ s.state = 'unsupported';
431
+ s.msg = 'This browser cannot recognise speech on this device, and notes never send audio away. Set up local transcription in Settings, or type.';
432
+ ctx.setState({}); return;
433
+ }
434
+ try {
435
+ if (install) { s.state = 'installing'; ctx.setState({}); await C.install({ langs: [lang], processLocally: true }); }
436
+ const av = await C.available({ langs: [lang], processLocally: true });
437
+ if (speech !== s) return;
438
+ if (av === 'downloadable' || av === 'downloading') { s.state = 'needs-install'; s.msg = 'Recognising ' + lang + ' on this device needs a one-time download by the browser.'; ctx.setState({}); return; }
439
+ if (av !== 'available') { s.state = 'unsupported'; s.msg = 'This browser cannot recognise ' + lang + ' on this device, and notes never send audio away. Set up local transcription in Settings, or type.'; ctx.setState({}); return; }
440
+ } catch (e) { s.state = 'error'; s.msg = 'Speech recognition did not start: ' + ((e && e.message) || e); ctx.setState({}); return; }
441
+ if (mode === 'record') {
442
+ const r = await pmPost('/api/notes/record', wsOf(ctx) ? { id: n.id, ws: wsOf(ctx) } : { id: n.id });
443
+ if (!r || !r.ok) { s.state = 'error'; s.msg = (r && r.error) || 'listening did not start'; ctx.setState({}); return; }
444
+ n.recorded_from = r.recorded_from;
445
+ s.started = Date.parse(r.recorded_from) || s.started;
446
+ }
447
+ if (speech !== s) return;
448
+ const rec = new C();
449
+ rec.lang = lang; rec.continuous = true; rec.interimResults = true; rec.processLocally = true;
450
+ rec.onresult = (e) => {
451
+ if (speech !== s) return; // a result after Stop or Private is dropped, never stored
452
+ let interim = '';
453
+ for (let i = e.resultIndex; i < e.results.length; i++) {
454
+ const res = e.results[i]; const t = String((res[0] && res[0].transcript) || '').trim();
455
+ if (!t) continue;
456
+ // one phrase at a time, in the order spoken, so "scratch that" removes the phrase before it
457
+ if (res.isFinal) s.queue = s.queue.then(() => onFinal(ctx, s, t)).catch(() => {}); else interim += (interim ? ' ' : '') + t;
458
+ }
459
+ s.interim = interim;
460
+ const live = typeof document !== 'undefined' && document.querySelector('[data-nt-interim]');
461
+ if (live) live.textContent = interim; else ctx.setState({});
462
+ };
463
+ rec.onerror = (e) => {
464
+ if (e && (e.error === 'no-speech' || e.error === 'aborted')) return;
465
+ s.state = 'error'; s.msg = 'Listening stopped: ' + ((e && (e.message || e.error)) || 'the browser gave no reason') + '.'; ctx.setState({});
466
+ };
467
+ // the browser ends a recognition after a silence; while the PM has not pressed Stop, it starts again
468
+ rec.onend = () => { if (speech === s && s.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
469
+ s.rec = rec;
470
+ try { rec.start(); s.state = 'on'; } catch (e) { s.state = 'error'; s.msg = 'Listening did not start: ' + ((e && e.message) || e); }
471
+ if (mode === 'record') { s.clock = setInterval(() => { const c = typeof document !== 'undefined' && document.querySelector('[data-nt-clock]'); if (c) c.textContent = fmtTime(Date.now() - s.started); }, 1000); poll(ctx, n.id); }
472
+ ctx.setState({});
473
+ }
474
+ async function stopRecording(ctx) {
475
+ const s = speech;
476
+ if (s && s.mode === 'record') {
477
+ clearInterval(s.clock); stopSpeech();
478
+ await s.queue;
479
+ const n = cache.get(s.id);
480
+ if (n && !n.private && (n.transcript || []).length) await summarize(ctx, n, true);
481
+ }
482
+ await stopAudio(ctx);
483
+ ctx.setState({});
484
+ }
485
+
486
+ /* ── the two sheets: what to listen to, and settings ─────────────────────────────────────────── */
487
+ async function chooseSources(ctx, n) {
488
+ const eng = engine && engine.ws === wsOf(ctx) && engine.whisper ? engine : await loadEngine(ctx);
489
+ const local = !!(eng.whisper && eng.whisper.ready);
490
+ const mic = field({ label: 'My microphone', kind: 'check', value: true, attrs: { 'data-nt-src-mic': '' } });
491
+ const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
492
+ const body = [mic, meet,
493
+ el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
494
+ el('p', { class: 'nt-consent', 'data-nt-consent': '' }, 'Speech is transcribed on this ' + (local ? 'machine' : 'device') + '. Let people know you\'re taking notes.')];
495
+ let h = null;
496
+ const go = () => {
497
+ const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
498
+ h.close();
499
+ if (!want.length) return;
500
+ if (local) startAudio(ctx, n, want); else startSpeech(ctx, n, 'record');
501
+ };
502
+ h = sheet({ title: 'Listen to', body, attrs: { 'data-nt-chooser': '' },
503
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Start', variant: 'primary', onClick: go, attrs: { 'data-nt-start': '' } })] });
504
+ }
505
+ async function setSwitch(ctx, patch) {
506
+ const ws = wsOf(ctx);
507
+ if (!ws) return;
508
+ const r = await pmPost('/api/notes/switch', { ws, ...patch });
509
+ if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
+ ctx.setState({});
511
+ return r;
512
+ }
513
+ async function whisperSetup(ctx) {
514
+ const ws = wsOf(ctx);
515
+ const r = await pmPost('/api/notes/whisper-setup', ws ? { ws } : {});
516
+ banner = r && r.ok && r.started
517
+ ? { tone: 'ok', text: 'A terminal opened with the setup commands typed in. Read them and press Enter there. The model is ' + ((engine && engine.whisper && engine.whisper.model_size) || 'about 1.6 GB') + '. Then open Settings again.' }
518
+ : { tone: 'bad', text: 'No terminal opened' + (r && r.reason ? ': ' + r.reason : '') + '. Run this in a terminal: ' + ((r && r.command) || '') };
519
+ ctx.setState({});
520
+ }
521
+ function openSettings(ctx, n) {
522
+ const draw = (h) => {
523
+ const eng = engine || {};
524
+ const w = eng.whisper; const j = eng.jev;
525
+ const parts = [];
526
+ parts.push(el('div', { class: 'nt-set', 'data-nt-whisper': w ? (w.ready ? 'ready' : 'missing') : 'unknown' },
527
+ el('div', { class: 'nt-set__head' }, 'Local transcription'),
528
+ el('p', { class: 'nt-hint' }, !w ? 'Reading …' : w.ready ? 'Whisper is set up. The microphone and the meeting are transcribed on this machine.'
529
+ : 'Whisper is not set up (' + w.missing.map((m) => (m === 'program' ? 'the program' : 'the model, ' + w.model_size)).join(' and ') + ' missing). The setup installs whisper.cpp with Homebrew and downloads the model.'),
530
+ w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null));
531
+ if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
532
+ else {
533
+ 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': '' } });
534
+ const csw = field({ label: 'Check notes against this repository with Jev', kind: 'check', value: !!(j && j.check), onCommit: (v) => setSwitch(ctx, { check: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-check': '' } });
535
+ parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'Jev'), jsw, csw,
536
+ el('p', { class: 'nt-hint', 'data-nt-gates': '' }, j && j.paused ? j.paused : j && j.sentence ? j.sentence + ' Lines get the local label and the local repository match.'
537
+ : j && j.check_open ? 'Each line and, for a captured line, one repository passage go to TypeSafe. Audio, names and private notes never do.'
538
+ : j && j.label_open ? 'Each line goes to TypeSafe for its label. Audio, names and private notes never do.' : 'Off. Lines get the local label and the local repository match.')));
539
+ }
540
+ parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'This note'),
541
+ n.private ? el('p', { class: 'nt-hint' }, 'Private: it is not recorded and nothing from it goes to Jev or to any agent.') : null,
542
+ el('div', { class: 'nt-row' },
543
+ 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']: '' } }),
544
+ button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
545
+ button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
546
+ return parts;
547
+ };
548
+ const h = sheet({ title: 'Settings', sub: 'Notes stay on this machine, outside every repository.', body: [], attrs: { 'data-nt-settings-sheet': '' } });
549
+ const paint = () => { h.body.replaceChildren(...draw(h).filter(Boolean)); };
550
+ paint();
551
+ loadEngine(ctx).then(() => { if (h.open) paint(); });
552
+ }
553
+
554
+ /* ── drawing ──────────────────────────────────────────────────────────────────────────────────── */
555
+ function fitBody() {
556
+ if (!editor || !editor.body) return;
557
+ const t = editor.body;
558
+ t.style.setProperty('--nt-h', 'auto');
559
+ t.style.setProperty('--nt-h', Math.max(96, t.scrollHeight + 2) + 'px');
560
+ }
561
+ function editorFor(ctx, n) {
562
+ if (editor && editor.id === n.id) return editor;
563
+ const tf = field({ label: 'Title', kind: 'title', hideLabel: true, value: n.title || '', placeholder: 'Untitled meeting', attrs: { 'data-nt-title': '', class: 'nt-titlein' } });
564
+ const bf = field({ label: 'Your notes', kind: 'textarea', hideLabel: true, value: n.body || '', placeholder: 'Type your own notes. They steer the summary.',
565
+ attrs: { 'data-nt-body': '', class: 'nt-text', rows: 3 } });
566
+ const title = tf.control; const body = bf.control;
567
+ editor = { id: n.id, title, body, titleWrap: tf, bodyWrap: bf, sel: null };
568
+ const remember = () => { editor.sel = [body.selectionStart, body.selectionEnd]; };
569
+ title.addEventListener('input', () => scheduleSave(ctx));
570
+ body.addEventListener('input', () => { remember(); fitBody(); if (speech) speech.last = null; scheduleSave(ctx); });
571
+ for (const ev of ['keyup', 'mouseup', 'select', 'blur']) body.addEventListener(ev, remember);
572
+ title.addEventListener('blur', () => flushSave(ctx));
573
+ return editor;
574
+ }
575
+ function listening(n) {
576
+ if (audio && audio.id === n.id) return { kind: 'local', state: audio.state, sources: audio.sources, started: audio.started };
577
+ if (speech && speech.id === n.id && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking')) return { kind: 'browser', state: speech.state, sources: ['mic'], started: speech.started };
578
+ return null;
579
+ }
580
+ function header(ctx, n, ed) {
581
+ const live = listening(n);
582
+ const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
583
+ const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()));
584
+ const acts = [];
585
+ if (live) {
586
+ acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
587
+ el('span', { class: 'nt-live-dot', 'aria-hidden': 'true' }),
588
+ el('span', { 'data-nt-clock': '' }, fmtTime(Date.now() - live.started)),
589
+ el('span', { 'data-nt-sources': '' }, ' · ' + (live.state === 'starting' || live.state === 'checking' ? 'starting' : ['mic', 'meeting'].filter((s) => live.sources.includes(s)).join(' + '))),
590
+ el('span', { class: 'nt-sr' }, ' Notes are being taken.')));
591
+ acts.push(button({ label: 'Stop', variant: 'danger', onClick: () => stopRecording(ctx), attrs: { 'data-nt-stop': '' } }));
592
+ } else if ((n.transcript || []).length) {
593
+ acts.push(button({ label: 'Copy as Markdown', variant: 'ghost', onClick: () => copyMarkdown(ctx, n), attrs: { 'data-nt-copy': '' } }));
594
+ if (!n.private) acts.push(button({ label: 'Listen again', variant: 'ghost', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': 'again' } }));
595
+ acts.push(button({ label: busy === 'new' ? 'Creating …' : 'New meeting', disabled: busy === 'new', onClick: () => newNote(ctx), attrs: { 'data-nt-new': '' } }));
596
+ } else if (!n.private) {
597
+ acts.push(button({ label: 'Listen', variant: 'primary', onClick: () => chooseSources(ctx, n), attrs: { 'data-nt-listen': '' } }));
598
+ }
599
+ acts.push(button({ label: 'All notes', variant: 'ghost', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
600
+ acts.push(iconButton({ icon: 'settings', ariaLabel: 'Settings', onClick: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } }));
601
+ return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, left, el('div', { class: 'nt-acts' }, acts));
602
+ }
603
+ function listPane(ctx) {
604
+ const rows = (list && list.notes) || [];
605
+ return el('div', { class: 'nt-list', 'data-nt-part': 'list' },
606
+ button({ label: busy === 'new' ? 'Creating …' : 'New note', variant: 'primary', disabled: busy === 'new', onClick: () => newNote(ctx), attrs: { 'data-nt-new-list': '' } }),
607
+ rows.length ? el('div', { class: 'nt-rows' }, rows.map((r) => row({ title: r.title || 'Untitled note',
608
+ meta: [dayOf(r.updated_at) + ' ' + nowClock(r.updated_at), r.segments ? 'listened' : null, r.private ? 'private' : null],
609
+ onOpen: () => selectNote(ctx, r.id), openLabel: 'Open ' + (r.title || 'Untitled note'),
610
+ attrs: { 'data-nt-row': r.id, 'aria-current': r.id === currentId ? 'true' : 'false', class: r.id === currentId ? 'nt-row--current' : '' } })))
611
+ : empty({ text: list && list.state === 'loading' ? 'reading …' : 'No notes yet.' }));
612
+ }
613
+ function checkMark(x) {
614
+ const c = x.check;
615
+ if (!c || !c.doc) return null;
616
+ const where = c.doc + (c.line ? ' line ' + c.line : '');
617
+ if (c.verdict === 'differs') return el('span', { class: 'nt-flag', 'data-nt-check': 'differs', title: 'Jev rates it ' + (c.words || 'likely') + ' that this differs from ' + where }, '≠ ' + baseName(c.doc));
618
+ if (c.verdict === 'agrees') return el('span', { class: 'nt-ok', 'data-nt-check': 'agrees', title: 'Jev rates it ' + (c.words || 'likely') + ' that ' + where + ' already says this', role: 'img', 'aria-label': 'consistent with ' + baseName(c.doc) }, icon('check'));
619
+ return el('span', { class: 'nt-rel', 'data-nt-check': 'related', title: 'The closest passage in this repository: ' + where }, 'see ' + baseName(c.doc));
620
+ }
621
+ function sectionOf(ctx, n, key, title, items) {
622
+ if (!items.length) return null;
623
+ return el('section', { class: 'nt-sec', 'data-nt-sec': key },
624
+ el('h3', { class: 'nt-sec__h' }, title),
625
+ el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
626
+ el('span', { class: 'nt-item__text' },
627
+ key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
628
+ x.text, ' ', checkMark(x)),
629
+ el('span', { class: 'nt-at' }, fmtTime(x.t)),
630
+ key === 'fact' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
631
+ }
632
+ function summarySection(ctx, n) {
633
+ const a = n.agent;
634
+ if (a && a.markdown) {
635
+ let blocks = [];
636
+ try { blocks = mdRender(a.markdown).blocks || []; } catch { blocks = [el('div', { class: 'nt-pre' }, a.markdown)]; }
637
+ return el('section', { class: 'nt-sec', 'data-nt-summary': 'agent' }, el('h3', { class: 'nt-sec__h' }, 'Summary'), el('div', { class: 'nt-md' }, blocks), el('p', { class: 'nt-hint' }, 'Written by your agent session.'));
638
+ }
639
+ const ov = n.summary && n.summary.sections && n.summary.sections.overview;
640
+ const out = [];
641
+ if (a && a.started && !a.markdown) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-wait': '' }, 'Waiting for your agent session to write the summary.'));
642
+ else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
643
+ if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
644
+ return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
645
+ el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, x.text)))),
646
+ el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
647
+ }
648
+ function noteColumn(ctx, n, ed) {
649
+ const live = listening(n);
650
+ const c = caught(n);
651
+ const parts = [summarySection(ctx, n), ed.bodyWrap];
652
+ const sp = speech && speech.id === n.id ? speech : null;
653
+ const bar = [];
654
+ if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
655
+ else if (!live) bar.push(button({ label: sp && sp.mode === 'dictate' && (sp.state === 'checking' || sp.state === 'installing') ? 'Starting …' : 'Dictate', variant: 'ghost', onClick: () => startSpeech(ctx, n, 'dictate'), attrs: { 'data-nt-dictate': '' } }));
656
+ if (sp && sp.state === 'needs-install') bar.push(button({ label: 'Download for ' + sp.lang, onClick: () => startSpeech(ctx, n, sp.mode, true), attrs: { 'data-nt-install': '' } }));
657
+ parts.push(el('div', { class: 'nt-row' }, bar));
658
+ if (sp && sp.msg && sp.state !== 'on') parts.push(el('p', { class: 'nt-hint', 'data-nt-listen-msg': sp.state }, sp.msg));
659
+ if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
660
+ const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
661
+ if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
662
+ if (secs.length) parts.push(...secs);
663
+ else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
664
+ : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
665
+ return el('section', { class: 'nt-notecol', 'data-nt-part': 'notes' }, parts.filter(Boolean));
666
+ }
667
+ function transcriptColumn(ctx, n) {
668
+ const tr = n.transcript || [];
669
+ const live = listening(n);
670
+ const lines = tr.map((x, i) => {
671
+ const lab = x.cat && x.cat !== 'small talk' ? el('span', { class: 'nt-lab', 'data-nt-cat': x.cat }, x.cat) : null;
672
+ const who = speakerOf(n, x.source);
673
+ const spk = x.source === 'meeting'
674
+ ? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
675
+ : el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
676
+ return el('div', { class: 'nt-ln' + (['decision', 'action', 'question'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
677
+ el('div', { class: 'nt-ln__meta' }, spk, el('span', { class: 'nt-at' }, fmtTime(x.t)), lab), el('div', null, x.text));
678
+ });
679
+ const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
680
+ if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
681
+ const pending = n.__audio && n.__audio.pending;
682
+ if (!lines.length) lines.push(el('p', { class: 'nt-hint' }, live ? 'Waiting for speech …' : 'The transcript runs here.'));
683
+ else if (live && pending) lines.push(el('p', { class: 'nt-hint', 'data-nt-pending': '' }, 'Transcribing …'));
684
+ const box = el('div', { class: 'nt-lines', 'data-nt-transcript': '' }, lines);
685
+ if (live) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
686
+ return el('aside', { class: 'nt-trans', 'data-nt-part': 'transcript' }, el('h3', { class: 'nt-sec__h' }, 'Transcript'), box);
687
+ }
688
+
689
+ export function renderBoard(inner, ctx) {
690
+ const { FIXTURE } = ctx;
691
+ ensureComponentsCss();
692
+ if (FIXTURE) { inner.appendChild(el('p', { class: 'nt-hint' }, 'The fixture keeps no notes.')); return; }
693
+ if (!list) loadList(ctx);
694
+ if (!engine || engine.ws !== wsOf(ctx)) { if (!engine || !engine.loading || engine.ws !== wsOf(ctx)) loadEngine(ctx); }
695
+
696
+ const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.body ? 'body' : document.activeElement === editor.title ? 'title' : null);
697
+ const caret = focus ? [editor[focus].selectionStart, editor[focus].selectionEnd] : null;
698
+
699
+ const wrap = el('div', { class: 'nt-wrap', 'data-nt-part': 'view' });
700
+ if (list && list.state === 'error') wrap.appendChild(el('div', { class: 'nt-banner nt-banner--bad', role: 'alert' }, list.error));
701
+ if (banner) wrap.appendChild(el('div', { class: 'nt-banner' + (banner.tone === 'bad' ? ' nt-banner--bad' : ''), 'data-nt-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status' }, banner.text));
702
+ const n = currentId ? cache.get(currentId) : null;
703
+ if (currentId && !n) { loadNote(ctx, currentId); wrap.appendChild(el('p', { class: 'nt-hint' }, 'reading the note …')); }
704
+ else if (!n) {
705
+ wrap.appendChild(el('div', { class: 'nt-start' },
706
+ el('h2', { class: 'nt-h' }, 'Notes'),
707
+ el('p', { class: 'nt-hint', 'data-nt-about': '' }, 'Take notes in a meeting. PMOS listens on this machine, catches the decisions, actions and questions, and checks them against your repository. No audio leaves this machine, and notes are kept outside every repository.'),
708
+ list && list.state === 'loading' ? el('p', { class: 'nt-hint' }, 'reading …')
709
+ : button({ label: busy === 'new' ? 'Creating …' : 'New meeting', variant: 'primary', disabled: busy === 'new', onClick: () => newNote(ctx), attrs: { 'data-nt-new': '' } })));
710
+ } else {
711
+ const ed = editorFor(ctx, n);
712
+ wrap.appendChild(header(ctx, n, ed));
713
+ if (showList) wrap.appendChild(listPane(ctx));
714
+ if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
715
+ wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
716
+ if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
717
+ requestAnimationFrame(fitBody);
718
+ }
719
+ inner.appendChild(wrap);
720
+ // the redraw moves the fields, which blurs them; focus comes back in a microtask, after the frame is attached and before the
721
+ // next key event, so no keystroke lands while the field is detached
722
+ if (caret) queueMicrotask(() => { try { const t = editor[focus]; if (t.isConnected) { t.focus(); t.setSelectionRange(caret[0], caret[1]); } } catch { /* the field is gone */ } });
723
+ }