create-pmos 0.63.0 → 0.63.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.63.0",
3
+ "version": "0.63.1",
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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@8370b9c
1
+ kernel-source: pmos@4cf3dc9
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T22:58:51Z"}
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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T22:58:51Z"}
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-06T17:30:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T22:58:51Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -39,7 +39,14 @@ Local transcription uses whisper.cpp, a free program that runs on your machine.
39
39
 
40
40
  1. Open the settings and press **Set up in a terminal**. A terminal opens with the setup commands typed in. They install whisper.cpp with Homebrew and download the model into the workspace's own folder.
41
41
  2. Read the commands and press Enter in the terminal. The workspace does not run them for you.
42
- 3. When the download is done, open the settings again. It says **Whisper is set up**.
42
+ 3. Homebrew then says that whisper.cpp needs a model file. That is expected: the next command downloads it. Keep the terminal open until it says **Whisper is set up**.
43
+ 4. Open the settings again. It says **Whisper is set up**.
44
+
45
+ If lines come slowly, choose **Faster (about 550 MB)** under **Model** in the settings, press **Set up in a terminal** and press Enter to download it. It is the same model in a smaller form: it starts and runs faster on an M1 and makes a few more mistakes. **Most accurate (1.6 GB)** is the default.
46
+
47
+ Whisper writes in the language you choose in the settings under **Language spoken in the meeting**: English unless you change it, or Indonesian. **Detect for each line** lets Whisper guess for each few seconds of speech; it can guess wrong and write a line in another language, so choose the language when you know it.
48
+
49
+ If the download stops, press **Set up in a terminal** again and press Enter. The download continues where it stopped, and a part-downloaded model is never used.
43
50
 
44
51
  ## Use Jev for labels and checks
45
52
 
@@ -5,6 +5,8 @@
5
5
  * setupCommand(runtimeDir) → the shell commands the PM runs once: install whisper.cpp and download its model
6
6
  * createTranscriber({ runtimeDir, onText, run?, detect? }) → { push, flush, pending, stopAll }
7
7
  * wav(pcm, rate) · levels(pcm, rate) · cleanText(text)
8
+ * getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
9
+ * getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
8
10
  *
9
11
  * 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
12
  * 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
@@ -36,8 +38,16 @@ const LEAD_MS = 300; // speech that starts a cut keeps this much
36
38
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
37
39
  const MAX_QUEUE = 24;
38
40
  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 MODEL_BASE = 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/';
42
+ // 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
43
+ // makes a few more mistakes. Every cut starts the program again, so the time to load the model is paid on every line.
44
+ const MODELS = Object.freeze([
45
+ Object.freeze({ id: 'accurate', file: 'ggml-large-v3-turbo.bin', size: '1.6 GB', label: 'Most accurate (1.6 GB)' }),
46
+ Object.freeze({ id: 'fast', file: 'ggml-large-v3-turbo-q5_0.bin', size: 'about 550 MB', label: 'Faster (about 550 MB)' }),
47
+ ]);
48
+ const DEFAULT_MODEL = 'accurate';
49
+ const MODEL_FILE = MODELS[0].file;
50
+ const MODEL_URL = MODEL_BASE + MODEL_FILE;
41
51
  const BIN_NAMES = ['whisper-cli', 'whisper-cpp'];
42
52
  const BIN_DIRS = ['/opt/homebrew/bin', '/usr/local/bin'];
43
53
 
