create-pmos 0.133.0 → 0.134.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.
@@ -25,9 +25,14 @@
25
25
  * The cuts are long (a pause of 1 s, at least 4 s) so that a cut holds a whole sentence more often than a fragment;
26
26
  * lib/notes.js joins a cut that still ends mid-sentence to the one before it.
27
27
  *
28
- * VOCABULARY AND CONTEXT. Each run gets `--prompt` with the names and terms of the repository (the `vocab` callback, or the
29
- * `prompt` string) and the last line heard from the same source, at most PROMPT_MAX characters. Whisper then spells the
30
- * repository's people and product terms as the repository does, and reads the cut as the continuation of the line before.
28
+ * VOCABULARY. Each run gets `--prompt` with the names and terms of the repository (the `vocab` callback, or the `prompt`
29
+ * string), at most PROMPT_MAX characters, so Whisper spells the repository's people and product terms as the repository does.
30
+ * The line before is no longer passed (notes-recording-reliability): it made Whisper repeat it, and a line in another language
31
+ * pulled the next cut into that language. promptFor still takes a previous line for the final pass.
32
+ *
33
+ * GUARDS (notes-recording-reliability, lib/notes-live-guard.js). A cut that repeats the line before it is dropped, a sentence
34
+ * repeated back to back is kept once, the meeting's words are taken out of the microphone's line (the echo through the PM's
35
+ * speakers), and with English chosen a cut that comes back in another language is run again in English.
31
36
  *
32
37
  * INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
33
38
  * quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
@@ -38,6 +43,7 @@ const os = require('os');
38
43
  const path = require('path');
39
44
  const crypto = require('crypto');
40
45
  const { execFile } = require('child_process');
46
+ const liveGuard = require('./notes-live-guard.js');
41
47
 
42
48
  const RATE = 16000;
43
49
  const FRAME_MS = 100;
@@ -53,6 +59,9 @@ const LEAD_MS = 300; // speech that starts a cut keeps this much
53
59
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
54
60
  const MAX_QUEUE = 24;
55
61
  const RUN_TIMEOUT_MS = 120000;
62
+ const HOLD_MS = 12000; // a microphone line waits at most this long for the meeting's line of the same time
63
+ const MEETING_LIVE_MS = 30000; // meeting audio this recent means the meeting is being captured
64
+ const ECHO_KEEP_MS = 60000; // the lines kept for the echo check
56
65
  const MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
57
66
  // The faster model is the same one quantized to 5 bits: a third of the size, so it loads and runs faster on an M1, and it
58
67
  // makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
@@ -253,9 +262,10 @@ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE, accuracy = DEFA
253
262
  * @param {Function} [a.run] runWhisper, replaceable in the suites
254
263
  * @param {Function} [a.detect] detect, replaceable in the suites
255
264
  */
