create-pmos 0.75.0 → 0.76.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.75.0",
3
+ "version": "0.76.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@a8b70bb
1
+ kernel-source: pmos@26e93ea
2
2
  assembled: 2026-10-07
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-07T07:33:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T08:15:37Z"}
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-07T07:33:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T08:15:37Z"}
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-07T07:33:59Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T08:15:37Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,19 +1,20 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
4
+ description: "How to take notes in a meeting: one note top to bottom, typed notes as timed lines, key points written each minute and labelled by Jev, delete a note; listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
5
5
  timestamp: 2026-10-07
6
6
  ---
7
7
 
8
8
  # Guide: notes
9
9
 
10
- The Notes view is one screen for one meeting. Your own notes are on the left, with the decisions, actions and questions that the workspace catches while people talk. The transcript is on the right. Notes are kept on this machine, outside every repository.
10
+ The Notes view shows one meeting, top to bottom. A bar at the top shows whether notes are being taken, with **Stop** or **Listen** and the **⋯** menu. Under it are the title, the day, the meeting, the initiative and the people. Then come the overview, the key points, the decisions, the next steps, the open questions and your own notes, each item with the meeting time it was said or written. The transcript folds away at the end. Notes are kept on this machine, outside every repository.
11
11
 
12
12
  ## Start a meeting
13
13
 
14
14
  1. Press ⌘K, type **notes** and press Enter. The view opens as a tab. It is the same in every repository.
15
- 2. Press **New meeting**. Type a title. Type your own notes in the box below it; they steer the summary. The note saves itself when you stop typing.
16
- 3. Press **All notes** to open an earlier note. Press the settings button for local transcription, Jev, private and delete.
15
+ 2. Press **New meeting**. Type a title.
16
+ 3. Under **Your notes**, type a note and press Enter. Each note is its own line, with the meeting time you added it at. To remove a line, press the **x** beside it. Each line also shows in the transcript as **You, typed** and is labelled like a spoken line, so a decision or a next step you type goes into its section. Your notes steer the summary, and they save at once.
17
+ 4. Press **All notes** to open an earlier note. The **⋯** menu has Copy as Markdown, New meeting, the meeting and the initiative, Summary by agent, Mark private, Settings and **Delete note**.
17
18
 
18
19
  ## Listen
19
20
 
@@ -22,13 +23,17 @@ The Notes view is one screen for one meeting. Your own notes are on the left, wi
22
23
  - **The meeting**: the other people in an online meeting. Select the meeting's tab or window next, and tick **Share tab audio**.
23
24
  2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
24
25
  3. Each line shows in the transcript a few seconds after it is said, with who said it, the time and its label. Your lines are **You**. The meeting's lines are **Meeting**: click the name to change it. The transcript cannot tell the people in the meeting apart.
25
- 4. Press **Stop** to end. The summary shows at the top of your notes.
26
+ 4. Press **Stop** to end. The summary shows at the top of your notes. To carry on, press **Listen again**: the clock carries on from where it stopped, and the time between does not count. Line times and the kept audio use the same clock.
26
27
 
27
28
  The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
28
29
 
29
30
  ## What is caught
30
31
 
31
- Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
32
+ Every minute, your agent session reads that minute of the transcript, with the minutes before it, and writes it as **Key points**: short, complete sentences. Jev then labels each point as a decision, a next step, an open question or a point, and the repository check runs on the points that qualify. A minute almost always ends in the middle of a conversation. A point that is still going on shows **… continuing** and gets no label yet; the next minute finishes it. At **Stop**, every point is finished and labelled.
33
+
34
+ When the agent session does not write a minute's points within two minutes, that minute's lines are labelled as below. When no agent session can start, or the note is private, the lines are labelled on this machine, and Jev labels the clear ones once a minute. A line that ends in the middle of a sentence waits for the rest of it.
35
+
36
+ After **Stop**, your agent session writes the **Overview** at the top. Until it is written, and when no agent session can start, the overview is picked from what was said, on this machine. Facts and statements with a number go under **Facts mentioned**.
32
37
 
33
38
  - To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
34
39
  - When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
@@ -95,12 +100,12 @@ Under the title, a note shows two lines: **Meeting** and **Initiative**.
95
100
 
96
101
  When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
97
102
 
98
- To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: type your notes, read the transcript, and press **Stop** when the meeting ends. Press **Minimize** or Esc to make it small again, and **Open in Notes** to see it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
103
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The note stays small at the bottom right of the workspace, with a red dot, its title and its time, on every page. Press it to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays at the bottom right after a reload. When it no longer listens, press the x beside it to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
99
104
 
100
105
  ## Dictate into your notes
101
106
 
102
- 1. Put the cursor where the text must go, then press **Dictate** and speak.
103
- 2. Say **new line** or **new paragraph** to start one. Say **scratch that** to remove the phrase you just said.
107
+ 1. Press **Dictate** and speak. The words go into the note field, at the cursor.
108
+ 2. Say **new line** to add what you said as a note line and start the next one. Say **scratch that** to remove the phrase you just said.
104
109
  3. Press **Stop dictating** when you are done.
105
110
 
106
111
  ## Copy, private and delete
@@ -108,7 +113,7 @@ To take notes for a calendar event, open the event's detail on the Agenda and pr
108
113
  - **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
109
114
  - In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
110
115
  - **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
111
- - **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
116
+ - **Delete note** in the **⋯** menu, or **Delete** on a row of **All notes**, removes the note, its transcript, its summaries and its audio from this machine after you confirm. The note's summary goes from the calendar and the initiative. You cannot undo it. Deleting a note that is not being taken does not stop the one that is.
112
117
 
113
118
  ## What stays on your machine
114
119
 
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * detect(runtimeDir, { env }) → { ready, bin, model, missing, setup, model_dir }
5
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)
6
+ * createTranscriber({ runtimeDir, onText, run?, detect?, vocab?, prompt? }) → { push, flush, drop, pending, idle }
7
+ * wav(pcm, rate) · levels(pcm, rate) · cleanText(text, { voicedRatio }) · promptFor(vocab, previous)
8
8
  * getLanguage(runtimeDir) · setLanguage(runtimeDir, code) the spoken language Whisper writes in (LANGUAGES)
9
9
  * getModel(runtimeDir) · setModel(runtimeDir, id) the model: accurate (1.6 GB) or faster (about 550 MB) (MODELS)
10
+ * getAccuracy(runtimeDir) · setAccuracy(runtimeDir, id) the decoding: fast (greedy, default) or accurate (beam search) (ACCURACIES)
10
11
  *
11
12
  * WHAT IT IS. The page captures the microphone and, when the PM picks it, a meeting tab's audio. It sends each source as
12
13
  * 16 kHz mono 16-bit samples to the server on the loopback address. This module keeps a buffer per note and source, cuts it
@@ -20,7 +21,17 @@
20
21
  * CUTTING AT PAUSES. A 100 ms frame counts as speech when its loudness is well above the quiet level measured so far. A cut
21
22
  * 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
22
23
  * page stops. A cut with less than MIN_VOICED_MS of speech is dropped without running the program, which also keeps
23
- * Whisper from inventing words for silence.
24
+ * Whisper from inventing words for silence. A pause of LONG_PAUSE_MS also cuts a short phrase, so it is not held back.
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
+ * lib/notes.js joins a cut that still ends mid-sentence to the one before it.
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.
31
+ *
32
+ * INVENTED LINES. whisper.cpp's no-speech threshold (`-nth`) drops segments it thinks are silence. A cut that is mostly
33
+ * quiet and comes back as only "Thank you." or "you" is a known invention and is dropped (cleanText). A program too old
34
+ * for `-nth` is run again without it, and is remembered.
24
35
  */
25
36
  const fs = require('fs');
26
37
  const os = require('os');
@@ -30,9 +41,13 @@ const { execFile } = require('child_process');
30
41
 
31
42
  const RATE = 16000;
32
43
  const FRAME_MS = 100;
33
- const MIN_CUT_MS = 2000;
44
+ const MIN_CUT_MS = 4000;
34
45
  const MAX_CUT_MS = 12000;
35
- const PAUSE_MS = 600;
46
+ const PAUSE_MS = 1000;
47
+ const LONG_PAUSE_MS = 2000; // a pause this long cuts whatever was said, however short
48
+ const PROMPT_MAX = 200; // characters of --prompt: the vocabulary first, then the end of the line before
49
+ const NO_SPEECH_THOLD = '0.6';
50
+ const LOW_VOICED_RATIO = 0.4; // a cut with less speech than this, as a share of its length, is mostly quiet
36
51
  const MIN_VOICED_MS = 300;
37
52
  const LEAD_MS = 300; // speech that starts a cut keeps this much of the quiet before it
38
53
  const MIN_RMS = 180; // in 16-bit units; below this a frame is quiet whatever the room
@@ -126,13 +141,33 @@ function levels(pcm, rate = RATE) {
126
141
  // Whisper marks what is not speech in brackets, and on near-silence it is known to produce stock closing lines
127
142
  const NOT_SPEECH = /\[[^\]]*\]|\([^)]*\)|[♪♫]+|>>/g;
128
143
  const STOCK = /^(?:thanks? (?:you )?(?:so much )?for watching[.!]?|please subscribe[.!]?|subtitles? by .*|transcribed by .*)$/i;
129
- /** The words of a run, without the non-speech marks. Empty when nothing was said. */
130
- function cleanText(text) {
144
+ // what Whisper writes for a mostly quiet cut: a lone thanks, "you", "bye", "okay"; dropped only when the cut was mostly quiet
145
+ const QUIET_STOCK = /^(?:(?:thank you|thanks)(?: (?:so much|very much|everyone|all))?|you|bye(?: bye)?|okay|ok|oh|uh|um|hmm|so)(?:[.!?,…]+(?:\s+(?:(?:thank you|thanks)(?: (?:so much|very much))?|you|bye|okay))?)*[.!?…]*$/i;
146
+ /** The words of a run, without the non-speech marks. Empty when nothing was said, or when a mostly quiet cut
147
+ * (`voicedRatio` under LOW_VOICED_RATIO) came back as only a stock line such as "Thank you." */
148
+ function cleanText(text, { voicedRatio = 1 } = {}) {
131
149
  const t = String(text || '').split(/\r?\n/).map((l) => l.replace(/^\s*\[\d[\d:.]*\s*-->\s*[\d:.]*\]\s*/, '')).join(' ')
132
150
  .replace(NOT_SPEECH, ' ').replace(/\s+/g, ' ').trim();
133
151
  if (!/[\p{L}\p{N}]/u.test(t) || STOCK.test(t)) return '';
152
+ if (voicedRatio < LOW_VOICED_RATIO && QUIET_STOCK.test(t)) return '';
134
153
  return t;
135
154
  }
155
+ /** The --prompt for one run: the vocabulary, then the end of the previous line, at most PROMPT_MAX characters. '' when
156
+ * there is neither. The vocabulary keeps at most two thirds of the room when there is a previous line. */
157
+ function promptFor(vocab, previous) {
158
+ const v = String(vocab || '').replace(/\s+/g, ' ').trim().replace(/[.,;:\s]+$/, '');
159
+ const p = String(previous || '').replace(/\s+/g, ' ').trim();
160
+ const cutAt = (s, n, fromEnd) => {
161
+ if (s.length <= n) return s;
162
+ if (fromEnd) { const x = s.slice(s.length - n); const sp = x.indexOf(' '); return (sp > 0 && sp < 30 ? x.slice(sp + 1) : x).trim(); }
163
+ const x = s.slice(0, n); const c = Math.max(x.lastIndexOf(','), x.lastIndexOf(' ')); return (c > n / 2 ? x.slice(0, c) : x).replace(/[.,;:\s]+$/, '');
164
+ };
165
+ if (!p) return cutAt(v, PROMPT_MAX, false);
166
+ if (!v) return cutAt(p, PROMPT_MAX, true);
167
+ const vv = cutAt(v, Math.min(v.length, Math.floor(PROMPT_MAX * 2 / 3)), false);
168
+ const room = PROMPT_MAX - vv.length - 2;
169
+ return room > 10 ? vv + '. ' + cutAt(p, room, true) : vv;
170
+ }
136
171
 
137
172
  /* THE SPOKEN LANGUAGE. Whisper writes in the language it is told. With 'auto' it guesses the language of each cut, and on a
138
173
  * few seconds of accented speech it can guess wrong and write the line in that language, which reads as a translation. So
@@ -160,6 +195,19 @@ function setLanguage(runtimeDir, code) {
160
195
  writeSettings(runtimeDir, { language: code });
161
196
  return { ok: true, language: code };
162
197
  }
198
+ /* THE DECODING. Greedy (fast, the default) or a five-way beam search (accurate): slower on every line, better on hard words. */
199
+ const ACCURACIES = Object.freeze([
200
+ Object.freeze({ id: 'fast', label: 'Faster (greedy decoding)', args: ['-bs', '1', '-bo', '1'] }),
201
+ Object.freeze({ id: 'accurate', label: 'More accurate (beam search, slower)', args: ['-bs', '5', '-bo', '5'] }),
202
+ ]);
203
+ const DEFAULT_ACCURACY = 'fast';
204
+ function accuracyOf(runtimeDir) { const id = readSettings(runtimeDir).accuracy; return ACCURACIES.find((a) => a.id === id) || ACCURACIES.find((a) => a.id === DEFAULT_ACCURACY); }
205
+ function getAccuracy(runtimeDir) { return accuracyOf(runtimeDir).id; }
206
+ function setAccuracy(runtimeDir, id) {
207
+ if (!ACCURACIES.some((a) => a.id === id)) return { ok: false, error: 'the accuracy must be one of ' + ACCURACIES.map((a) => a.id).join(', ') };
208
+ writeSettings(runtimeDir, { accuracy: id });
209
+ return { ok: true, accuracy: id };
210
+ }
163
211
  function modelOf(runtimeDir) { const id = readSettings(runtimeDir).model; return MODELS.find((m) => m.id === id) || MODELS.find((m) => m.id === DEFAULT_MODEL); }
164
212
  function getModel(runtimeDir) { return modelOf(runtimeDir).id; }
165
213
  function setModel(runtimeDir, id) {
@@ -168,18 +216,32 @@ function setModel(runtimeDir, id) {
168
216
  return { ok: true, model: id };
169
217
  }
170
218
 
171
- /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }. */
172
- function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
219
+ const noNth = new Set(); // programs too old for -nth, found when they refused it
220
+ /** Run the program on one WAV file. Resolves { ok, text } or { ok: false, error }.
221
+ * `accuracy` is an ACCURACIES id; `prompt` is the --prompt text, left out when empty. */
222
+ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE, accuracy = DEFAULT_ACCURACY, prompt = '' }) {
173
223
  // The GPU does most of the work on Apple silicon, so four threads are enough; more only compete with the browser.
174
224
  // 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.
225
+ // faster for a small loss on hard words; the accurate setting uses the beam search.
176
226
  const threads = String(Math.max(2, Math.min(4, (os.cpus() || []).length || 4)));
177
- return new Promise((resolve) => {
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) => {
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) });
180
- else resolve({ ok: true, text: String(stdout || '') });
181
- });
227
+ const dec = (ACCURACIES.find((a) => a.id === accuracy) || ACCURACIES[0]).args;
228
+ const base = ['-m', model, '-f', file, '-nt', '-np', '-l', lang, '-t', threads, ...dec];
229
+ const extra = prompt ? ['--prompt', String(prompt)] : [];
230
+ const once = (args) => new Promise((resolve) => {
231
+ execFile(bin, args, { timeout: RUN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (e, stdout, stderr) => resolve({ e, stdout: String(stdout || ''), stderr: String(stderr || '') }));
182
232
  });
233
+ const done = ({ e, stdout, stderr }) => (e
234
+ ? { ok: false, error: e.killed ? 'the transcription took too long' : String((stderr || e.message || 'the program failed')).trim().split('\n').pop().slice(0, 200) }
235
+ : { ok: true, text: stdout });
236
+ const refused = (r) => /unknown argument|unrecognized option|invalid option/i.test(r.stderr) && /-nth|no-speech/i.test(r.stderr);
237
+ return (async () => {
238
+ if (!noNth.has(bin)) {
239
+ const r = await once([...base, '-nth', NO_SPEECH_THOLD, ...extra]);
240
+ if (!refused(r)) return done(r);
241
+ noNth.add(bin);
242
+ }
243
+ return done(await once([...base, ...extra]));
244
+ })();
183
245
  }
184
246
 
185
247
  /**
@@ -191,8 +253,10 @@ function runWhisper({ bin, model, file, lang = DEFAULT_LANGUAGE }) {
191
253
  * @param {Function} [a.run] runWhisper, replaceable in the suites
192
254
  * @param {Function} [a.detect] detect, replaceable in the suites
193
255
  */
194
- function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir) }) {
256
+ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWhisper, detect: det = () => detect(runtimeDir), vocab = null, prompt = '' }) {
195
257
  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
259
+ const vocabOf = (job) => { if (typeof vocab !== 'function') return prompt || ''; try { return String(vocab({ id: job.id, source: job.source }) || ''); } catch { return prompt || ''; } };
196
260
  const noiseOf = new Map(); // source → the quiet level last measured, so a new cut starts from it
197
261
  let queue = Promise.resolve();
198
262
  let waiting = 0;
@@ -206,6 +270,7 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
206
270
  if (waiting >= MAX_QUEUE) { onError({ id: b.id, source: b.source, error: 'transcription is falling behind, so a few seconds were skipped' }); return false; }
207
271
  const pcm = Buffer.concat(b.chunks);
208
272
  const job = { id: b.id, source: b.source, t: b.t, ms: b.ms };
273
+ const voicedRatio = b.ms > 0 ? b.voicedMs / b.ms : 1;
209
274
  waiting++;
210
275
  queue = queue.then(async () => {
211
276
  const d = det();
@@ -214,10 +279,11 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
214
279
  const file = path.join(tmpDir, 'cut-' + crypto.randomBytes(6).toString('hex') + '.wav');
215
280
  try {
216
281
  fs.writeFileSync(file, wav(pcm), { mode: 0o600 });
217
- const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir) });
282
+ const key = job.id + '|' + job.source;
283
+ const r = await run({ bin: d.bin, model: d.model, file, lang: getLanguage(runtimeDir), accuracy: getAccuracy(runtimeDir), prompt: promptFor(vocabOf(job), lastText.get(key)) });
218
284
  if (!r.ok) { onError({ ...job, error: r.error }); return; }
219
- const text = cleanText(r.text);
220
- if (text) await onText({ ...job, text });
285
+ const text = cleanText(r.text, { voicedRatio });
286
+ if (text) { lastText.set(key, text); await onText({ ...job, text }); }
221
287
  } catch (e) { onError({ ...job, error: String((e && e.message) || e) }); }
222
288
  finally { try { fs.unlinkSync(file); } catch { /* already gone */ } }
223
289
  }).catch(() => {}).finally(() => { waiting--; });
@@ -249,14 +315,20 @@ function createTranscriber({ runtimeDir, onText, onError = () => {}, run = runWh
249
315
  b.t += b.ms - LEAD_MS; b.ms = LEAD_MS;
250
316
  }
251
317
  let did = false;
252
- if (final || b.ms >= MAX_CUT_MS || (b.ms >= MIN_CUT_MS && b.voicedMs >= MIN_VOICED_MS && b.quietMs >= PAUSE_MS)) did = cut(b);
318
+ const spoke = b.voicedMs >= MIN_VOICED_MS;
319
+ if (final || b.ms >= MAX_CUT_MS || (spoke && b.ms >= MIN_CUT_MS && b.quietMs >= PAUSE_MS) || (spoke && b.quietMs >= LONG_PAUSE_MS)) did = cut(b);
253
320
  return { ok: true, cut: did };
254
321
  }
255
322
  /** Cut whatever is buffered for a note (the page stopped). */
256
323
  function flush(id) { for (const b of Array.from(bufs.values())) if (b.id === id) cut(b); }
257
324
  /** Drop a note's buffers without transcribing them (the note was marked private or deleted). */
258
- function drop(id) { for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k); }
325
+ function drop(id) {
326
+ for (const [k, b] of Array.from(bufs.entries())) if (b.id === id) bufs.delete(k);
327
+ for (const k of Array.from(lastText.keys())) if (k.startsWith(id + '|')) lastText.delete(k);
328
+ }
259
329
  return { push, flush, drop, pending: () => waiting, idle: () => queue };
260
330
  }
261
331
 
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 };
332
+ module.exports = { detect, setupCommand, getLanguage, setLanguage, LANGUAGES, DEFAULT_LANGUAGE, getModel, setModel, MODELS, DEFAULT_MODEL,
333
+ getAccuracy, setAccuracy, ACCURACIES, DEFAULT_ACCURACY, createTranscriber, runWhisper, wav, levels, cleanText, promptFor, modelDir, RATE, MODEL_FILE, MODEL_URL,
334
+ MIN_CUT_MS, MAX_CUT_MS, PAUSE_MS, LONG_PAUSE_MS, PROMPT_MAX, LOW_VOICED_RATIO, NO_SPEECH_THOLD };