create-pmos 0.61.1 → 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/first-paint-audit.js +4 -2
  7. package/workspace/guide/index.md +6 -3
  8. package/workspace/guide/layout.md +5 -4
  9. package/workspace/guide/notes.md +72 -0
  10. package/workspace/guide/people.md +70 -0
  11. package/workspace/lib/calendar-memory.js +2 -1
  12. package/workspace/lib/jev-notes.js +162 -0
  13. package/workspace/lib/notes-repo.js +133 -0
  14. package/workspace/lib/notes-whisper.js +200 -0
  15. package/workspace/lib/notes.js +511 -0
  16. package/workspace/lib/people-cli.js +135 -0
  17. package/workspace/lib/people-store.js +123 -0
  18. package/workspace/lib/people.js +399 -0
  19. package/workspace/lib/pmos-verbs.js +3 -0
  20. package/workspace/lib/route-modules.js +1 -0
  21. package/workspace/lib/routes-notes.js +230 -0
  22. package/workspace/package-lock.json +1 -1
  23. package/workspace/package.json +1 -1
  24. package/workspace/public/pm/app.js +2 -2
  25. package/workspace/public/pm/components.css +441 -0
  26. package/workspace/public/pm/dest-people.js +385 -0
  27. package/workspace/public/pm/dest-settings.js +44 -3
  28. package/workspace/public/pm/frame-copy.js +7 -0
  29. package/workspace/public/pm/initiative-sheet.js +5 -2
  30. package/workspace/public/pm/initiatives-copy.js +1 -0
  31. package/workspace/public/pm/item-sheet.js +18 -1
  32. package/workspace/public/pm/panel-cockpit.js +3 -0
  33. package/workspace/public/pm/people-link.js +106 -0
  34. package/workspace/public/pm/registry.js +2 -0
  35. package/workspace/public/pm/today-agenda.js +3 -1
  36. package/workspace/public/pm/today-day.js +2 -0
  37. package/workspace/public/pm/topbar.js +4 -3
  38. package/workspace/public/pm/view-notes.js +723 -0
  39. package/workspace/replaced-tests.json +14 -0
  40. package/workspace/server.js +30 -1
  41. package/workspace/skills/gather-requirements.skill +5 -3
  42. package/workspace/skills/jira-sync.skill +5 -4
  43. package/workspace/skills/live-notes.skill +3 -1
  44. package/workspace/skills/morning-brief.skill +5 -3
  45. package/workspace/test-lists/meeting-notes.json +15 -0
  46. package/workspace/test-lists/people-directory.json +6 -0
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+ /* notes-whisper.js — local speech-to-text for the Notes view: whisper.cpp on the PM's machine (meeting-notes Run 2, D91).
3
+ *
4
+ * detect(runtimeDir, { env }) → { ready, bin, model, missing, setup, model_dir }
5
+ * setupCommand(runtimeDir) → the shell commands the PM runs once: install whisper.cpp and download its model
6
+ * createTranscriber({ runtimeDir, onText, run?, detect? }) → { push, flush, pending, stopAll }
7
+ * wav(pcm, rate) · levels(pcm, rate) · cleanText(text)
8
+ *
9
+ * WHAT IT IS. The page captures the microphone and, when the PM picks it, a meeting tab's audio. It sends each source as
10
+ * 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
11
+ * at a pause, writes the cut to a temporary WAV file, runs `whisper-cli` on it, deletes the file and hands the words to
12
+ * `onText`. One run at a time, in the order the cuts were made, so the transcript keeps the order things were said.
13
+ *
14
+ * WHAT IT NEVER DOES. It keeps no audio: a buffer is dropped once it is cut, and the WAV file is deleted when the program
15
+ * ends, whatever the outcome. It makes no request: the PM installs whisper.cpp and downloads the model in their own terminal
16
+ * (setupCommand), and this module only looks for the program and the model file. It opens no network module.
17
+ *
18
+ * CUTTING AT PAUSES. A 100 ms frame counts as speech when its loudness is well above the quiet level measured so far. A cut
19
+ * happens when at least MIN_CUT_MS is buffered and the last PAUSE_MS were quiet, or when MAX_CUT_MS is buffered, or when the
20
+ * page stops. A cut with less than MIN_VOICED_MS of speech is dropped without running the program, which also keeps
21
+ * Whisper from inventing words for silence.
22
+ */
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+ const { execFile } = require('child_process');
28
+
29
+ const RATE = 16000;
30
+ const FRAME_MS = 100;
31
+ const MIN_CUT_MS = 2000;
32
+ const MAX_CUT_MS = 12000;
33
+ const PAUSE_MS = 600;
34
+ const MIN_VOICED_MS = 300;
35
+ const LEAD_MS = 300; // speech that starts a cut keeps this much of the quiet before it
36
+ const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
37
+ const MAX_QUEUE = 24;
38
+ const RUN_TIMEOUT_MS = 120000;
39
+ const MODEL_FILE = 'ggml-large-v3-turbo.bin';
40
+ const MODEL_URL = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/' + MODEL_FILE;
41
+ const BIN_NAMES = ['whisper-cli', 'whisper-cpp'];
42
+ const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
43
+
44
+ const modelDir = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper');
45
+ const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
46
+ const isExec = (p) => { try { fs.accessSync(p, fs.constants.X_OK); return isFile(p); } catch { return false; } };
47
+ const shq = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
48
+
49
+ function findBin(env) {
50
+ if (env.PMOS_WHISPER_BIN) return isExec(env.PMOS_WHISPER_BIN) ? env.PMOS_WHISPER_BIN : null;
51
+ const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean).concat(BIN_DIRS);
52
+ for (const name of BIN_NAMES) for (const d of dirs) { const p = path.join(d, name); if (isExec(p)) return p; }
53
+ return null;
54
+ }
55
+ function findModel(runtimeDir, env) {
56
+ if (env.PMOS_WHISPER_MODEL) return isFile(env.PMOS_WHISPER_MODEL) ? env.PMOS_WHISPER_MODEL : null;
57
+ const dir = modelDir(runtimeDir);
58
+ if (isFile(path.join(dir, MODEL_FILE))) return path.join(dir, MODEL_FILE);
59
+ let names = [];
60
+ try { names = fs.readdirSync(dir).filter((f) => /^ggml-.+\.bin$/.test(f)).sort(); } catch { /* none */ }
61
+ return names.length ? path.join(dir, names[names.length - 1]) : null;
62
+ }
63
+
64
+ /** The commands the PM runs once, in their own terminal. The server never runs them. */
65
+ function setupCommand(runtimeDir) {
66
+ const dir = modelDir(runtimeDir);
67
+ return 'brew install whisper-cpp && mkdir -p ' + shq(dir) + ' && curl -L --fail -o ' + shq(path.join(dir, MODEL_FILE)) + ' ' + MODEL_URL;
68
+ }
69
+
70
+ /** Is local transcription ready? `missing` names what is not there: 'program', 'model'. */
71
+ function detect(runtimeDir, { env = process.env } = {}) {
72
+ const bin = findBin(env);
73
+ const model = findModel(runtimeDir, env);
74
+ const missing = [];
75
+ if (!bin) missing.push('program');
76
+ if (!model) missing.push('model');
77
+ return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: '1.6 GB' };
78
+ }
79
+
80
+ /** A mono 16-bit PCM buffer as a WAV file. */
81
+ function wav(pcm, rate = RATE) {
82
+ const h = Buffer.alloc(44);
83
+ h.write('RIFF', 0); h.writeUInt32LE(36 + pcm.length, 4); h.write('WAVE', 8);
84
+ h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
85
+ h.writeUInt32LE(rate, 24); h.writeUInt32LE(rate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
86
+ h.write('data', 36); h.writeUInt32LE(pcm.length, 40);
87
+ return Buffer.concat([h, pcm]);
88
+ }
89
+ /** The loudness (RMS, 16-bit units) of each 100 ms frame. */
90
+ function levels(pcm, rate = RATE) {
91
+ const per = Math.round(rate * FRAME_MS / 1000);
92
+ const n = Math.floor(pcm.length / 2);
93
+ const out = [];
94
+ for (let i = 0; i < n; i += per) {
95
+ const end = Math.min(n, i + per); let sum = 0;
96
+ for (let k = i; k < end; k++) { const v = pcm.readInt16LE(k * 2); sum += v * v; }
97
+ out.push(Math.sqrt(sum / Math.max(1, end - i)));
98
+ }
99
+ return out;
100
+ }
101
+ // Whisper marks what is not speech in brackets, and on near-silence it is known to produce stock closing lines
102
+ const NOT_SPEECH = /\[[^\]]*\]|\([^)]*\)|[♪♫]+|>>/g;
103
+ const STOCK = /^(?:thanks? (?:you )?(?:so much )?for watching[.!]?|please subscribe[.!]?|subtitles? by .*|transcribed by .*)$/i;
104
+ /** The words of a run, without the non-speech marks. Empty when nothing was said. */
105
+ function cleanText(text) {
106
+ const t = String(text || '').split(/\r?\n/).map((l) => l.replace(/^\s*\[\d[\d:.]*\s*-->\s*[\d:.]*\]\s*/, '')).join(' ')
107
+ .replace(NOT_SPEECH, ' ').replace(/\s+/g, ' ').trim();
108
+ if (!/[\p{L}\p{N}]/u.test(t) || STOCK.test(t)) return '';
109
+ return t;
110
+ }
111
+
112
+ /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
113
+ function runWhisper({ bin, model, file, lang = process.env.PMOS_WHISPER_LANG || 'auto' }) {
114
+ const threads = String(Math.max(2, Math.min(8, (os.cpus() || []).length || 4)));
115
+ return new Promise((resolve) => {
116
+ execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
117
+ if (e) resolve({ ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) });
118
+ else resolve({ ok: true, text: String(stdout || '') });
119
+ });
120
+ });
121
+ }
122
+
123
+ /**
124
+ * The buffers and the one queue.
125
+ * @param {object} a
126
+ * @param {string} a.runtimeDir
127
+ * @param {(cut:{id,source,t,text,ms}) => void} a.onText called with the words of each cut, in order
128
+ * @param {(cut:{id,source,error}) => void} [a.onError]
129
+ * @param {Function} [a.run] runWhisper, replaceable in the suites
130
+ * @param {Function} [a.detect] detect, replaceable in the suites
131
+ */
132
+ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir) }) {
133
+ const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
134
+ const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
135
+ let queue = Promise.resolve();
136
+ let waiting = 0;
137
+ const tmpDir = path.join(runtimeDir, 'notes', 'audio');
138
+ // a cut left by a process that ended while the program ran is deleted when the next transcriber starts
139
+ 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 */ }
140
+
141
+ function cut(b) {
142
+ bufs.delete(b.id + '|' + b.source);
143
+ if (b.voicedMs < MIN_VOICED_MS || !b.chunks.length) return false;
144
+ if (waiting >= MAX_QUEUE) { onError({ id: b.id, source: b.source, error: 'transcription is falling behind, so a few seconds were skipped' }); return false; }
145
+ const pcm = Buffer.concat(b.chunks);
146
+ const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
147
+ waiting++;
148
+ queue = queue.then(async () => {
149
+ const d = det();
150
+ if (!d.ready) { onError({ ...job, error: 'local transcription is not set up' }); return; }
151
+ fs.mkdirSync(tmpDir, { recursive: true });
152
+ const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
153
+ try {
154
+ fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
155
+ const r = await run({ bin: d.bin, model: d.model, file });
156
+ if (!r.ok) { onError({ ...job, error: r.error }); return; }
157
+ const text = cleanText(r.text);
158
+ if (text) await onText({ ...job, text });
159
+ } catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
160
+ finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
161
+ }).catch(() => {}).finally(() => { waiting--; });
162
+ return true;
163
+ }
164
+
165
+ /**
166
+ * One chunk from the page. `t` is the chunk's start, in ms from the note's recording start.
167
+ * @returns {{ ok:true, cut:boolean }}
168
+ */
169
+ function push({ id, source, pcm, t = 0, final = false }) {
170
+ const k = id + '|' + source;
171
+ let b = bufs.get(k);
172
+ const lv = pcm && pcm.length ? levels(pcm) : [];
173
+ 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); }
174
+ for (const v of lv) {
175
+ // the quiet level is learnt from quiet frames only, so speech at the very start is not taken for the room's noise
176
+ const voiced = v > Math.max(MIN_RMS, (b.noise == null ? 0 : b.noise) * 3);
177
+ if (voiced) { b.voicedMs += FRAME_MS; b.quietMs = 0; }
178
+ else { b.quietMs += FRAME_MS; b.noise = b.noise == null ? v : b.noise * 0.9 + v * 0.1; }
179
+ }
180
+ if (pcm && pcm.length) { b.chunks.push(pcm); b.ms += (pcm.length / 2 / RATE) * 1000; }
181
+ if (b.noise != null) noiseOf.set(source, b.noise);
182
+ // quiet before anyone speaks is not kept: only the last LEAD_MS of it stays in front of the speech
183
+ if (!b.voicedMs && b.ms > LEAD_MS + 1000) {
184
+ const keepBytes = Math.round(RATE * LEAD_MS / 1000) * 2;
185
+ const all = Buffer.concat(b.chunks);
186
+ b.chunks = [all.subarray(Math.max(0, all.length - keepBytes))];
187
+ b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
188
+ }
189
+ let did = false;
190
+ if (final || b.ms >= MAX_CUT_MS || (b.ms >= MIN_CUT_MS && b.voicedMs >= MIN_VOICED_MS && b.quietMs >= PAUSE_MS)) did = cut(b);
191
+ return { ok: true, cut: did };
192
+ }
193
+ /** Cut whatever is buffered for a note (the page stopped). */
194
+ function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
195
+ /** Drop a note's buffers without transcribing them (the note was marked private or deleted). */
196
+ function drop(id) { for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k); }
197
+ return { push, flush, drop, pending: () => waiting, idle: () => queue };
198
+ }
199
+
200
+ module.exports = { detect, setupCommand, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };