create-pmos 0.194.0 → 0.195.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/lib/notes-final.js +36 -9
- package/workspace/lib/notes-live-guard.js +30 -1
- package/workspace/lib/notes-whisper.js +11 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/test-lists/notes-silence-loops.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T08:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T08:29:05Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T08:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T08:29:05Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-13T08:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-13T08:29:05Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* setupCommand(runtimeDir) → the shell commands the PM runs once for the better engine, speech detection and speakers
|
|
6
6
|
* createFinal({ runtimeDir, onDone, onState, run?, detect?, qwen? }) → { queue(id, opts), cancel(id), state(id), idle() }
|
|
7
7
|
* parseWhisper(json) · parseParakeet(json) · splitAtPauses(pcm) · assignSpeakers(segs, diar) · dropEcho(mic, meeting)
|
|
8
|
-
* mergeSources(parts)
|
|
8
|
+
* mergeSources(parts) · dropSilent(segments, pcm)
|
|
9
9
|
* transcribeClip(runtimeDir, pcm, { prompt }) → { ok, engine, segments } one stretch through the same engine (notes-live-settle)
|
|
10
10
|
*
|
|
11
11
|
* WHY. The live transcript is cut every few seconds so that lines appear while people talk. A cut that short gives Whisper
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
* speakers: Speaker 1, Speaker 2 and so on. The microphone is the PM. In a room with one microphone and no meeting
|
|
27
27
|
* audio, the microphone is split instead.
|
|
28
28
|
* 4. The sources are merged in time order. A microphone line that repeats a meeting line said at the same moment is the
|
|
29
|
-
* meeting heard through the PM's speakers, and is dropped.
|
|
29
|
+
* meeting heard through the PM's speakers, and is dropped. A line with no speech under it, and a short line said again
|
|
30
|
+
* and again in one source, are the engine writing on quiet audio, and are dropped too (dropSilent, notes-live-guard dropLoops).
|
|
30
31
|
* The result goes to `onDone(id, final)`; lib/routes-notes.js stores it on the note and then asks the PM's agent session for
|
|
31
32
|
* the notes. One job runs at a time, so the model and the speaker separation never share the memory of an 8 GB machine.
|
|
32
33
|
*
|
|
@@ -41,6 +42,7 @@ const { execFile } = require('child_process');
|
|
|
41
42
|
const whisper = require('./notes-whisper.js');
|
|
42
43
|
const notesAudio = require('./notes-audio.js');
|
|
43
44
|
const notesQwen = require('./notes-qwen.js');
|
|
45
|
+
const liveGuard = require('./notes-live-guard.js');
|
|
44
46
|
|
|
45
47
|
const RUN_TIMEOUT_MS = 45 * 60 * 1000;
|
|
46
48
|
const PARAKEET_MODEL = 'mlx-community/parakeet-tdt-0.6b-v2';
|
|
@@ -130,15 +132,19 @@ function parseParakeet(json) {
|
|
|
130
132
|
/** The pieces Qwen3-ASR transcribes one at a time: [{ start, end }] in ms. A piece ends at a pause of PIECE_PAUSE_MS once it
|
|
131
133
|
* is PIECE_MIN_MS long, or at its quietest frame between PIECE_MIN_MS and PIECE_MAX_MS. Quiet at either end is left out,
|
|
132
134
|
* and a piece with less than PIECE_MIN_VOICED_MS of speech is dropped. */
|
|
133
|
-
|
|
135
|
+
/** The loudness of each 100 ms frame and whether it is speech. A frame is speech above three times the room's quiet (the 10th
|
|
136
|
+
* percentile), but never above a third of the loud frames (the 90th), so a recording that is speech from end to end still has
|
|
137
|
+
* its pauses. */
|
|
138
|
+
function voicedFrames(pcm, rate = whisper.RATE) {
|
|
134
139
|
const lv = whisper.levels(pcm, rate);
|
|
135
|
-
const F = 100;
|
|
136
|
-
// a frame is speech above three times the room's quiet (the 10th percentile), but never above a third of the loud frames
|
|
137
|
-
// (the 90th), so a recording that is speech from end to end still has its pauses
|
|
138
140
|
const sorted = lv.filter((v) => v > 0).sort((x, y) => x - y);
|
|
139
141
|
const at = (q) => (sorted.length ? sorted[Math.floor((sorted.length - 1) * q)] : 0);
|
|
140
142
|
const floor = Math.max(180, Math.min(at(0.1) * 3, at(0.9) * 0.3));
|
|
141
|
-
|
|
143
|
+
return { lv, voiced: lv.map((v) => v > floor) };
|
|
144
|
+
}
|
|
145
|
+
function splitAtPauses(pcm, rate = whisper.RATE) {
|
|
146
|
+
const { lv, voiced } = voicedFrames(pcm, rate);
|
|
147
|
+
const F = 100;
|
|
142
148
|
const out = [];
|
|
143
149
|
let i = 0;
|
|
144
150
|
while (i < lv.length) {
|
|
@@ -163,6 +169,24 @@ function splitAtPauses(pcm, rate = whisper.RATE) {
|
|
|
163
169
|
return out;
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
/** The segments that have speech under them (notes-silence-loops). Whisper reads the whole file in 30-second windows, and on a
|
|
173
|
+
* window with nobody speaking it can still write a line, often the same one each time ("Off, Briefing." every half minute). A
|
|
174
|
+
* segment with less than PIECE_MIN_VOICED_MS of speech between its start and end (SEGMENT_SLACK_MS either side, as the
|
|
175
|
+
* engine's times are not exact) was said by nobody, and is dropped. A segment with no times is kept. */
|
|
176
|
+
const SEGMENT_SLACK_MS = 500;
|
|
177
|
+
function dropSilent(segments, pcm, rate = whisper.RATE) {
|
|
178
|
+
if (!pcm || !pcm.length) return segments || [];
|
|
179
|
+
const { voiced } = voicedFrames(pcm, rate);
|
|
180
|
+
const F = 100;
|
|
181
|
+
return (segments || []).filter((g) => {
|
|
182
|
+
if (!(g.end > g.start)) return true;
|
|
183
|
+
const a = Math.max(0, Math.floor((g.start - SEGMENT_SLACK_MS) / F)); const b = Math.min(voiced.length, Math.ceil((g.end + SEGMENT_SLACK_MS) / F));
|
|
184
|
+
if (a >= voiced.length) return true; // past the end of the audio: the times are not this file's, so they say nothing
|
|
185
|
+
let v = 0; for (let k = a; k < b; k++) if (voiced[k]) v++;
|
|
186
|
+
return v * F >= PIECE_MIN_VOICED_MS;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
166
190
|
/** Each segment gets the speaker who talks most during it. `diar`: [{ start, end, speaker }] in seconds. Speakers are
|
|
167
191
|
* numbered in the order they first speak. → segments with `spk` (0, 1, …) and the number of speakers. */
|
|
168
192
|
function assignSpeakers(segs, diar) {
|
|
@@ -208,7 +232,8 @@ function mergeSources(parts) {
|
|
|
208
232
|
if (mic && meet) mic.segments = dropEcho(mic.segments, meet.segments);
|
|
209
233
|
const out = [];
|
|
210
234
|
for (const p of parts) {
|
|
211
|
-
|
|
235
|
+
// a short line said again and again in one source is the engine looping on quiet audio (notes-silence-loops)
|
|
236
|
+
for (const g of liveGuard.dropLoops(p.segments)) {
|
|
212
237
|
const spk = p.speakers > 1 && g.spk !== null && g.spk !== undefined ? p.source + '-' + (g.spk + 1) : p.source;
|
|
213
238
|
out.push({ t: g.start, end: g.end, text: g.text, source: p.source, spk });
|
|
214
239
|
}
|
|
@@ -290,6 +315,8 @@ async function bestTranscript({ run, qwen }, d, pcmFile, wav, tmp, { prompt = ''
|
|
|
290
315
|
: e === 'parakeet' ? await transcribeParakeet(run, d, wav, tmp) : await transcribeWhisper(run, d, wav, tmp, { lang: d.lang, prompt });
|
|
291
316
|
if (r.ok) break;
|
|
292
317
|
}
|
|
318
|
+
// Qwen3-ASR is given only the pieces with speech; Whisper and Parakeet read the whole file, quiet stretches included
|
|
319
|
+
if (r.ok && r.engine !== 'qwen') { try { r = { ...r, segments: dropSilent(r.segments, fs.readFileSync(pcmFile)) }; } catch { /* no samples to check */ } }
|
|
293
320
|
return r;
|
|
294
321
|
}
|
|
295
322
|
/**
|
|
@@ -394,5 +421,5 @@ function createFinal({ runtimeDir, onDone, onState = () => {}, run = runProgram,
|
|
|
394
421
|
};
|
|
395
422
|
}
|
|
396
423
|
|
|
397
|
-
module.exports = { detect, setupCommand, createFinal, transcribeClip, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, finalDir,
|
|
424
|
+
module.exports = { detect, setupCommand, createFinal, transcribeClip, runProgram, parseWhisper, parseParakeet, splitAtPauses, assignSpeakers, dropEcho, mergeSources, dropSilent, finalDir,
|
|
398
425
|
PARAKEET_MODEL, VAD_FILE, DIARIZE_THRESHOLD };
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* repeats(text, previous) → true when a cut only says again what the line before it said
|
|
6
6
|
* onceEach(text) → the text with a sentence repeated back to back kept once
|
|
7
7
|
* looksForeign(text, reported) → true when a cut that should be English came back in another language
|
|
8
|
+
* loopKey(text) → the key a short line is counted under for the loop guard, or '' when the line is never counted
|
|
9
|
+
* dropLoops(lines) → the lines without the short lines said LOOP_TIMES times or more (Whisper's loop on quiet audio)
|
|
8
10
|
*
|
|
9
11
|
* ECHO. On speakers the remote voice also reaches the microphone, so the microphone's cut holds the meeting's words a second
|
|
10
12
|
* time. lib/notes-final.js drops such lines after Stop (dropEcho); this is the same rule, live: a run of ECHO_RUN or more words
|
|
@@ -16,6 +18,12 @@
|
|
|
16
18
|
* the previous line's words, or end it, adds nothing. A short reply ("Yes.") said again later is kept: a short line counts
|
|
17
19
|
* as a repeat only within REPEAT_MS.
|
|
18
20
|
*
|
|
21
|
+
* LOOPS (notes-silence-loops). On a long quiet stretch, or on a voice too far from the microphone to make out, Whisper writes
|
|
22
|
+
* the same short line again and again, often words from its --prompt: "Off, Briefing." every half minute for a quarter of an
|
|
23
|
+
* hour. The lines are far apart, so the repetition guard above does not see them. A short line (LOOP_MAX_WORDS words or fewer)
|
|
24
|
+
* that comes back word for word LOOP_TIMES times or more in one source of one note is such a loop, and every copy goes. Lines made
|
|
25
|
+
* only of backchannel words ("Yeah, yeah.", "Okay.", "Thank you.") are never counted: people do say those many times.
|
|
26
|
+
*
|
|
19
27
|
* LANGUAGE (decision 8 of the console3 feedback plan: accented English is always English). A cut is foreign when the engine
|
|
20
28
|
* says another language, when most of its letters are not Latin, or when most of its words are Indonesian function words.
|
|
21
29
|
*/
|
|
@@ -24,6 +32,13 @@ const ECHO_WORDS = 0.5;
|
|
|
24
32
|
const ECHO_RUN = 4;
|
|
25
33
|
const ECHO_SLACK_MS = 1500;
|
|
26
34
|
const REPEAT_MS = 15000;
|
|
35
|
+
const LOOP_MAX_WORDS = 8;
|
|
36
|
+
const LOOP_TIMES = 3;
|
|
37
|
+
// words people say many times in a meeting; a line made only of them is never taken for a loop
|
|
38
|
+
const BACKCHANNEL = new Set(['yeah', 'yes', 'yep', 'yup', 'no', 'nope', 'ok', 'okay', 'right', 'sure', 'alright', 'thanks', 'thank', 'you',
|
|
39
|
+
'mm', 'mhm', 'hmm', 'uh', 'um', 'huh', 'oh', 'ah', 'so', 'and', 'but', 'well', 'i', 'see', 'got', 'it', 'good', 'great', 'cool', 'nice',
|
|
40
|
+
'exactly', 'true', 'correct', 'sorry', 'hi', 'hello', 'bye', 'agree', 'agreed', 'indeed', 'fine', 'perfect', 'wow', 'really', 'for', 'very',
|
|
41
|
+
'much', 'ya', 'iya', 'oke', 'betul', 'sip']);
|
|
27
42
|
|
|
28
43
|
const norm = (w) => String(w || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '');
|
|
29
44
|
const tokens = (text) => String(text || '').split(/\s+/).filter(Boolean);
|
|
@@ -98,6 +113,20 @@ function onceEach(text) {
|
|
|
98
113
|
return out.join('').trim();
|
|
99
114
|
}
|
|
100
115
|
|
|
116
|
+
/** The key a short line is counted under: its words, lower case, without punctuation. '' for a line that is long, empty, or
|
|
117
|
+
* only backchannel words, which the loop guard never counts. */
|
|
118
|
+
function loopKey(text) {
|
|
119
|
+
const w = tokens(text).map(norm).filter(Boolean);
|
|
120
|
+
if (!w.length || w.length > LOOP_MAX_WORDS || w.every((x) => BACKCHANNEL.has(x))) return '';
|
|
121
|
+
return w.join(' ');
|
|
122
|
+
}
|
|
123
|
+
/** `lines`: [{ text, … }] of one source. → the same lines without every copy of a short line said `times` times or more. */
|
|
124
|
+
function dropLoops(lines, { times = LOOP_TIMES } = {}) {
|
|
125
|
+
const count = new Map();
|
|
126
|
+
for (const l of lines || []) { const k = loopKey(l && l.text); if (k) count.set(k, (count.get(k) || 0) + 1); }
|
|
127
|
+
return (lines || []).filter((l) => { const k = loopKey(l && l.text); return !k || count.get(k) < times; });
|
|
128
|
+
}
|
|
129
|
+
|
|
101
130
|
const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'itu', 'tidak', 'dengan', 'untuk', 'ada', 'saya', 'kita', 'kami', 'akan', 'sudah', 'belum', 'juga',
|
|
102
131
|
'dari', 'ke', 'karena', 'jadi', 'bisa', 'apa', 'kalau', 'tapi', 'atau', 'nya', 'lagi', 'harus', 'sama', 'mau', 'gak', 'nggak', 'ya', 'kamu', 'mereka', 'dia', 'pada', 'dalam', 'seperti', 'lebih', 'masih', 'aja', 'saja']);
|
|
103
132
|
/** Did a cut that should be English come back in another language? `reported` is the engine's language name, when it says one. */
|
|
@@ -114,4 +143,4 @@ function looksForeign(text, reported) {
|
|
|
114
143
|
return false;
|
|
115
144
|
}
|
|
116
145
|
|
|
117
|
-
module.exports = { stripEcho, repeats, onceEach, looksForeign, sameWords, ECHO_OVERLAP, ECHO_WORDS, ECHO_RUN, REPEAT_MS };
|
|
146
|
+
module.exports = { stripEcho, repeats, onceEach, looksForeign, sameWords, loopKey, dropLoops, LOOP_TIMES, LOOP_MAX_WORDS, ECHO_OVERLAP, ECHO_WORDS, ECHO_RUN, REPEAT_MS };
|
|
@@ -32,7 +32,9 @@
|
|
|
32
32
|
*
|
|
33
33
|
* GUARDS (notes-recording-reliability, lib/notes-live-guard.js). A cut that repeats the line before it is dropped, a sentence
|
|
34
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.
|
|
35
|
+
* speakers), and with English chosen a cut that comes back in another language is run again in English. A short line that
|
|
36
|
+
* comes back word for word for the LOOP_TIMES-th time in a note's source is Whisper looping on quiet audio, and is dropped
|
|
37
|
+
* (the first copies stay live; the final pass after Stop drops them all).
|
|
36
38
|
*
|
|
37
39
|
* INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
|
|
38
40
|
* quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
|
|
@@ -266,6 +268,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
266
268
|
echo = true, holdMs = HOLD_MS, recent = null, lastLine = null }) {
|
|
267
269
|
const bufs = new Map(); // `${id}|${source}` → { id, source, chunks, ms, voicedMs, quietMs, t, noise }
|
|
268
270
|
const lastText = new Map(); // `${id}|${source}` → { text, t }: the last line heard there, for the repetition guard
|
|
271
|
+
const said = new Map(); // `${id}|${source}` → Map(loopKey → times written), for the loop guard
|
|
269
272
|
const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
|
|
270
273
|
const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
|
|
271
274
|
let queue = Promise.resolve();
|
|
@@ -302,6 +305,12 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
302
305
|
let prev = lastText.get(key);
|
|
303
306
|
if (!prev && typeof lastLine === 'function') { try { prev = lastLine(job.id, job.source) || null; } catch { prev = null; } }
|
|
304
307
|
if (liveGuard.repeats(text, prev, job.t)) return;
|
|
308
|
+
const lk = liveGuard.loopKey(text);
|
|
309
|
+
if (lk) {
|
|
310
|
+
const m = said.get(key) || new Map(); said.set(key, m);
|
|
311
|
+
const n = (m.get(lk) || 0) + 1; m.set(lk, n);
|
|
312
|
+
if (n >= liveGuard.LOOP_TIMES) return;
|
|
313
|
+
}
|
|
305
314
|
lastText.set(key, { text, t: job.t });
|
|
306
315
|
remember(job, text);
|
|
307
316
|
await onText({ ...job, text, ...(extra || {}) });
|
|
@@ -438,6 +447,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, onCut = ()
|
|
|
438
447
|
function drop(id) {
|
|
439
448
|
for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
|
|
440
449
|
for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
|
|
450
|
+
for (const k of Array.from(said.keys())) if (k.startsWith(id + '|')) said.delete(k);
|
|
441
451
|
for (let i = held.length - 1; i >= 0; i--) if (held[i].job.id === id) held.splice(i, 1);
|
|
442
452
|
heard.delete(id); meetingAt.delete(id); meetingTo.delete(id);
|
|
443
453
|
for (const k of Array.from(cutEnd.keys())) if (k.startsWith(id + '|')) cutEnd.delete(k);
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.195.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.195.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "notes-silence-loops: Whisper looping on quiet audio. The loop guard (live and in the final pass) and the final pass dropping segments with no speech under them (notes-loops.test.js).",
|
|
3
|
+
"modules": ["notes-loops.test.js"],
|
|
4
|
+
"seconds": { "notes-loops.test.js": 3 }
|
|
5
|
+
}
|