256
- function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '' }) {
265
+ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '',
266
+ echo = true, holdMs = HOLD_MS, recent = null, lastLine = null }) {
257
267
  const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
258
- const lastText = new Map(); // `${id}|${source}` → the last words heard there, the context of the next cut
268
+ const lastText = new Map(); // `${id}|${source}` → { text, t }: the last line heard there, for the repetition guard
259
269
  const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
260
270
  const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
261
271
  let queue = Promise.resolve();
@@ -264,13 +274,98 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
264
274
  // a cut left by a process that ended while the program ran is deleted when the next transcriber starts
265
275
  try { for (const f of fs.readdirSync(tmpDir)) if (/^cut-[0-9a-f]+\.wav$/.test(f)) fs.unlinkSync(path.join(tmpDir, f)); } catch { /* no folder yet */ }
266
276
 
277
+ /* LIVE ECHO (notes-recording-reliability). A microphone line is held until the meeting source has nothing left to say about the
278
+ * same time: no meeting cut queued that starts before the line ends, and no meeting speech buffered from before then. Then
279
+ * the meeting's words are taken out of it (lib/notes-live-guard.js stripEcho), and what is left, if anything, is the PM's.
280
+ * A line is never held longer than holdMs. A note with no meeting audio holds nothing. */
281
+ const heard = new Map(); // note id → [{ source, t, end, text }] the lines of the last ECHO_KEEP_MS, for the echo check
282
+ const meetingAt = new Map();// note id → when meeting audio last arrived (Date.now())
283
+ const inQueue = []; // { id, source, t } of the cuts queued and not yet emitted
284
+ const held = []; // { job, text, until }
285
+ let holdTimer = null;
286
+ const remember = (job, text) => {
287
+ const xs = (heard.get(job.id) || []).filter((x) => x.end >= job.t - ECHO_KEEP_MS);
288
+ xs.push({ source: job.source, t: job.t, end: job.t + job.ms, text });
289
+ heard.set(job.id, xs);
290
+ };
291
+ const meetingBusyBefore = (id, end) => {
292
+ if (inQueue.some((q) => q.id === id && q.source === 'meeting' && q.t < end)) return true;
293
+ const b = bufs.get(id + '|meeting');
294
+ return !!(b && b.voicedMs >= MIN_VOICED_MS && b.t < end);
295
+ };
296
+ const emit = async (job, text, extra) => {
297
+ const key = job.id + '|' + job.source;
298
+ let prev = lastText.get(key);
299
+ if (!prev && typeof lastLine === 'function') { try { prev = lastLine(job.id, job.source) || null; } catch { prev = null; } }
300
+ if (liveGuard.repeats(text, prev, job.t)) return;
301
+ lastText.set(key, { text, t: job.t });
302
+ remember(job, text);
303
+ await onText({ ...job, text, ...(extra || {}) });
304
+ };
305
+ // the meeting lines heard here, and the note's own meeting lines (`recent`), so a restart still knows what the meeting said
306
+ const meetingLines = (id) => {
307
+ let stored = [];
308
+ try { stored = typeof recent === 'function' ? (recent(id) || []) : []; } catch { stored = []; }
309
+ return stored.concat((heard.get(id) || []).filter((x) => x.source === 'meeting'));
310
+ };
311
+ const ownWords = (job, text) => liveGuard.stripEcho({ t: job.t, end: job.t + job.ms, text }, meetingLines(job.id));
312
+ function release(force = false) {
313
+ const now = Date.now();
314
+ for (let i = 0; i < held.length;) {
315
+ const h = held[i];
316
+ if (force || now >= h.until || !meetingBusyBefore(h.job.id, h.job.t + h.job.ms)) {
317
+ held.splice(i, 1);
318
+ queue = queue.then(async () => { const own = ownWords(h.job, h.text); if (own) await emit(h.job, own, h.extra); }).catch(() => {});
319
+ } else i++;
320
+ }
321
+ if (holdTimer) { clearTimeout(holdTimer); holdTimer = null; }
322
+ if (held.length) { holdTimer = setTimeout(() => release(), 250); if (holdTimer.unref) holdTimer.unref(); }
323
+ }
324
+ async function deliver(job, text, extra) {
325
+ // only cuts placed by their capture's own clock (`exact`) are compared: a time taken from arrival says nothing about overlap
326
+ const meetingLive = echo && job.exact && job.source === 'mic' && Date.now() - (meetingAt.get(job.id) || 0) < MEETING_LIVE_MS;
327
+ if (!meetingLive) { await emit(job, text, extra); return; }
328
+ const own = ownWords(job, text);
329
+ if (!own) return;
330
+ if (!meetingBusyBefore(job.id, job.t + job.ms)) { await emit(job, own, extra); return; }
331
+ held.push({ job, text: own, extra, until: Date.now() + holdMs });
332
+ release();
333
+ }
334
+
335
+ /* LANGUAGE (decision 8: accented English is always English). With English (or detect) chosen, a cut that comes back in
336
+ * another language is run again forced to English, with the vocabulary alone as its prompt. The English text is kept and the
337
+ * first text goes with it as `heard_as`. When the run in English still is not English, the first text is kept. */
338
+ async function transcribe(d, job, file, voicedRatio) {
339
+ const lang = getLanguage(runtimeDir);
340
+ const args = { engine: d.engine || 'whisper', bin: d.bin, model: d.model, file, accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), '') };
341
+ const r = await run({ ...args, lang });
342
+ if (!r.ok) return r;
343
+ const text = liveGuard.onceEach(cleanText(r.text, { voicedRatio }));
344
+ if (!text || !(lang === 'en' || lang === 'auto') || !liveGuard.looksForeign(text, r.language)) return { ok: true, text };
345
+ const again = await run({ ...args, lang: 'en' });
346
+ const en = again.ok ? liveGuard.onceEach(cleanText(again.text, { voicedRatio })) : '';
347
+ if (!en || liveGuard.looksForeign(en, again.language)) return { ok: true, text };
348
+ return { ok: true, text: en, extra: { heard_as: { text, language: r.language || '' } } };
349
+ }
350
+
351
+ const cutEnd = new Map(); // `${id}|${source}` → where the last cut of that source ended (ms)
267
352
  function cut(b) {
268
353
  bufs.delete(b.id + '|' + b.source);
269
- if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) return false;
354
+ cutEnd.set(b.id + '|' + b.source, Math.max(cutEnd.get(b.id + '|' + b.source) || 0, b.t + b.ms));
355
+ // the caller learns how far this source is transcribed once the cut is done, in order, so a restart redoes a cut it lost
356
+ // A microphone line still held for the echo check is not written yet, so the progress stops at its start: a restart then
357
+ // transcribes it again rather than losing it (and the repetition guard drops the lines after it that were written).
358
+ const done = () => {
359
+ const waitFor = held.filter((h) => h.job.id === b.id && h.job.source === b.source).map((h) => h.job.t);
360
+ try { onCut({ id: b.id, source: b.source, until: Math.min(b.t + b.ms, ...waitFor) }); } catch { /* the caller's bookkeeping */ }
361
+ };
362
+ if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) { queue = queue.then(done).catch(() => {}); if (held.length) release(); return false; }
270
363
  if (waiting >= MAX_QUEUE) { onError({ id: b.id, source: b.source, error: 'transcription is falling behind, so a few seconds were skipped' }); return false; }
271
364
  const pcm = Buffer.concat(b.chunks);
272
- const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
365
+ const job = { id: b.id, source: b.source, t: b.t, ms: b.ms, ...(b.ts ? { exact: true } : {}) };
273
366
  const voicedRatio = b.ms > 0 ? b.voicedMs / b.ms : 1;
367
+ const q = { id: job.id, source: job.source, t: job.t };
368
+ inQueue.push(q);
274
369
  waiting++;
275
370
  queue = queue.then(async () => {
276
371
  const d = det();
@@ -279,15 +374,13 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
279
374
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
280
375
  try {
281
376
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
282
- const key = job.id + '|' + job.source;
283
377
  // `engine` is 'qwen' when the caller's detect chose Qwen3-ASR (lib/notes-qwen.js); `run` then sends the cut there
284
- const r = await run({ engine: d.engine || 'whisper', bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir), accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), lastText.get(key)) });
378
+ const r = await transcribe(d, job, file, voicedRatio);
285
379
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
286
- const text = cleanText(r.text, { voicedRatio });
287
- if (text) { lastText.set(key, text); await onText({ ...job, text }); }
380
+ if (r.text) await deliver(job, r.text, r.extra);
288
381
  } catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
289
382
  finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
290
- }).catch(() => {}).finally(() => { waiting--; });
383
+ }).catch(() => {}).finally(() => { waiting--; done(); const i = inQueue.indexOf(q); if (i >= 0) inQueue.splice(i, 1); if (held.length) release(); });
291
384
  return true;
292
385
  }
293
386
 
@@ -295,24 +388,34 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
295
388
  * One chunk from the page. `t` is the chunk's start, in ms from the note's recording start.
296
389
  * @returns {{ ok:true, cut:boolean }}
297
390
  */
298
- function push({ id, source, pcm, t = 0, final = false }) {
391
+ function push({ id, source, pcm, t = 0, final = false, exact = false }) {
299
392
  const k = id + '|' + source;
393
+ if (source === 'meeting' && pcm && pcm.length) meetingAt.set(id, Date.now());
394
+ // a chunk that names its own place and belongs before audio already cut comes too late for the live transcript: on its own it
395
+ // would be a fragment of a sentence already written. Its audio is kept, and the final pass after Stop reads it.
396
+ if (exact && !final && t + 1 < (cutEnd.get(k) || 0)) return { ok: true, cut: false, late: true };
300
397
  let b = bufs.get(k);
301
398
  const lv = pcm && pcm.length ? levels(pcm) : [];
302
- if (!b) { b = { id, source, chunks: [], ms: 0, voicedMs: 0, quietMs: 0, t, noise: noiseOf.has(source) ? noiseOf.get(source) : null }; bufs.set(k, b); }
399
+ if (!b) { b = { id, source, chunks: [], ts: exact ? [] : null, ms: 0, voicedMs: 0, quietMs: 0, t, noise: noiseOf.has(source) ? noiseOf.get(source) : null }; bufs.set(k, b); }
303
400
  for (const v of lv) {
304
401
  // the quiet level is learnt from quiet frames only, so speech at the very start is not taken for the room's noise
305
402
  const voiced = v > Math.max(MIN_RMS, (b.noise == null ? 0 : b.noise) * 3);
306
403
  if (voiced) { b.voicedMs += FRAME_MS; b.quietMs = 0; }
307
404
  else { b.quietMs += FRAME_MS; b.noise = b.noise == null ? v : b.noise * 0.9 + v * 0.1; }
308
405
  }
309
- if (pcm && pcm.length) { b.chunks.push(pcm); b.ms += (pcm.length / 2 / RATE) * 1000; }
406
+ if (pcm && pcm.length) {
407
+ // a chunk that names its own place (`exact`) and arrives after a later one goes back where it belongs in the buffer
408
+ const at = exact && b.ts && t < b.t + b.ms - 1 && t >= b.t ? b.ts.findIndex((x) => x > t) : -1;
409
+ if (at >= 0) { b.chunks.splice(at, 0, pcm); b.ts.splice(at, 0, t); } else { b.chunks.push(pcm); if (b.ts) b.ts.push(t); }
410
+ b.ms += (pcm.length / 2 / RATE) * 1000;
411
+ }
310
412
  if (b.noise != null) noiseOf.set(source, b.noise);
311
413
  // quiet before anyone speaks is not kept: only the last LEAD_MS of it stays in front of the speech
312
414
  if (!b.voicedMs && b.ms > LEAD_MS + 1000) {
313
415
  const keepBytes = Math.round(RATE * LEAD_MS / 1000) * 2;
314
416
  const all = Buffer.concat(b.chunks);
315
417
  b.chunks = [all.subarray(Math.max(0, all.length - keepBytes))];
418
+ if (b.ts) b.ts = [b.t + b.ms - LEAD_MS];
316
419
  b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
317
420
  }
318
421
  let did = false;
@@ -322,12 +425,25 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
322
425
  }
323
426
  /** Cut whatever is buffered for a note (the page stopped). */
324
427
  function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
428
+ /** Is audio of this note and source buffered and not cut yet? */
429
+ const has = (id, source) => bufs.has(id + '|' + source);
325
430
  /** Drop a note's buffers without transcribing them (the note was marked private or deleted). */
326
431
  function drop(id) {
327
432
  for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
328
433
  for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
434
+ for (let i = held.length - 1; i >= 0; i--) if (held[i].job.id === id) held.splice(i, 1);
435
+ heard.delete(id); meetingAt.delete(id);
436
+ for (const k of Array.from(cutEnd.keys())) if (k.startsWith(id + '|')) cutEnd.delete(k);
437
+ }
438
+ /** Settled: every queued cut is transcribed and every held line is out. */
439
+ async function idle() {
440
+ for (;;) {
441
+ const q = queue; await q;
442
+ if (held.length) { release(true); continue; }
443
+ if (q === queue) return;
444
+ }
329
445
  }
330
- return { push, flush, drop, pending: () => waiting, idle: () => queue };
446
+ return { push, flush, drop, has, pending: () => waiting + held.length, idle };
331
447
  }
332
448
 
333
449
  module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL,
@@ -520,7 +520,7 @@ function joinText(prev, next, names) {
520
520
  const tail = KEEP_CAP.test(next) || (names && names.has(word)) || PROPER_DAYS.test(word) ? next : next.charAt(0).toLowerCase() + next.slice(1);
521
521
  return askMark(head.replace(/[,\s]+$/, '') + ' ' + tail);
522
522
  }
523
- function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms = null, now = Date.now() } = {}) {
523
+ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms = null, heard_as = null, now = Date.now() } = {}) {
524
524
  const note = load(dir, id);
525
525
  if (!note) return err(404, 'no such note');
526
526
  if (note.private) return err(409, 'this note is marked private, so nothing more is recorded in it');
@@ -536,6 +536,8 @@ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms =
536
536
  const tr = note.transcript || (note.transcript = []);
537
537
  const tEnd = Number.isFinite(ms) && ms > 0 ? t + Math.round(ms) : null;
538
538
  const open = JOINS.has(src) && endsOpen(raw);
539
+ // the first text of a line that came back in another language and was run again in English (notes-recording-reliability, D8)
540
+ const heardAs = heard_as && typeof heard_as.text === 'string' && heard_as.text ? { text: heard_as.text.slice(0, MAX_PHRASE), language: String(heard_as.language || '').slice(0, 40) } : null;
539
541
  const out = [];
540
542
  // the first sentence may finish the line before it, from the same source
541
543
  if (JOINS.has(src)) {
@@ -558,7 +560,7 @@ function appendTranscript(dir, { id, text, source = 'speech', t: at = null, ms =
558
560
  const base = tr.length;
559
561
  const added = sentences.map((s, k) => { const isOpen = open && k === sentences.length - 1;
560
562
  return { at: iso(now), t, ...(tEnd != null ? { t_end: tEnd } : {}), text: s, source: src, cat: isOpen ? null : categoryOf(s, names), cat_by: 'local',
561
- ...(isOpen ? { open: true } : {}), index: base + k }; });
563
+ ...(isOpen ? { open: true } : {}), ...(heardAs ? { heard_as: heardAs } : {}), index: base + k }; });
562
564
  note.transcript = tr.concat(added.map(({ index, ...x }) => x));
563
565
  note.updated_at = iso(now);
564
566
  store(dir, note);
@@ -15,7 +15,7 @@
15
15
  * POST /api/notes/save { id, title?, body? } → { ok, updated_at, versions, added } each new line of the notes is also
16
16
  * a transcript line, source 'typed' (`added`, Run 8)
17
17
  * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
18
- * POST /api/notes/record { id, ws? } → { ok, recorded_from, clock_from, meeting_ms } `ws` is the repository the note's lines are checked against;
18
+ * POST /api/notes/record { id, ws? } → { ok, recorded_from, clock_from, meeting_ms, capture: { token, base_ms } } `ws` is the repository the note's lines are checked against;
19
19
  * clock_from is the first Listen moved on by the time listening was stopped
20
20
  * POST /api/notes/pause { id } → { ok, meeting_ms, state, paused_at, resume_until, ended_at, ended_by, sessions }
21
21
  * a resumable pause (Run 13, R51): the clock stops, the meeting stays open for one hour
@@ -28,7 +28,8 @@
28
28
  * ended. A row and a note carry state ('idle'|'recording'|'paused'|'ended'), paused_at, resume_until, ended_at, ended_by, sessions
29
29
  * and meeting.context (the snapshot of the meeting, lib/notes.js cleanContext; R59).
30
30
  * POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
31
- * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
31
+ * POST /api/notes/audio { id, source, pcm, final?, cap?, seq?, off? } → { ok, cut, dup? } 16 kHz mono 16-bit samples, base64; local Whisper (D91);
32
+ * cap/seq/off name the chunk's capture, sequence number and sample offset (lib/notes-capture.js)
32
33
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
33
34
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
34
35
  * POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
@@ -74,6 +75,7 @@
74
75
  const guard = require('./pm-guard.js');
75
76
  const notes = require('./notes.js');
76
77
  const whisper = require('./notes-whisper.js');
78
+ const notesCapture = require('./notes-capture.js');
77
79
  const notesRepo = require('./notes-repo.js');
78
80
  const notesAudio = require('./notes-audio.js');
79
81
  const notesEcho = require('./notes-echo.js');
@@ -381,6 +383,7 @@ function liveRun(ctx, args) {
381
383
  }
382
384
  let transcriber = null; let transcriberDir = null;
383
385
  const audioErrors = new Map(); // note id → the last thing that went wrong, for the page
386
+ const fedHere = new Set(); // `${id}|${source}|${token}` this process has had chunks of: the first one after a restart picks up what was lost
384
387
  function transcriberFor(ctx) {
385
388
  if (transcriber && transcriberDir === ctx.runtimeDir) return transcriber;
386
389
  transcriberDir = ctx.runtimeDir;
@@ -389,11 +392,27 @@ function transcriberFor(ctx) {
389
392
  detect: () => liveDetect(ctx),
390
393
  run: (args) => liveRun(ctx, args),
391
394
  vocab: ({ id }) => vocabFor(ctx, id),
395
+ // the note's meeting lines of the last minute, for the live echo check
396
+ recent: (id) => {
397
+ const r = notes.read(ctx.runtimeDir, id);
398
+ if (!r.ok) return [];
399
+ const tr = r.note.transcript || []; const last = tr.length ? tr[tr.length - 1].t || 0 : 0;
400
+ return tr.filter((x) => x && x.source === 'meeting' && (x.t || 0) >= last - 60000).map((x) => ({ source: 'meeting', t: x.t || 0, end: Number.isFinite(x.t_end) ? x.t_end : (x.t || 0), text: x.text }));
401
+ },
402
+ // the last line of a source, for the repetition guard after a restart
403
+ lastLine: (id, source) => {
404
+ const r = notes.read(ctx.runtimeDir, id);
405
+ const tr = r.ok ? r.note.transcript || [] : [];
406
+ for (let k = tr.length - 1; k >= 0; k--) if (tr[k] && tr[k].source === source) return { text: tr[k].text, t: tr[k].t || 0 };
407
+ return null;
408
+ },
392
409
  onText: async (c) => {
393
- const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t, ms: c.ms });
410
+ const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t, ms: c.ms, heard_as: c.heard_as });
394
411
  if (r.ok) { audioErrors.delete(c.id); arm(ctx, c.id); }
395
412
  },
396
413
  onError: (c) => { audioErrors.set(c.id, { at: new Date().toISOString(), error: c.error }); },
414
+ // how far the live transcription has cut each source, kept with the capture so a restart picks up what it had buffered
415
+ onCut: (c) => { try { notesCapture.setCut(ctx.runtimeDir, c.id, { source: c.source, sample: Math.round(c.until * whisper.RATE / 1000) }); } catch { /* best effort */ } },
397
416
  });
398
417
  return transcriber;
399
418
  }
@@ -669,8 +688,13 @@ async function handle(req, res, url, ctx) {
669
688
  const rm = resumeMsOf(ctx);
670
689
  // a pause that ran out ends the meeting first (final notes and all), then the record is refused
671
690
  endMeeting(ctx, id, 'timeout');
691
+ // one capture per note: another tab or window that is sending this note's audio keeps it (notes-recording-reliability)
692
+ if (notesCapture.busy(dir, id)) return done({ ok: false, status: 409, error: 'this meeting is already being recorded in another tab or window' });
672
693
  const r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id, resumeMs: rm });
673
694
  if (r.ok) {
695
+ // the page names each chunk's place from here: the capture's token, and the meeting time it starts at
696
+ const c = notesCapture.start(dir, id, { baseMs: r.meeting_ms });
697
+ if (c.ok) r.capture = { token: c.token, base_ms: c.base_ms };
674
698
  clearExpiry(id); swept.delete(id);
675
699
  // a new Listen tries the agent again when it could not start last time
676
700
  const cur = notes.read(dir, id);
@@ -685,6 +709,7 @@ async function handle(req, res, url, ctx) {
685
709
  }
686
710
  if (p === '/api/notes/pause') {
687
711
  const r = notes.pause(dir, { id, resumeMs: resumeMsOf(ctx) });
712
+ notesCapture.close(dir, id);
688
713
  // listening stopped: the minute timer stops, and what was said since the last minute is one more batch.
689
714
  // The meeting stays open: Wispr Flow and the final pass wait for Stop or for the hour to run out.
690
715
  if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); if (r.state === 'paused') armExpiry(ctx, id, r.resume_until); }
@@ -692,6 +717,7 @@ async function handle(req, res, url, ctx) {
692
717
  }
693
718
  if (p === '/api/notes/stop') {
694
719
  const r = notes.stop(dir, { id, resumeMs: resumeMsOf(ctx) });
720
+ notesCapture.close(dir, id);
695
721
  if (r.ok) { clearExpiry(id); swept.add(id); disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
696
722
  return done(r);
697
723
  }
@@ -718,15 +744,41 @@ async function handle(req, res, url, ctx) {
718
744
  if (pcm24 && pcm24.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
719
745
  if (!liveDetect(ctx).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
720
746
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
721
- const t = Math.max(0, notes.meetingMs(note.note) - ms);
747
+ // A chunk that names its capture (notes-recording-reliability) is placed at the capture's start plus its offset, and a sequence
748
+ // number already written is answered as done and neither written nor transcribed again. A chunk with no token keeps the old
749
+ // placement by arrival time, unless a capture with a token is live: then it comes from a second tab and is refused.
750
+ let t; let sample = null; let cap = null;
751
+ if (body.cap !== undefined) {
752
+ cap = notesCapture.check(dir, id, { token: String(body.cap), source, seq: body.seq });
753
+ if (!cap.ok) return done(cap);
754
+ if (cap.dup) return done({ ok: true, cut: false, dup: true });
755
+ const off = Number(body.off);
756
+ if (!Number.isInteger(off) || off < 0) return done({ ok: false, status: 400, error: 'a chunk needs its sample offset' });
757
+ sample = Math.round(cap.base_ms * whisper.RATE / 1000) + off;
758
+ t = (sample / whisper.RATE) * 1000;
759
+ } else {
760
+ if (notesCapture.tokened(dir, id)) return done({ ok: false, status: 409, error: 'this meeting is already being recorded in another tab or window' });
761
+ t = Math.max(0, notes.meetingMs(note.note) - ms);
762
+ }
722
763
  // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
723
764
  arm(ctx, id);
724
765
  // each source is also kept apart for the final pass after Stop, which deletes it when the PM keeps no audio (Run 9)
725
766
  // a 24 kHz chunk must cover the same time as the 16 kHz chunk (to a tenth of a second); one that does not is not kept
726
767
  const sameTime = !!pcm24 && pcm24.length > 0 && Math.abs(pcm24.length / 2 / notesAudio.HI_RATE - pcm.length / 2 / whisper.RATE) <= 0.1;
727
768
  if (pcm24 && pcm24.length && !sameTime) audioErrors.set(id, { at: new Date().toISOString(), error: 'the 24 kHz audio of one chunk did not match its 16 kHz audio, so the replay has a gap there' });
728
- { try { notesAudio.write(dir, { id, source, pcm, pcm24: sameTime ? pcm24 : null, t, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
729
- return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
769
+ { try { notesAudio.write(dir, { id, source, pcm, pcm24: sameTime ? pcm24 : null, t, sample, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
770
+ const tr = transcriberFor(ctx);
771
+ if (cap && !fedHere.has(id + '|' + source + '|' + body.cap)) {
772
+ fedHere.add(id + '|' + source + '|' + body.cap);
773
+ if (fedHere.size > 400) fedHere.delete(fedHere.values().next().value);
774
+ // after a restart, what this source said since its last cut was written but never transcribed: transcribe it first
775
+ const gap = notesCapture.untranscribed(dir, id, { source, before: sample });
776
+ const back = gap ? notesAudio.readSource(dir, id, source, gap.from, gap.to) : null;
777
+ if (back && back.pcm.length) tr.push({ id, source, pcm: back.pcm, t: (back.from / whisper.RATE) * 1000, exact: true });
778
+ }
779
+ const pushed = tr.push({ id, source, pcm, t, final: body.final === true, exact: !!cap });
780
+ if (cap) notesCapture.mark(dir, id, { source, seq: body.seq, end: sample + pcm.length / 2 });
781
+ return done(pushed);
730
782
  }
731
783
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
732
784
  if (p === '/api/notes/speaker') {
@@ -808,7 +860,7 @@ async function handle(req, res, url, ctx) {
808
860
  if (p === '/api/notes/private') {
809
861
  vocabCache.delete(id);
810
862
  const r = notes.setPrivate(dir, { id, private: body.private });
811
- if (r.ok && r.private) { clearExpiry(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
863
+ if (r.ok && r.private) { clearExpiry(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); notesCapture.remove(dir, id); }
812
864
  return done(r);
813
865
  }
814
866
  if (p === '/api/notes/summarize') {
@@ -837,7 +889,7 @@ async function handle(req, res, url, ctx) {
837
889
  }
838
890
  return done(r);
839
891
  }
840
- if (p === '/api/notes/delete') { clearExpiry(id); swept.delete(id); 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); }
892
+ if (p === '/api/notes/delete') { clearExpiry(id); swept.delete(id); 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); notesCapture.remove(dir, id); } return done(r); }
841
893
  if (p === '/api/notes/highlight') {
842
894
  // the PM's highlight: the transcript of the window, then Jev's label and the repository check of its text
843
895
  const r = notes.addHighlight(dir, { id, from: body.from, to: body.to });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.133.0",
9
+ "version": "0.134.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.133.0",
3
+ "version": "0.134.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",
@@ -3,7 +3,7 @@
3
3
  * (.calbar, .lens, .seg, .ib), the Today chip (.tdy), the lens header (.lh), the day timeline (.vday, .vb), the month grid
4
4
  * (.cal, .cd), the progress chart (svg.pg: progSVG, line 2712), the direction card (.fc.dir with .dial and .sevs), the key
5
5
  * result rows (.krg, .krt) and the swipe and key gestures. The styles are pm/console3.css (see pm/console3-lift.md).
6
- * This file keeps the data call and draws what the server sent. It reads GET /api/calendar-views only and writes nothing.
6
+ * This file keeps the data call and draws what the server sent. It reads GET /api/calendar-views only; its Record button starts, resumes or stops a recording through pm/note-dock.js.
7
7
  * A KR without a metric reads "no metric" and is not scored; an empty lens says why; nothing is invented.
8
8
  *
9
9
  * mountCalendarViews(host, ctx) is safe to call on every render. disposeCalendarViews(host) drops the host's state. */
@@ -18,6 +18,7 @@ import { renderCase } from './c3-case.js';
18
18
  import { makeXp, attachExpand } from './c3-expand.js';
19
19
  import { mountQuarter, mountYear, dispose as disposeStrategy } from './c3-cal-strategy.js'; // the Quarter and Year panes
20
20
  import { simDock, simStart, simState, isDemo } from './shell-recording.js';
21
+ import * as dock from './note-dock.js';
21
22
  import { drawCalendarPage } from './c3-calendar.js'; // the Calendar tab as a page about time (ctx.page)
22
23
 
23
24
  /* ── shared state of the /c3.html page (console3 wave C3) ──────────────────────────────────────────────────────────
@@ -597,18 +598,30 @@ function scrubWire(v, timed, end, t0, pph) {
597
598
  /* ── the stage under a day's cards on the Calendar tab: the mockup's Before, During and After (stageHtml line 2598) ── */
598
599
 
599
600
  const autoPhase = (e) => (endH(e) <= nowH() ? 'after' : timeOf(e.at) <= nowH() ? 'during' : 'before');
600
- const NOREC = 'Nothing on a page can record. Recording happens in the main app\'s note dock.';
601
601
  const IX2 = () => (typeof window !== 'undefined' && window.__c3cal2) || null; // c3-demo-cal2.js (demo only): the After notes, the Week grid, the progress rows
602
602
  const simOn = () => simState().mode !== 'idle';
603
- const noRecNode = () => el('div', { class: 'fold', 'data-cv': 'norec' }, c3ic('mic', 'sm'), NOREC + ' ', el('a', { href: '/', target: '_blank', rel: 'noopener', 'data-cv': 'main-app' }, 'Open the main app'));
603
+ const realMode = () => { try { return dock.recordState().mode; } catch { return 'idle'; } };
604
604
 
605
605
  /* the Record button of the During and After stages (the mockup's data-a="nlive"). In demo it drives the simulated recorder, which the top bar's
606
- * recording group shows (shell-recording.js). In real mode it is disabled: a page records nothing, so it says where recording happens. */
607
- function recordBtn(m) {
606
+ * recording group shows (shell-recording.js). In real mode it records through the note dock, the same recorder as the top bar's Record control:
607
+ * Record starts a note linked to this calendar event, Resume continues a paused meeting, Stop asks first. `ev` is the calendar cell's event. */
608
+ const asEvent = (ev) => ({ ...ev, id: baseId(ev), start: ev.start || ev.at, end: ev.end || null });
609
+ function recordBtn(m, ev, redraw) {
608
610
  const demo = isDemo(); const on = demo && simOn();
609
- return el('button', { type: 'button', class: 'ib pri', style: 'width:auto;padding:0 14px;gap:6px;display:inline-flex;align-items:center', 'data-cv': 'record', disabled: !demo,
610
- title: demo ? (on ? 'Stop and write final notes' : 'Record (a simulation: the demo records nothing)') : NOREC + ' Use the link below.',
611
- onClick: () => { if (simOn()) simDock.stopNow(); else simStart(m.title); } }, c3ic(on ? 'stop' : 'mic'), on ? 'Stop' : 'Record');
611
+ const mode = demo ? 'idle' : realMode();
612
+ const label = demo ? (on ? 'Stop' : 'Record') : mode === 'recording' ? 'Stop' : mode === 'paused' ? 'Resume' : 'Record';
613
+ const done = () => { if (redraw) setTimeout(redraw, 300); };
614
+ const real = async () => {
615
+ if (mode === 'recording') await dock.stopNow();
616
+ else if (mode === 'paused') await dock.resumeNow();
617
+ else if (ev) await dock.recordEvent(asEvent(ev));
618
+ done();
619
+ };
620
+ const title = demo ? (on ? 'Stop and write final notes' : 'Record (a simulation: the demo records nothing)') : mode === 'recording' ? 'Stop and write final notes' : mode === 'paused' ? 'Resume the paused meeting' : 'Record this meeting. Notes stay on this machine.';
621
+ const b = c3ib(title, label === 'Stop' ? 'stop' : 'mic', { 'data-cv': 'record', 'aria-label': label,
622
+ onClick: () => { if (!demo) { real(); return; } if (simOn()) simDock.stopNow(); else simStart(m.title); } }, 'pri c3cv-rec');
623
+ b.append(label);
624
+ return b;
612
625
  }
613
626
  const liveBody = () => [el('div', { class: 'wave', 'aria-hidden': 'true' }, ...new Array(28).fill(0).map(() => el('i'))),
614
627
  el('div', { class: 'term', 'data-cv': 'live-text' }, (IX2() && IX2().NOTES.live) || 'Dara: the keys are the real blocker, not the scope.\nYou: then we cut to three flows and keep 24 Oct.\n[highlight] decision: cut to three flows\n[caught] action: Dara confirms sandbox keys'),
@@ -618,9 +631,9 @@ const avN = (n) => el('span', { class: 'av', title: n }, String(n)[0]);
618
631
  /* the After stage's notes card: the mockup's notesView (line 1494). Decisions, actions with owner and due, routing, and who was told what. */
619
632
  function notesCard(host, r, m) {
620
633
  const N = isDemo() && IX2() && IX2().NOTES; const redraw = () => refreshSel(host, r);
621
- const head = el('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px' }, el('h3', null, c3ic('notes'), 'Notes'), recordBtn(m));
634
+ const head = el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('notes'), 'Notes'), recordBtn(m, selEvent(r), redraw));
622
635
  if (isDemo() && simOn()) return el('section', { class: 'card', 'data-cv': 'notes' }, head, ...liveBody());
623
- if (!N) return el('section', { class: 'card', 'data-cv': 'notes' }, head, el('div', { class: 'fold', 'data-cv': 'no-notes' }, c3ic('moon', 'sm'), 'No notes, decisions or actions are held for this meeting. This page reads the calendar and records nothing.'), isDemo() ? null : noRecNode());
636
+ if (!N) return el('section', { class: 'card', 'data-cv': 'notes' }, head, el('div', { class: 'fold', 'data-cv': 'no-notes' }, c3ic('moon', 'sm'), 'No notes, decisions or actions are held for this meeting. Press Record to take them. Notes stay on this machine.'));
624
637
  const nt = r.nt || (r.nt = { owners: {}, due: {}, logged: {}, diff: false, sent: false });
625
638
  const sel = (label, opts, val, on) => el('select', { 'aria-label': label, onChange: (e) => on(e.target.value) }, ...opts.map((x) => el('option', { value: x, selected: x === val }, x || 'No owner')));
626
639
  const small2 = 'font-size:var(--fs-2)';
@@ -681,8 +694,8 @@ function stageNode(host, r) {
681
694
  } else if (ph === 'during') {
682
695
  const sim = isDemo() && simOn();
683
696
  body = el('section', { class: 'card', 'data-cv': 'stage-during' },
684
- el('div', { style: 'display:flex;justify-content:space-between;align-items:center;gap:10px' }, el('h3', null, c3ic('mic'), 'Live notes'), recordBtn(m)),
685
- sim ? liveBody() : isDemo() ? el('div', { class: 'fold' }, c3ic('mic', 'sm'), 'press Record. Notes stay on this machine.') : noRecNode());
697
+ el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('mic'), 'Live notes'), recordBtn(m, e, () => refreshSel(host, r))),
698
+ sim ? liveBody() : el('div', { class: 'fold', 'data-cv': 'live-idle' }, c3ic('mic', 'sm'), realMode() === 'idle' ? 'Press Record. Notes stay on this machine.' : 'A meeting is ' + (realMode() === 'recording' ? 'recording' : 'paused') + '. Use the Record control in the top bar to mark a moment, pause or stop.'));
686
699
  } else {
687
700
  const D = m.demo ? [['Note to Priya: 24 Oct is conditional', 'out'], ['Note to design: scope decision', 'out']] : [];
688
701
  const drafts = el('section', { class: 'card', 'data-cv': 'stage-after' }, el('h3', null, c3ic('spark'), 'Drafted after the meeting'),
@@ -1699,6 +1699,25 @@
1699
1699
  .c3 .c3hold .hb.off{opacity:.55;cursor:not-allowed}
1700
1700
  .c3 .c3hold .hint{font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1701
1701
  .c3 .c3hold .cq{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;font:var(--fs-2) var(--mono)}
1702
+ /* The Record control, always in the top bar (shell-recording.js, real mode). Left, while a meeting records or is paused: the red .recg group
1703
+ * with the chip, Highlight, Pause or Resume, and Stop. Right, always: the neutral .recg.idle group with New and the arrow that lists today's meetings. */
1704
+ .c3 .c3rec{position:relative;display:inline-flex;align-items:stretch;gap:6px;z-index:31}
1705
+ .c3 .c3rec .recg.idle{color:var(--foreground);border-color:var(--border);background:var(--card)}
1706
+ .c3 .c3rec .recg.idle>*+*{border-left-color:var(--border)}
1707
+ .c3 .c3rec .recg.idle .ic{color:var(--destructive)}
1708
+ .c3 .c3rec .recg .ic{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
1709
+ .c3 .c3rec .recg .c3rec__chip{max-width:44ch}
1710
+ .c3 .c3rec .c3rec__eng{margin-left:6px;font-size:11px;color:var(--muted-foreground);white-space:nowrap}
1711
+ .c3.ph .c3rec .c3rec__eng{display:none}
1712
+ .c3 .c3rec .c3rec__off{opacity:.45;cursor:not-allowed}
1713
+ @media (hover:hover){.c3 .c3rec .recg.idle button:hover{background:var(--muted)}.c3 .c3rec .c3rec__off:hover{background:none}}
1714
+ .c3 .c3rec .recg button>span{display:inline-flex;align-items:center;gap:6px;min-width:0}
1715
+ /* the calendar's Record button (calendar-views.js recordBtn) and the card heads it sits in */
1716
+ .c3 .ib.c3cv-rec{width:auto;padding:0 14px;gap:6px;display:inline-flex;align-items:center}
1717
+ .c3 .c3cv-head{display:flex;justify-content:space-between;align-items:center;gap:10px}
1718
+ .c3 .c3rec .c3rec__pop{position:absolute;top:calc(100% + 6px);right:0;z-index:40;min-width:280px}
1719
+ .c3.ph .c3rec .c3rec__lbl{display:none}
1720
+ .c3.ph .c3rec .c3rec__live{order:-1}
1702
1721
  /* ===== the Work page (pm/pm-work-view.js, initiative console3-work-page): Focus, Upcoming by day, the weekly Review, offline work and the
1703
1722
  * shared actions. Tokens only; corners 6px on a control and 8px on a container. */
1704
1723
  .c3 .wk-min{min-width:0}