create-pmos 0.132.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/notes.md +19 -1
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +128 -0
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-whisper.js +132 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +60 -8
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/calendar-views.js +25 -12
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/console3.css +19 -0
- package/workspace/public/pm/shell-recording.js +122 -12
- package/workspace/public/pm/view-notes.js +7 -2
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
378
|
+
const r = await transcribe(d, job, file, voicedRatio);
|
|
285
379
|
if (!r.ok) { onError({ ...job, error: r.error }); return; }
|
|
286
|
-
|
|
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) {
|
|
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
|
|
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,
|
package/workspace/lib/notes.js
CHANGED
|
@@ -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);
|
|
@@ -60,6 +60,7 @@ const MODULES = Object.freeze([
|
|
|
60
60
|
'routes-c3-life', // console3 Lifecycle lens panels: newest evaluation per initiative, corrections per run, evidence sources (read-only)
|
|
61
61
|
'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
|
|
62
62
|
'routes-c3-reports', // console3 Reports dashboards: library, per-scope data, page-press layouts (layouts are the only write)
|
|
63
|
+
'routes-onboarding', // onboarding-scan-and-map slice 2: first run (scan read-only, structure, import into the overlay, undo, first Briefing)
|
|
63
64
|
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
64
65
|
'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
|
|
65
66
|
]);
|
|
@@ -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 }
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|