@@ -55,16 +65,31 @@ function findBin(env) {
55
65
  function findModel(runtimeDir, env) {
56
66
  if (env.PMOS_WHISPER_MODEL) return isFile(env.PMOS_WHISPER_MODEL) ? env.PMOS_WHISPER_MODEL : null;
57
67
  const dir = modelDir(runtimeDir);
58
- if (isFile(path.join(dir, MODEL_FILE))) return path.join(dir, MODEL_FILE);
68
+ const chosen = modelOf(runtimeDir);
69
+ if (isFile(path.join(dir, chosen.file))) return path.join(dir, chosen.file);
70
+ // a model the PM chose and has not downloaded yet is missing, so the setup downloads it; with no choice made, any
71
+ // whole model in the folder will do
72
+ if (readSettings(runtimeDir).model) return null;
59
73
  let names = [];
60
74
  try { names = fs.readdirSync(dir).filter((f) => /^ggml-.+\.bin$/.test(f)).sort(); } catch { /* none */ }
61
75
  return names.length ? path.join(dir, names[names.length - 1]) : null;
62
76
  }
63
77
 
64
- /** The commands the PM runs once, in their own terminal. The server never runs them. */
78
+ /** The commands the PM runs once, in their own terminal. The server never runs them.
79
+ *
80
+ * Homebrew's own note after the install says that no model is downloaded; the next command is that download. It goes to a
81
+ * `.part` file that is renamed only when it is complete, so a stopped download never reads as ready, and `-C -` continues
82
+ * a stopped download when the PM runs the commands again. A program already installed is not installed again. */
65
83
  function setupCommand(runtimeDir) {
66
84
  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;
85
+ const m = modelOf(runtimeDir);
86
+ const file = path.join(dir, m.file);
87
+ return '{ command -v whisper-cli >/dev/null 2>&1 || brew install whisper-cpp; }'
88
+ + ' && mkdir -p ' + shq(dir)
89
+ + ' && echo ' + shq('Downloading the Whisper model (' + m.size + '). Keep this terminal open.')
90
+ + ' && curl -L --fail -C - -# -o ' + shq(file + '.part') + ' ' + MODEL_BASE + m.file
91
+ + ' && mv ' + shq(file + '.part') + ' ' + shq(file)
92
+ + ' && echo ' + shq('Whisper is set up. Open the Notes settings again.');
68
93
  }
69
94
 
70
95
  /** Is local transcription ready? `missing` names what is not there: 'program', 'model'. */
@@ -74,7 +99,7 @@ function detect(runtimeDir, { env = process.env } = {}) {
74
99
  const missing = [];
75
100
  if (!bin) missing.push('program');
76
101
  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' };
102
+ return { ready: missing.length === 0, bin, model, missing, setup: setupCommand(runtimeDir), model_dir: modelDir(runtimeDir), model_size: modelOf(runtimeDir).size };
78
103
  }
79
104
 
80
105
  /** A mono 16-bit PCM buffer as a WAV file. */
@@ -109,11 +134,48 @@ function cleanText(text) {
109
134
  return t;
110
135
  }
111
136
 
137
+ /* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
138
+ * few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
139
+ * the PM picks the language of the meeting once (English by default) and every cut uses it. PMOS_WHISPER_LANG wins. */
140
+ const LANGUAGES = Object.freeze([['en', 'English'], ['id', 'Indonesian'], ['auto', 'Detect for each line']]);
141
+ const DEFAULT_LANGUAGE = 'en';
142
+ const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'whisper-settings.json');
143
+ function readSettings(runtimeDir) {
144
+ try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; }
145
+ }
146
+ function writeSettings(runtimeDir, patch) {
147
+ const f = settingsFile(runtimeDir);
148
+ fs.mkdirSync(path.dirname(f), { recursive: true });
149
+ const tmp = f + '.' + process.pid + '.tmp';
150
+ fs.writeFileSync(tmp, JSON.stringify({ ...readSettings(runtimeDir), ...patch }), { mode: 0o600 });
151
+ fs.renameSync(tmp, f);
152
+ }
153
+ function getLanguage(runtimeDir, { env = process.env } = {}) {
154
+ if (env.PMOS_WHISPER_LANG) return env.PMOS_WHISPER_LANG;
155
+ const c = readSettings(runtimeDir).language;
156
+ return LANGUAGES.some(([k]) => k === c) ? c : DEFAULT_LANGUAGE;
157
+ }
158
+ function setLanguage(runtimeDir, code) {
159
+ if (!LANGUAGES.some(([k]) => k === code)) return { ok: false, error: 'the language must be one of ' + LANGUAGES.map(([k]) => k).join(', ') };
160
+ writeSettings(runtimeDir, { language: code });
161
+ return { ok: true, language: code };
162
+ }
163
+ function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
164
+ function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
165
+ function setModel(runtimeDir, id) {
166
+ if (!MODELS.some((m) => m.id === id)) return { ok: false, error: 'the model must be one of ' + MODELS.map((m) => m.id).join(', ') };
167
+ writeSettings(runtimeDir, { model: id });
168
+ return { ok: true, model: id };
169
+ }
170
+
112
171
  /** 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)));
172
+ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
173
+ // The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
174
+ // Greedy decoding (-bs 1 -bo 1) instead of the program's default five-way beam search makes each line several times
175
+ // faster for a small loss on hard words.
176
+ const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
115
177
  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) => {
178
+ execFile(bin, ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, '-bs', '1', '-bo', '1'], { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => {
117
179
  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
180
  else resolve({ ok: true, text: String(stdout || '') });
119
181
  });
@@ -152,7 +214,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
152
214
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
153
215
  try {
154
216
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
155
- const r = await run({ bin: d.bin, model: d.model, file });
217
+ const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
156
218
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
157
219
  const text = cleanText(r.text);
158
220
  if (text) await onText({ ...job, text });
@@ -197,4 +259,4 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
197
259
  return { push, flush, drop, pending: () => waiting, idle: () => queue };
198
260
  }
199
261
 
200
- module.exports = { detect, setupCommand, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
262
+ module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL, createTranscriber, runWhisper, wav, levels, cleanText, modelDir, RATE, MODEL_FILE, MODEL_URL, MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS };
@@ -13,6 +13,7 @@
13
13
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
14
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
15
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
+ * { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
16
17
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
17
18
  * POST /api/notes/private { id, private } → { ok, private }
18
19
  * POST /api/notes/summarize { id } → { ok, summary } the local summary
@@ -106,7 +107,9 @@ function transcriberFor(ctx) {
106
107
  }
107
108
  function engineInfo(ctx, w) {
108
109
  const d = whisper.detect(ctx.runtimeDir);
109
- const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size };
110
+ const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
111
+ language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
112
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
110
113
  if (!w) return { whisper: out, jev: null };
111
114
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
112
115
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -183,6 +186,13 @@ async function handle(req, res, url, ctx) {
183
186
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
184
187
  if (p === '/api/notes/switch') {
185
188
  const o = wsOf();
189
+ // the spoken language belongs to this machine, not to a repository
190
+ if (body.language !== undefined || body.model !== undefined) {
191
+ const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
192
+ const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
193
+ if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
194
+ if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
195
+ }
186
196
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
187
197
  jevNotes.setSwitch(dir, o.w.root, { jev: typeof body.jev === 'boolean' ? body.jev : undefined, check: typeof body.check === 'boolean' ? body.check : undefined });
188
198
  ctx.json(res, { ok: true, ...engineInfo(ctx, o.w) });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.0",
3
+ "version": "0.63.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.63.0",
3
+ "version": "0.63.1",
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",
@@ -504,8 +504,8 @@ async function chooseSources(ctx, n) {
504
504
  }
505
505
  async function setSwitch(ctx, patch) {
506
506
  const ws = wsOf(ctx);
507
- if (!ws) return;
508
- const r = await pmPost('/api/notes/switch', { ws, ...patch });
507
+ if (!ws && patch.language === undefined && patch.model === undefined) return;
508
+ const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
509
509
  if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
510
  ctx.setState({});
511
511
  return r;
@@ -527,7 +527,11 @@ function openSettings(ctx, n) {
527
527
  el('div', { class: 'nt-set__head' }, 'Local transcription'),
528
528
  el('p', { class: 'nt-hint' }, !w ? 'Reading …' : w.ready ? 'Whisper is set up. The microphone and the meeting are transcribed on this machine.'
529
529
  : 'Whisper is not set up (' + w.missing.map((m) => (m === 'program' ? 'the program' : 'the model, ' + w.model_size)).join(' and ') + ' missing). The setup installs whisper.cpp with Homebrew and downloads the model.'),
530
- w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null));
530
+ w && w.models ? field({ label: 'Model', kind: 'select', value: w.model, options: w.models,
531
+ onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
532
+ w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
533
+ w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
534
+ onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
531
535
  if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
532
536
  else {
533
537
  const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });