create-pmos 0.62.0 → 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.
- 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/guide/index.md +1 -0
- package/workspace/guide/layout.md +1 -1
- package/workspace/guide/notes.md +72 -0
- package/workspace/lib/jev-notes.js +162 -0
- package/workspace/lib/notes-repo.js +133 -0
- package/workspace/lib/notes-whisper.js +200 -0
- package/workspace/lib/notes.js +511 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +230 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +272 -0
- package/workspace/public/pm/panel-cockpit.js +3 -0
- package/workspace/public/pm/view-notes.js +723 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +3 -1
- package/workspace/test-lists/meeting-notes.json +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.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-06T17:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T17:30:54Z"}
|
|
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:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T17:30:54Z"}
|
|
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:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T17:30:54Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/index.md
CHANGED
|
@@ -48,6 +48,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
48
48
|
- [Validate a repository](validate-with-typesafe.md): what each kind of document is compared with, how to read the verdict, and what leaves your machine.
|
|
49
49
|
- [Today and the proactive engine](proactive-engine.md): what Today finds, what you can do with an item, the settings, the commands and the limits.
|
|
50
50
|
- [Live notes](live-notes.md): take notes in a meeting, confirm each talking point, and what goes to Jev when you turn it on.
|
|
51
|
+
- [Notes](notes.md): listen to a meeting on this machine, see decisions, actions and questions caught and checked against the repository, and get a summary.
|
|
51
52
|
- [Find knowledge without reading whole files](knowledge-graph.md): what `pmos kg` finds, its commands, how to read its numbers, and its limits.
|
|
52
53
|
- [Initiatives: the Roadmap, the List and the initiative sheet](initiatives-screen.md): how to read and order the initiatives of a repository, open the sheet, make a new initiative, and review the cycle.
|
|
53
54
|
- [Initiatives and the Roadmap: Now, Next and Later](initiatives-roadmap.md): what day one shows, how an agent proposes a move, and `pmos initiative` and `pmos measure` from a terminal.
|
|
@@ -127,7 +127,7 @@ The address options `?layout=legacy` and `?fixture=cockpit` keep the older layou
|
|
|
127
127
|
|
|
128
128
|
The rail is 64 pixels wide and shows one tile for each repository. A dot on a tile means that repository has something that needs you. The selected repository has no dot, because the list beside it shows what needs you. The `+` tile adds a repository. The rail has no tools menu: the repository list and Validate are in Settings, and everything else is in ⌘K.
|
|
129
129
|
|
|
130
|
-
Today, Validate, live notes and Add repository open as a tab, not in place of the list. Choose the same one again and the existing tab comes to the front.
|
|
130
|
+
Today, Validate, live notes, notes and Add repository open as a tab, not in place of the list. Choose the same one again and the existing tab comes to the front.
|
|
131
131
|
|
|
132
132
|
Press ⌘K (Ctrl-K) to jump to anything: an initiative, a repository, an open tab, a session or a tool. Type to filter, use the arrow keys to choose, and press Enter. Esc closes it.
|
|
133
133
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
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, and get a summary."
|
|
5
|
+
timestamp: 2026-10-06
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Guide: notes
|
|
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.
|
|
11
|
+
|
|
12
|
+
## Start a meeting
|
|
13
|
+
|
|
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.
|
|
17
|
+
|
|
18
|
+
## Listen
|
|
19
|
+
|
|
20
|
+
1. Press **Listen**. Select what to listen to:
|
|
21
|
+
- **My microphone**: what you say.
|
|
22
|
+
- **The meeting**: the other people in an online meeting. Select the meeting's tab or window next, and tick **Share tab audio**.
|
|
23
|
+
2. Press **Start**. The header shows a red dot, the time, and the sources. Tell people that you are taking notes.
|
|
24
|
+
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
|
+
|
|
27
|
+
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
|
+
## What is caught
|
|
30
|
+
|
|
31
|
+
Each line gets a label: decision, action, question, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, statements with a number go under **Facts mentioned**.
|
|
32
|
+
|
|
33
|
+
- To remove an item that does not belong, press the **x** beside it.
|
|
34
|
+
- 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.
|
|
35
|
+
|
|
36
|
+
## Set up local transcription
|
|
37
|
+
|
|
38
|
+
Local transcription uses whisper.cpp, a free program that runs on your machine. A Mac with Apple silicon (M1 or later) and 8 GB of memory is enough. The model is about 1.6 GB on disk and uses about 3 GB of memory while it runs.
|
|
39
|
+
|
|
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
|
+
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**.
|
|
43
|
+
|
|
44
|
+
## Use Jev for labels and checks
|
|
45
|
+
|
|
46
|
+
Without Jev, the workspace labels each line with its own rules and finds the closest file by shared words. Jev labels better and can say whether a line agrees with the file.
|
|
47
|
+
|
|
48
|
+
1. Save a TypeSafe key and switch on validation for the repository (see [Validate with TypeSafe](validate-with-typesafe.md)).
|
|
49
|
+
2. Open the settings and switch on **Label lines with Jev**. Switch on **Check notes against this repository with Jev** for the checks too. Both are off until you switch them on, for each repository.
|
|
50
|
+
|
|
51
|
+
With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
|
|
52
|
+
|
|
53
|
+
## Dictate into your notes
|
|
54
|
+
|
|
55
|
+
1. Put the cursor where the text must go, then press **Dictate** and speak.
|
|
56
|
+
2. Say **new line** or **new paragraph** to start one. Say **scratch that** to remove the phrase you just said.
|
|
57
|
+
3. Press **Stop dictating** when you are done.
|
|
58
|
+
|
|
59
|
+
## Copy, private and delete
|
|
60
|
+
|
|
61
|
+
- **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
|
|
62
|
+
- In the settings, **Mark private** stops listening at once. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
|
|
63
|
+
- **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.
|
|
64
|
+
- **Delete** removes the note, its transcript and its summaries from this machine after you confirm. You cannot undo it.
|
|
65
|
+
|
|
66
|
+
## What stays on your machine
|
|
67
|
+
|
|
68
|
+
- Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes the file at once. The browser's own recognition is used only on the device, never in the cloud.
|
|
69
|
+
- Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
|
|
70
|
+
- Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
|
|
71
|
+
|
|
72
|
+
Live notes is a different tool: it maps each talking point to an initiative. See [Live notes](live-notes.md).
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jev-notes.js — the one bridge from the Notes view to Jev (meeting-notes Run 2, D91).
|
|
3
|
+
*
|
|
4
|
+
* gatesFor(key, flags) · checkGatesFor(key, flags) · senderFor(key, flags, askOpts) · infoFor(present, flags)
|
|
5
|
+
* label({ ask, key, askOpts, text, pause }) → { cat, p } | { cat: null, reason }
|
|
6
|
+
* compare({ ask, key, askOpts, text, passage, pause }) → { verdict: 'agrees'|'differs'|'related', words } | { verdict: null, reason }
|
|
7
|
+
* getSwitch(runtimeDir, root) · setSwitch(runtimeDir, root, { jev, check })
|
|
8
|
+
*
|
|
9
|
+
* WHAT MAY LEAVE THE MACHINE. For a label: one cleaned transcript line and one Choice question. For a check: the line and
|
|
10
|
+
* ONE repository passage (lib/notes-repo.js closest, at most 1,200 characters) and two Noul questions. Only through
|
|
11
|
+
* lib/typesafe.js. Labels need three gates: a saved key, D79 consent for the repository and the switch "notes may use Jev".
|
|
12
|
+
* A check needs those and the switch "check notes against this repository". Both switches default off and live in the
|
|
13
|
+
* per-user runtime directory. Never sent: audio, a speaker's name, a line or passage the secret scan matches, a private
|
|
14
|
+
* note (the caller refuses it before this module), anything while paused.
|
|
15
|
+
*
|
|
16
|
+
* WHAT A RESULT IS. Advisory (D40), shown in words. A label under UNSURE_BELOW keeps the local label. A check says
|
|
17
|
+
* "agrees" or "differs" only above SURE; otherwise "related", which is what the local match alone also says.
|
|
18
|
+
*
|
|
19
|
+
* PAUSE. Two rejected keys or three failed asks in a row pause Jev for the repository until the server restarts or the
|
|
20
|
+
* PM turns a switch off and on again; the local labels carry on.
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const typesafe = require('./typesafe');
|
|
25
|
+
const scanner = require('./secret-scan');
|
|
26
|
+
|
|
27
|
+
const UNSURE_BELOW = 0.55;
|
|
28
|
+
const SURE = 0.65;
|
|
29
|
+
const REJECTED_LIMIT = 2;
|
|
30
|
+
const UNREACHABLE_LIMIT = 3;
|
|
31
|
+
const NOTES_ASK = { timeoutMs: 10000, retries: 1, backoffMs: 400 };
|
|
32
|
+
|
|
33
|
+
const CATS = {
|
|
34
|
+
decision: 'A decision: something the group agreed, chose, approved or ruled out.',
|
|
35
|
+
action: 'An action item: a task someone will do, or is asked to do, often with an owner or a due day.',
|
|
36
|
+
question: 'An open question the meeting raises and has not answered.',
|
|
37
|
+
topic: 'A topic explanation: it introduces or explains what is being discussed, or moves to a new subject.',
|
|
38
|
+
statement: 'A statement of fact, data, a claim or an opinion about the work.',
|
|
39
|
+
'small talk': 'Small talk, a greeting, thanks, or talk about the meeting itself.',
|
|
40
|
+
};
|
|
41
|
+
const LABEL_INSTRUCTIONS = 'This is one line from a meeting transcript. Which kind of line is it?';
|
|
42
|
+
const SAME_Q = 'Does the PASSAGE from the repository already say what the MEETING LINE says?';
|
|
43
|
+
const DIFF_Q = 'Does the MEETING LINE say something different from, or in conflict with, the PASSAGE from the repository (for example another number, date, owner or choice)?';
|
|
44
|
+
|
|
45
|
+
/* ── gates and switches ─────────────────────────────────────────────────────────────────────────── */
|
|
46
|
+
function gates({ key, consent, jev }) {
|
|
47
|
+
const missing = [];
|
|
48
|
+
if (!key) missing.push('key');
|
|
49
|
+
if (!consent) missing.push('consent');
|
|
50
|
+
if (!jev) missing.push('jev');
|
|
51
|
+
return { open: missing.length === 0, missing };
|
|
52
|
+
}
|
|
53
|
+
function checkGates({ key, consent, jev, check }) {
|
|
54
|
+
const g = gates({ key, consent, jev });
|
|
55
|
+
if (!check) g.missing.push('check');
|
|
56
|
+
return { open: g.missing.length === 0, missing: g.missing };
|
|
57
|
+
}
|
|
58
|
+
const GATE_WORDS = {
|
|
59
|
+
key: 'No TypeSafe key is saved.',
|
|
60
|
+
consent: 'Validation is not switched on for this repository.',
|
|
61
|
+
jev: 'Notes may not use Jev in this repository.',
|
|
62
|
+
check: 'Checking notes against this repository is off.',
|
|
63
|
+
};
|
|
64
|
+
const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
|
|
65
|
+
const gatesFor = (key, f) => gates({ key: typesafe.validKey(key), consent: !!(f && f.consent), jev: !!(f && f.jev) });
|
|
66
|
+
const checkGatesFor = (key, f) => checkGates({ key: typesafe.validKey(key), consent: !!(f && f.consent), jev: !!(f && f.jev), check: !!(f && f.check) });
|
|
67
|
+
|
|
68
|
+
/** { ask, check, askOpts }: `ask` sends labels and `check` is true when checks may also be sent. Both shut when a gate is. */
|
|
69
|
+
function senderFor(key, flags, askOpts = {}) {
|
|
70
|
+
const g = gatesFor(key, flags);
|
|
71
|
+
if (!g.open) return { ask: null, check: false, gates: g, askOpts: {} };
|
|
72
|
+
return { ask: (a) => typesafe.ask(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
73
|
+
}
|
|
74
|
+
/** What the view needs to explain the two switches. Booleans and sentences only. */
|
|
75
|
+
function infoFor(present, flags) {
|
|
76
|
+
const f = { key: !!present, consent: !!(flags && flags.consent), jev: !!(flags && flags.jev), check: !!(flags && flags.check) };
|
|
77
|
+
const l = gates(f); const c = checkGates(f);
|
|
78
|
+
return { jev: f.jev, check: f.check, label_open: l.open, check_open: c.open, missing: c.missing,
|
|
79
|
+
sentence: l.open ? (c.open ? null : gateSentence(c.missing.filter((m) => m === 'check'))) : gateSentence(l.missing) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
83
|
+
const switchFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'settings.json');
|
|
84
|
+
function getSwitch(runtimeDir, root) {
|
|
85
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
86
|
+
const r = s && s.repos && root ? s.repos[root] : null;
|
|
87
|
+
return { jev: !!(r && r.jev === true), check: !!(r && r.check === true) };
|
|
88
|
+
}
|
|
89
|
+
function setSwitch(runtimeDir, root, { jev, check } = {}) {
|
|
90
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
91
|
+
const prev = getSwitch(runtimeDir, root);
|
|
92
|
+
const next = { jev: typeof jev === 'boolean' ? jev : prev.jev, check: typeof check === 'boolean' ? check : prev.check };
|
|
93
|
+
const f = switchFile(runtimeDir);
|
|
94
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
95
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify({ repos: { ...((s && s.repos) || {}), [root]: next } }), { mode: 0o600 });
|
|
97
|
+
fs.renameSync(tmp, f);
|
|
98
|
+
pauses.delete(root);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── pause ─────────────────────────────────────────────────────────────────────────────────────── */
|
|
103
|
+
const pauses = new Map(); // root → { rejected, unreachable, paused }
|
|
104
|
+
const pauseOf = (root) => { if (!pauses.has(root)) pauses.set(root, { rejected: 0, unreachable: 0, paused: null }); return pauses.get(root); };
|
|
105
|
+
function note(p, res) {
|
|
106
|
+
if (res.ok) { p.rejected = 0; p.unreachable = 0; return; }
|
|
107
|
+
if (res.status === 401) { p.rejected++; p.unreachable = 0; } else { p.unreachable++; p.rejected = 0; }
|
|
108
|
+
if (p.rejected >= REJECTED_LIMIT) p.paused = 'The service rejected the key twice, so Jev is paused for notes.';
|
|
109
|
+
else if (p.unreachable >= UNREACHABLE_LIMIT) p.paused = 'The service could not be reached three times in a row, so Jev is paused for notes.';
|
|
110
|
+
}
|
|
111
|
+
const secretIn = (...texts) => texts.some((t) => scanner.scan(String(t || '')).length > 0);
|
|
112
|
+
|
|
113
|
+
/* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
|
|
114
|
+
function choiceProbs(ans) {
|
|
115
|
+
const probs = {};
|
|
116
|
+
const put = (k, v) => { const n = Number(v); if (typeof k === 'string' && Number.isFinite(n)) probs[k.toLowerCase()] = n; };
|
|
117
|
+
if (!ans || typeof ans !== 'object') return probs;
|
|
118
|
+
const raw = ans.probabilities || ans.distribution;
|
|
119
|
+
if (Array.isArray(raw)) { for (const x of raw) if (x && typeof x === 'object') put(x.option || x.label || x.choice, x.probability != null ? x.probability : x.p); }
|
|
120
|
+
else if (raw && typeof raw === 'object') for (const [k, v] of Object.entries(raw)) put(k, v);
|
|
121
|
+
if (!Object.keys(probs).length && typeof ans.choice === 'string') put(ans.choice, ans.confidence != null ? ans.confidence : 1);
|
|
122
|
+
return probs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line's label from Jev. The caller has checked the private flag; this checks the pause and the scan. */
|
|
126
|
+
async function label({ ask, key, askOpts = {}, text, root = '' }) {
|
|
127
|
+
const p = pauseOf(root);
|
|
128
|
+
if (!ask) return { cat: null, reason: 'off' };
|
|
129
|
+
if (p.paused) return { cat: null, reason: 'paused', sentence: p.paused };
|
|
130
|
+
if (secretIn(text)) return { cat: null, reason: 'withheld' };
|
|
131
|
+
const res = await ask({ key, state: String(text).slice(0, 2000), questions: { kind: { type: 'choice', instructions: LABEL_INSTRUCTIONS, criteria: CATS } }, ...askOpts });
|
|
132
|
+
note(p, res);
|
|
133
|
+
if (!res.ok) return { cat: null, reason: 'failed', sentence: res.reason || null };
|
|
134
|
+
const probs = choiceProbs(res.answers && res.answers.kind);
|
|
135
|
+
const top = Object.entries(probs).filter(([k]) => CATS[k]).sort((a, b) => b[1] - a[1])[0];
|
|
136
|
+
if (!top || top[1] < UNSURE_BELOW) return { cat: null, reason: 'unsure' };
|
|
137
|
+
return { cat: top[0], p: top[1], model: res.model || null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const wordsFor = (v) => (v >= 0.85 ? 'very likely' : 'likely');
|
|
141
|
+
/** One captured line against one passage. */
|
|
142
|
+
async function compare({ ask, key, askOpts = {}, text, passage, root = '' }) {
|
|
143
|
+
const p = pauseOf(root);
|
|
144
|
+
if (!ask) return { verdict: null, reason: 'off' };
|
|
145
|
+
if (p.paused) return { verdict: null, reason: 'paused', sentence: p.paused };
|
|
146
|
+
if (!passage || !passage.text) return { verdict: null, reason: 'nothing' };
|
|
147
|
+
if (secretIn(text, passage.text)) return { verdict: null, reason: 'withheld' };
|
|
148
|
+
const state = 'MEETING LINE:\n' + String(text).slice(0, 2000) + '\n\nPASSAGE (' + passage.path + '):\n' + String(passage.text).slice(0, 1200);
|
|
149
|
+
const res = await ask({ key, state, questions: { same: { type: 'noul', instructions: SAME_Q }, differs: { type: 'noul', instructions: DIFF_Q } }, ...askOpts });
|
|
150
|
+
note(p, res);
|
|
151
|
+
if (!res.ok) return { verdict: null, reason: 'failed', sentence: res.reason || null };
|
|
152
|
+
const num = (a) => { const v = a && (a.type == null || a.type === 'noul') ? Number(a.noul) : NaN; return Number.isFinite(v) && v >= 0 && v <= 1 ? v : null; };
|
|
153
|
+
const same = num(res.answers && res.answers.same); const diff = num(res.answers && res.answers.differs);
|
|
154
|
+
if (same == null && diff == null) return { verdict: null, reason: 'unreadable' };
|
|
155
|
+
if (diff != null && diff >= SURE && (same == null || diff > same)) return { verdict: 'differs', words: wordsFor(diff), model: res.model || null };
|
|
156
|
+
if (same != null && same >= SURE) return { verdict: 'agrees', words: wordsFor(same), model: res.model || null };
|
|
157
|
+
return { verdict: 'related', words: null, model: res.model || null };
|
|
158
|
+
}
|
|
159
|
+
function pauseStatus(root) { const p = pauses.get(root); return p && p.paused ? p.paused : null; }
|
|
160
|
+
|
|
161
|
+
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, label, compare, choiceProbs, pauseStatus,
|
|
162
|
+
CATS, UNSURE_BELOW, SURE, REJECTED_LIMIT, UNREACHABLE_LIMIT };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-repo.js — find the repository passage a meeting line is about (meeting-notes Run 2, D91). Local and read-only.
|
|
3
|
+
*
|
|
4
|
+
* passages(root, { exclude }) → [{ path, line, text }] the repository's Markdown paragraphs, cached for a minute
|
|
5
|
+
* closest(root, text, { exclude }) → { path, line, text, shared } | null
|
|
6
|
+
*
|
|
7
|
+
* WHAT IT READS. Every tracked-looking Markdown file under the repository root, skipping dependency, build and hidden
|
|
8
|
+
* folders, at most MAX_FILES files and MAX_FILE_BYTES each. A paragraph is a run of non-blank lines outside frontmatter
|
|
9
|
+
* and code fences, cut to PASSAGE_MAX characters. An excluded path (D79 exclusions) is never read.
|
|
10
|
+
*
|
|
11
|
+
* HOW IT MATCHES. Content words and numbers shared with the line, each weighted by how rare it is across the paragraphs.
|
|
12
|
+
* A passage counts only when it shares at least MIN_SHARED words and scores at least MIN_SCORE, so a line about nothing
|
|
13
|
+
* in particular finds nothing. Numbers count as words: "a 14-day trial" and "21 days" are what a consistency check is for.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT NEVER DOES. It writes nothing and sends nothing. The Jev check (lib/jev-notes.js) decides what leaves.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const settings = require('./typesafe-settings');
|
|
20
|
+
|
|
21
|
+
const MAX_FILES = 3000;
|
|
22
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
23
|
+
const PASSAGE_MAX = 1200;
|
|
24
|
+
const MIN_SHARED = 3;
|
|
25
|
+
const MIN_SCORE = 3;
|
|
26
|
+
const CACHE_MS = 60000;
|
|
27
|
+
const SKIP = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', 'out', 'coverage', '.next', 'tmp', 'payload']);
|
|
28
|
+
|
|
29
|
+
const STOP = new Set(('the and for are but not you all any can had her was one our out has have this that with from they will would there their what '
|
|
30
|
+
+ 'about which when make like time just know take into year your some could them than then look only come its over think also back after use '
|
|
31
|
+
+ 'two how work first well way even want because these give most should does did done been being were yeah okay right really thing things '
|
|
32
|
+
+ 'going gonna said say says very much more less here where who why let lets get got need needs maybe sure good great thanks thank '
|
|
33
|
+
+ 'today week next last new old same other each every into onto upon per via yes no').split(/\s+/));
|
|
34
|
+
const NUM_WORDS = { one: '1', two: '2', three: '3', four: '4', five: '5', six: '6', seven: '7', eight: '8', nine: '9', ten: '10', eleven: '11', twelve: '12',
|
|
35
|
+
thirteen: '13', fourteen: '14', fifteen: '15', sixteen: '16', seventeen: '17', eighteen: '18', nineteen: '19', twenty: '20', thirty: '30', forty: '40', fifty: '50' };
|
|
36
|
+
const stem = (w) => {
|
|
37
|
+
const x = w.replace(/'s$/, '');
|
|
38
|
+
if (/(?:ing|ed|es)$/.test(x)) return x.replace(/(?:ing|ed|es)$/, (s) => (x.length - s.length >= 4 ? '' : s));
|
|
39
|
+
return /[^s]s$/.test(x) && x.length >= 4 ? x.slice(0, -1) : x;
|
|
40
|
+
};
|
|
41
|
+
/** Content words and numbers. "twenty-one" is 21; "14-day" is 14 and day. */
|
|
42
|
+
function termsOf(text) {
|
|
43
|
+
const t = String(text || '').toLowerCase().replace(/\b(twenty|thirty|forty|fifty)[- ](one|two|three|four|five|six|seven|eight|nine)\b/g,
|
|
44
|
+
(m, a, b) => String(Number(NUM_WORDS[a]) + Number(NUM_WORDS[b])));
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const raw of t.match(/[\p{L}\p{N}']+/gu) || []) {
|
|
47
|
+
const w = NUM_WORDS[raw] || raw;
|
|
48
|
+
if (/^\d+$/.test(w)) { out.push(w); continue; }
|
|
49
|
+
if (w.length < 3 || STOP.has(w)) continue;
|
|
50
|
+
out.push(stem(w));
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(root, exclude) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const go = (dir) => {
|
|
58
|
+
if (out.length >= MAX_FILES) return;
|
|
59
|
+
let ents = [];
|
|
60
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
61
|
+
for (const e of ents) {
|
|
62
|
+
if (out.length >= MAX_FILES) return;
|
|
63
|
+
if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
|
|
64
|
+
const p = path.join(dir, e.name);
|
|
65
|
+
const rel = path.relative(root, p).split(path.sep).join('/');
|
|
66
|
+
if (settings.isExcluded(exclude, rel)) continue;
|
|
67
|
+
if (e.isDirectory()) go(p);
|
|
68
|
+
else if (e.isFile() && /\.md$/i.test(e.name)) out.push(rel);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
go(root);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function paragraphsOf(rel, text) {
|
|
75
|
+
const out = [];
|
|
76
|
+
const lines = String(text).split(/\r?\n/);
|
|
77
|
+
let i = 0;
|
|
78
|
+
if (lines[0] === '---') { i = 1; while (i < lines.length && lines[i] !== '---') i++; i++; }
|
|
79
|
+
let fence = false; let buf = []; let start = 0;
|
|
80
|
+
const end = () => { if (buf.length) { const t = buf.join(' ').replace(/\s+/g, ' ').trim(); if (t.length >= 30) out.push({ path: rel, line: start + 1, text: t.slice(0, PASSAGE_MAX) }); } buf = []; };
|
|
81
|
+
for (; i < lines.length; i++) {
|
|
82
|
+
const l = lines[i];
|
|
83
|
+
if (/^\s*(```|~~~)/.test(l)) { end(); fence = !fence; continue; }
|
|
84
|
+
if (fence) continue;
|
|
85
|
+
if (!l.trim() || /^#{1,6}\s/.test(l) || /^\s*\|?\s*-{3,}/.test(l)) { end(); continue; }
|
|
86
|
+
if (!buf.length) start = i;
|
|
87
|
+
buf.push(l.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, ''));
|
|
88
|
+
}
|
|
89
|
+
end();
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const cache = new Map(); // root → { at, key, items, df }
|
|
94
|
+
function index(root, { exclude = [], now = Date.now() } = {}) {
|
|
95
|
+
const key = JSON.stringify(exclude);
|
|
96
|
+
const hit = cache.get(root);
|
|
97
|
+
if (hit && hit.key === key && now - hit.at < CACHE_MS) return hit;
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const rel of walk(root, exclude)) {
|
|
100
|
+
let text = '';
|
|
101
|
+
try { const st = fs.statSync(path.join(root, rel)); if (st.size > MAX_FILE_BYTES) continue; text = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; }
|
|
102
|
+
for (const p of paragraphsOf(rel, text)) items.push({ ...p, terms: new Set(termsOf(p.text)) });
|
|
103
|
+
}
|
|
104
|
+
const df = new Map();
|
|
105
|
+
for (const it of items) for (const w of it.terms) df.set(w, (df.get(w) || 0) + 1);
|
|
106
|
+
const ix = { at: now, key, items, df };
|
|
107
|
+
cache.set(root, ix);
|
|
108
|
+
return ix;
|
|
109
|
+
}
|
|
110
|
+
function passages(root, opts = {}) { return index(root, opts).items.map(({ path: p, line, text }) => ({ path: p, line, text })); }
|
|
111
|
+
|
|
112
|
+
/** The passage that shares the most rare words with `text`, or null when none shares enough. */
|
|
113
|
+
function closest(root, text, { exclude = [], now = Date.now() } = {}) {
|
|
114
|
+
if (!root) return null;
|
|
115
|
+
const ix = index(root, { exclude, now });
|
|
116
|
+
const q = new Set(termsOf(text));
|
|
117
|
+
if (q.size < MIN_SHARED) return null;
|
|
118
|
+
const n = ix.items.length || 1;
|
|
119
|
+
let best = null;
|
|
120
|
+
for (const it of ix.items) {
|
|
121
|
+
let s = 0; const shared = [];
|
|
122
|
+
// a shared word counts 1, more when it is rare in the repository; a long passage is not favoured for its length
|
|
123
|
+
for (const w of q) if (it.terms.has(w)) { s += 1 + Math.log(n / (ix.df.get(w) || 1)); shared.push(w); }
|
|
124
|
+
if (shared.length < MIN_SHARED || s < MIN_SCORE) continue;
|
|
125
|
+
const rank = s / Math.sqrt(it.terms.size / 20 + 1);
|
|
126
|
+
if (!best || rank > best.rank) best = { path: it.path, line: it.line, text: it.text, shared, score: s, rank };
|
|
127
|
+
}
|
|
128
|
+
if (!best) return null;
|
|
129
|
+
return { path: best.path, line: best.line, text: best.text, shared: best.shared };
|
|
130
|
+
}
|
|
131
|
+
function clearCache() { cache.clear(); }
|
|
132
|
+
|
|
133
|
+
module.exports = { passages, closest, termsOf, paragraphsOf, clearCache, PASSAGE_MAX, MIN_SHARED, MIN_SCORE };
|
|
@@ -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 };
|