create-pmos 0.132.0 → 0.134.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/bin/pmos.js +18 -2
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/notes.md +19 -1
- package/workspace/guide/welcome.md +38 -0
- package/workspace/lib/import-overlay.js +32 -0
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +128 -0
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-whisper.js +132 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-notes.js +60 -8
- package/workspace/lib/routes-onboarding.js +215 -0
- package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
- package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
- package/workspace/lib/vendor/scripts/intake.js +78 -0
- package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
- package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
- package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
- package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
- package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
- package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
- package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
- package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
- package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
- package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
- package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
- package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
- package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
- package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
- package/workspace/lib/vendor/scripts/status-facts.js +214 -0
- package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
- package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
- package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
- package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
- package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
- package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
- package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
- package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
- package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/calendar-views.js +25 -12
- package/workspace/public/pm/components.css +280 -0
- package/workspace/public/pm/console3.css +19 -0
- package/workspace/public/pm/shell-recording.js +122 -12
- package/workspace/public/pm/view-notes.js +7 -2
- package/workspace/public/pm/welcome.js +283 -0
- package/workspace/public/welcome.html +20 -0
- package/workspace/server.js +2 -2
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-lists/onboarding-firstrun.json +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.134.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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T08:10:43Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T08:10:43Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-11T08:10:43Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -188,6 +188,7 @@ function ensureInstalled() {
|
|
|
188
188
|
// Register the target directory and open the browser ON IT. The `focus` broadcast matters:
|
|
189
189
|
// the OS may refocus an already-open tab instead of opening our URL, and without it the PM
|
|
190
190
|
// would be looking at the previously-opened repo — the defect this replaces.
|
|
191
|
+
let FORCE_WELCOME = false; // `pmos welcome [path]`
|
|
191
192
|
async function openWorkspace(dir, port, { open = true } = {}) {
|
|
192
193
|
const root = repoRootOf(dir);
|
|
193
194
|
const reg = await api(port, 'POST', '/api/workspaces', { root, name: path.basename(root) });
|
|
@@ -199,7 +200,17 @@ async function openWorkspace(dir, port, { open = true } = {}) {
|
|
|
199
200
|
const w = reg.workspace;
|
|
200
201
|
say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
|
|
201
202
|
say(` ${root}`);
|
|
202
|
-
|
|
203
|
+
// First run (onboarding-scan-and-map): a plain folder PMOS has never imported opens on Welcome (the route's first_run);
|
|
204
|
+
// `pmos welcome` opens it on purpose. Any failure keeps the old URL.
|
|
205
|
+
let first = FORCE_WELCOME;
|
|
206
|
+
if (!first) {
|
|
207
|
+
try {
|
|
208
|
+
const st = await api(port, 'GET', `/api/onboarding/status?ws=${encodeURIComponent(w.id)}`);
|
|
209
|
+
first = !!(st && st.ok && st.first_run);
|
|
210
|
+
} catch { first = false; }
|
|
211
|
+
}
|
|
212
|
+
if (first) say(FORCE_WELCOME ? '✓ opening Welcome' : '✓ first run — opening Welcome');
|
|
213
|
+
if (open) openBrowser(first ? `${urlOf(port)}/welcome?ws=${w.id}` : `${urlOf(port)}?ws=${w.id}`);
|
|
203
214
|
return 0;
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -511,9 +522,10 @@ function doctor() {
|
|
|
511
522
|
if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
|
|
512
523
|
else code = r.status === null ? 1 : r.status;
|
|
513
524
|
} else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
514
|
-
say('usage: pmos [<path>|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
525
|
+
say('usage: pmos [<path>|welcome|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
|
|
515
526
|
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
516
527
|
say(' pmos <path> open another repo as a workspace');
|
|
528
|
+
say(' pmos welcome [<path>] scan a folder and bring its products and initiatives into PMOS (undoable)');
|
|
517
529
|
say(' pmos phone turn on phone access over Tailscale and show the QR code to scan');
|
|
518
530
|
say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
|
|
519
531
|
say(' pmos skill … list, read, create and edit skills');
|
|
@@ -523,6 +535,10 @@ function doctor() {
|
|
|
523
535
|
for (const line of require('../lib/pmos-verbs.js').helpLines()) say(line);
|
|
524
536
|
say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
|
|
525
537
|
code = 0;
|
|
538
|
+
} else if (cmd === 'welcome') {
|
|
539
|
+
// the first-run page on purpose: scan this folder, import it, or undo an import (onboarding-scan-and-map)
|
|
540
|
+
FORCE_WELCOME = true;
|
|
541
|
+
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
|
526
542
|
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
527
543
|
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
528
544
|
code = await up(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
|
package/workspace/guide/index.md
CHANGED
|
@@ -39,6 +39,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
39
39
|
|
|
40
40
|
## Where to read next
|
|
41
41
|
|
|
42
|
+
- [Welcome (first run)](welcome.md): scan a new folder, review the proposed portfolio, import it and undo it.
|
|
42
43
|
- [The Briefing](briefing.md): the console's first page: Today's meetings, Needs you (Done, Not yet, Drop) and the Portfolio pulse.
|
|
43
44
|
- [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
|
|
44
45
|
- [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
|
package/workspace/guide/notes.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: notes"
|
|
4
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; final notes written after Stop from the whole recording, with speakers and a quote for each item, decisions with their why and who, next steps with an open question, a discussion paragraph, and Wispr Flow's notes merged in and fetched by hand when the fetch fails; 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 for conflicts, capture highlights during the meeting, create tasks from next steps, 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
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-11
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Guide: notes
|
|
@@ -20,6 +20,24 @@ The Record control is at the right of the top bar. It has two groups, always in
|
|
|
20
20
|
|
|
21
21
|
Starting a note, from the Record control or from **Take notes** on a calendar event, shows the Notes page with that note.
|
|
22
22
|
|
|
23
|
+
### The Record control in the console
|
|
24
|
+
|
|
25
|
+
The console (the page at `/c3.html`) has the same Record control in its top bar, beside the Sessions button. It is always there, also when nothing is recording. **Record** (the microphone) starts an untitled note and listens at once. The arrow lists the meetings of today that have not started. Press one to start a note linked to that calendar event. While a meeting records, the red group shows its name and clock, **Highlight**, **Pause** and **Stop**. While it is paused, the group shows **Resume** and **Stop**. **Stop** asks first and offers **Pause instead**. A red dot shows on the Calendar tab while a meeting is held. The console has no Notes page, so the name in the red group opens the note in the main app.
|
|
26
|
+
|
|
27
|
+
When Whisper or Qwen3-ASR transcribes the meeting, the red group also names the engine and the language, for example **Whisper · English**. Check it when lines come out in the wrong language: the language is set in the Notes settings.
|
|
28
|
+
|
|
29
|
+
One meeting records in one tab at a time. If another tab or window already records the meeting, **Record** there says so and starts nothing.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## What the live transcript leaves out
|
|
33
|
+
|
|
34
|
+
The live transcript keeps each sentence once:
|
|
35
|
+
|
|
36
|
+
- When the meeting plays on your speakers, your microphone hears the other people too. PMOS takes their words out of your microphone's line, and keeps the line from the meeting. Your own words stay.
|
|
37
|
+
- A line that only repeats the line before it is left out. A sentence said twice in a row in one line is kept once.
|
|
38
|
+
- If the meeting language is English (the default), a line that comes back in another language, for example Hindi, Indonesian or Chinese, is transcribed again in English. The first text stays with the line.
|
|
39
|
+
- If the workspace restarts during a meeting, the recording continues where it was. Nothing is written twice.
|
|
40
|
+
|
|
23
41
|
## Pause, resume and stop
|
|
24
42
|
|
|
25
43
|
**Pause** keeps the meeting open. PMOS stops listening and the clock stops. It does not ask, and it does not write the final notes yet. You can resume for one hour. The top bar shows the minutes left. After an hour the meeting ends by itself, and PMOS writes its final notes.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Guide: Welcome (first run)"
|
|
4
|
+
timestamp: 2026-10-11
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Welcome (first run)
|
|
8
|
+
|
|
9
|
+
The Welcome page is the first screen of a new folder. It reads your folder, shows what it found, and sets up your portfolio from it. You do not need to answer questions first.
|
|
10
|
+
|
|
11
|
+
## How you get there
|
|
12
|
+
|
|
13
|
+
Run `pmos` in a plain folder that PMOS has not imported. The terminal says "first run — opening Welcome" and the browser opens `/welcome`. A folder that already has its own PMOS state folder opens the console as before. After an import, `pmos` opens the console.
|
|
14
|
+
|
|
15
|
+
Run `pmos welcome` to open the page on purpose, for example to scan a folder again or to undo an import. `pmos welcome <path>` opens it for another folder.
|
|
16
|
+
|
|
17
|
+
Choose **Not now** on the first screen to go to the console. Welcome then stops opening on its own for that folder. `pmos welcome` still opens it.
|
|
18
|
+
|
|
19
|
+
## The promise
|
|
20
|
+
|
|
21
|
+
The page says it before the scan: PMOS reads only, writes nothing in your folder, and nothing leaves this computer. The scan runs on your machine and uses no model. The import is stored in the PMOS state folder, never in your folder.
|
|
22
|
+
|
|
23
|
+
## The steps
|
|
24
|
+
|
|
25
|
+
1. **Welcome.** The page shows the folder name and path. Choose **Add another folder** to open the system folder chooser and add a second folder. Choose **Scan** to start. A spinner shows while the scan runs. A large folder can take a minute or two, and the rest of PMOS keeps working.
|
|
26
|
+
2. **First look.** One sentence gives the counts: initiatives, documents, deadlines, stale documents, duplicates, people and risks. Up to ten findings follow. Each finding shows the file and line it comes from. Choose **Continue**.
|
|
27
|
+
3. **Your portfolio.** PMOS proposes one board for each product folder (`products/<name>/`). If there is no product folder, it proposes one board for the whole folder. Each board lists its initiatives. Each row has a checkbox, a title you can edit, a lane, an anchor, a stage and a document count. Choose the document count to see the documents. Use the board list on a row to move it to another board. Each board shows how many documents PMOS could not place. High and medium confidence rows start checked. The button reads **Import N initiatives into M boards**. The numbers change as you check, uncheck and move rows.
|
|
28
|
+
4. **Questions.** This step appears only when the scan could not decide something. There are three questions at most. Each question shows its default already chosen. Choose **Import** to accept what is chosen. Choose **Skip** to leave a question open: for the anchor question, the proposed anchors then stay marked for you to confirm later.
|
|
29
|
+
5. **First Briefing.** After the import, the page lists up to five things that need you first. An item that belongs to an imported initiative names the initiative and its board. Choose **Continue**. Choose **Undo this import** to take the import back and return to your portfolio.
|
|
30
|
+
6. **More sources.** This step is optional. It offers Calendar (your meetings today and for the next 14 days) and Jira (the issues assigned to you). PMOS reads nothing from them until you choose **Bring in**. Bring in starts the source's read-only skill as an agent session in the workspace, with your own connection. The skill writes nothing to the tool. Choose **Skip** to leave a source out. You can bring it in later from Settings. Inbox and Slack have no import yet. Choose **Open PMOS** to go to the console.
|
|
31
|
+
|
|
32
|
+
## Undo
|
|
33
|
+
|
|
34
|
+
**Undo this import** retracts the import. Nothing is deleted: the import and its undo stay in the record. You can then change the portfolio and import again. When you open Welcome after an import, the page shows the first Briefing with the Undo link.
|
|
35
|
+
|
|
36
|
+
## What the page does not do
|
|
37
|
+
|
|
38
|
+
The page does not change your folder. It does not open the files that the exclusion list names, such as `.git`, `node_modules` and files that look like secrets. It does not read Calendar or Jira until you choose **Bring in** on More sources, and the PMOS server itself calls nothing outside this computer. It does not import from Drive, Inbox, Slack or other tools.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* import-overlay.js: where a workspace's first-run import lives, and the fold of it (slice 2 of onboarding-scan-and-map).
|
|
3
|
+
* overlayDir(stateRoot, wsId) = <stateRoot>/imports/<wsId> overlayDirFor is the same function
|
|
4
|
+
* importedFor(stateRoot, wsId) -> { boards, initiatives, imports } from scripts/onboarding/apply.js; empty shapes when nothing is there.
|
|
5
|
+
* Initiative rows carry import_id and source: 'import'; board rows too. Never throws on a missing folder or file. */
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/** Where scripts/onboarding/ is: the checkout's own, else the copy an installed package vendors under lib/vendor/
|
|
10
|
+
* (scripts/vendor-workspace-engine.js vendorTree). Null when neither is there. */
|
|
11
|
+
function onboardingDir() {
|
|
12
|
+
for (const d of [path.join(__dirname, '..', '..', 'scripts', 'onboarding'), path.join(__dirname, 'vendor', 'scripts', 'onboarding')]) {
|
|
13
|
+
if (fs.existsSync(path.join(d, 'scan.js'))) return d;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const WS_ID = /^[A-Za-z0-9_-]{1,64}$/; // one safe path segment (server ids are 10 hex characters)
|
|
19
|
+
const empty = () => ({ boards: [], initiatives: [], imports: [] });
|
|
20
|
+
|
|
21
|
+
function overlayDir(stateRoot, wsId) {
|
|
22
|
+
if (!WS_ID.test(String(wsId))) throw new Error('bad workspace id');
|
|
23
|
+
return path.join(stateRoot, 'imports', String(wsId));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function importedFor(stateRoot, wsId) {
|
|
27
|
+
let apply;
|
|
28
|
+
try { apply = require(path.join(onboardingDir(), 'apply.js')); } catch { return empty(); }
|
|
29
|
+
try { return apply.importedFor(overlayDir(stateRoot, wsId)); } catch { return empty(); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { overlayDir, overlayDirFor: overlayDir, importedFor, onboardingDir };
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* place and with nothing added, so the final pass after Stop (lib/notes-final.js) can tell the PM's microphone from the
|
|
19
19
|
* meeting. remove deletes all three.
|
|
20
20
|
*
|
|
21
|
+
* A chunk from a page that names its place (`sample`, from lib/notes-capture.js) is written exactly there, so a chunk that arrives
|
|
22
|
+
* late, or after a restart, lands where it belongs (notes-recording-reliability).
|
|
23
|
+
*
|
|
21
24
|
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
|
|
22
25
|
* private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
|
|
23
26
|
*/
|
|
@@ -80,20 +83,23 @@ function softLimit(x) {
|
|
|
80
83
|
/** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, then limited by softLimit. With
|
|
81
84
|
* `mixed: false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done.
|
|
82
85
|
* The per-source files are never limited: they are what the transcription reads. */
|
|
83
|
-
function write(runtimeDir, { id, source, pcm, pcm24 = null, t, mixed = true }) {
|
|
86
|
+
function write(runtimeDir, { id, source, pcm, pcm24 = null, t, sample = null, mixed = true }) {
|
|
84
87
|
if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
|
|
85
88
|
const f = fileOf(runtimeDir, id);
|
|
86
89
|
const key = f + '|' + source;
|
|
87
|
-
|
|
90
|
+
// `sample` (notes-recording-reliability): the chunk's own place, from the capture's start and its offset, so it is written there
|
|
91
|
+
// and nowhere else, whenever it arrives. Without it the chunk is placed by its arrival time, as before.
|
|
92
|
+
const exact = Number.isInteger(sample) && sample >= 0;
|
|
93
|
+
let at = exact ? sample * 2 : Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
|
|
88
94
|
const prev = next.get(key);
|
|
89
95
|
// a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
|
|
90
|
-
if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
96
|
+
if (!exact && prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
|
|
91
97
|
if (at >= MAX_BYTES) return { ok: true, kept: false };
|
|
92
98
|
const len = Math.min(pcm.length, MAX_BYTES - at);
|
|
93
99
|
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
94
100
|
// With 24 kHz audio the replay is the 24 kHz mix and no 16 kHz mix is written; the 16 kHz source files are written either way
|
|
95
101
|
const hiOk = mixed && pcm24 && pcm24.length && pcm24.length % 2 === 0;
|
|
96
|
-
if (hiOk) writeHi(runtimeDir, id, source, pcm24, t);
|
|
102
|
+
if (hiOk) writeHi(runtimeDir, id, source, pcm24, t, exact ? Math.round(sample * HI_RATE / RATE) : null);
|
|
97
103
|
if (mixed && !hiOk) {
|
|
98
104
|
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
99
105
|
try {
|
|
@@ -114,12 +120,13 @@ function write(runtimeDir, { id, source, pcm, pcm24 = null, t, mixed = true }) {
|
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
/** Add one chunk of 24 kHz audio to the note's 24 kHz mix at meeting time `t`, placed as write() places the 16 kHz audio. */
|
|
117
|
-
function writeHi(runtimeDir, id, source, pcm24, t) {
|
|
123
|
+
function writeHi(runtimeDir, id, source, pcm24, t, sample = null) {
|
|
118
124
|
const f = hiFileOf(runtimeDir, id);
|
|
119
125
|
const key = f + '|' + source;
|
|
120
|
-
|
|
126
|
+
const exact = Number.isInteger(sample) && sample >= 0;
|
|
127
|
+
let at = exact ? sample * 2 : Math.max(0, Math.round((Number(t) || 0) * HI_BYTES_PER_MS / 2) * 2);
|
|
121
128
|
const prev = next.get(key);
|
|
122
|
-
if (prev !== undefined && at <= prev + ON_TIME_MS * HI_BYTES_PER_MS) at = prev;
|
|
129
|
+
if (!exact && prev !== undefined && at <= prev + ON_TIME_MS * HI_BYTES_PER_MS) at = prev;
|
|
123
130
|
if (at >= HI_MAX_BYTES) return;
|
|
124
131
|
const len = Math.min(pcm24.length, HI_MAX_BYTES - at);
|
|
125
132
|
const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
|
|
@@ -155,12 +162,24 @@ function remove(runtimeDir, id) {
|
|
|
155
162
|
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|') || k.startsWith(hiFileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
156
163
|
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
157
164
|
let removed = false;
|
|
158
|
-
for (const extra of ['.echo.json', '.pcm.echo.tmp', '.hi.pcm.echo.tmp', '.hi.pcm', '.clean.pcm', '.clean.pcm.tmp']) {
|
|
165
|
+
for (const extra of ['.capture.json', '.echo.json', '.pcm.echo.tmp', '.hi.pcm.echo.tmp', '.hi.pcm', '.clean.pcm', '.clean.pcm.tmp']) {
|
|
159
166
|
try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); if (extra === '.hi.pcm') removed = true; } catch { /* none */ }
|
|
160
167
|
}
|
|
161
168
|
try { fs.unlinkSync(fileOf(runtimeDir, id)); removed = true; } catch { /* none */ }
|
|
162
169
|
return removed;
|
|
163
170
|
}
|
|
171
|
+
/** Samples [from, to) (16 kHz) of one source's own file, or null. At most a minute. */
|
|
172
|
+
function readSource(runtimeDir, id, source, from, to) {
|
|
173
|
+
if (!ID_RE.test(String(id || '')) || !SOURCES.includes(source)) return null;
|
|
174
|
+
const a = Math.max(0, from, to - RATE * 60); const n = (to - a) * 2;
|
|
175
|
+
if (n <= 0) return null;
|
|
176
|
+
let fd;
|
|
177
|
+
try {
|
|
178
|
+
fd = fs.openSync(sourceFileOf(runtimeDir, id, source), 'r');
|
|
179
|
+
const b = Buffer.alloc(n); const got = fs.readSync(fd, b, 0, n, a * 2);
|
|
180
|
+
return got > 0 ? { pcm: b.subarray(0, got - (got % 2)), from: a } : null;
|
|
181
|
+
} catch { return null; } finally { if (fd !== undefined) fs.closeSync(fd); }
|
|
182
|
+
}
|
|
164
183
|
/** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
|
|
165
184
|
function sources(runtimeDir, id) {
|
|
166
185
|
if (!ID_RE.test(String(id || ''))) return [];
|
|
@@ -220,4 +239,4 @@ function serve(req, res, runtimeDir, id) {
|
|
|
220
239
|
fs.createReadStream(inf.file, { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
221
240
|
}
|
|
222
241
|
|
|
223
|
-
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: (dir, id) => (baseOf(dir, id) || { file: fileOf(dir, id) }).file, mixFile: fileOf, hiFile: hiFileOf, sideFile: sideFileOf, baseOf, cleanFile: cleanFileOf, HI_RATE, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
242
|
+
module.exports = { write, readSource, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: (dir, id) => (baseOf(dir, id) || { file: fileOf(dir, id) }).file, mixFile: fileOf, hiFile: hiFileOf, sideFile: sideFileOf, baseOf, cleanFile: cleanFileOf, HI_RATE, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-capture.js — which page is capturing a note, and which chunks of it are already written (notes-recording-reliability). Local only.
|
|
3
|
+
*
|
|
4
|
+
* start(runtimeDir, id, { baseMs, now }) → { ok, token, base_ms } | { ok: false, status: 409, error }
|
|
5
|
+
* check(runtimeDir, id, { token, source, seq, now }) → { ok, dup, base_ms } | { ok: false, status: 409, error }
|
|
6
|
+
* mark(runtimeDir, id, { source, seq, now }) the chunk is written: a second copy of it is a duplicate
|
|
7
|
+
* setCut(runtimeDir, id, { source, sample }) · untranscribed(runtimeDir, id, { source, before }) → { from, to } | null
|
|
8
|
+
* the live transcription's progress, so a restart transcribes what it had buffered
|
|
9
|
+
* close(runtimeDir, id) Pause or Stop: the capture ends, and the next Listen starts a new one
|
|
10
|
+
* tokened(runtimeDir, id) → true when the live capture has sent a chunk with its token
|
|
11
|
+
* remove(runtimeDir, id)
|
|
12
|
+
*
|
|
13
|
+
* WHY. The page sends each source in one-second chunks and sends a chunk again when its answer does not come back. Before this
|
|
14
|
+
* module, the server placed each chunk at its arrival time and remembered in memory where each source ended. So a chunk written
|
|
15
|
+
* twice was kept twice, a restart lost the memory and the next chunk was added onto audio already written, a late chunk moved
|
|
16
|
+
* the rest of its source, and two tabs could capture one note into the same file (measured by notes-duplication.test.js).
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT KEEPS. Listen hands the page a token and the meeting time the capture starts at (base_ms). Each chunk carries the
|
|
19
|
+
* token, a sequence number per source and its sample offset in the capture, so the route places it at base_ms + offset, and
|
|
20
|
+
* the sequence numbers already written are kept as ranges. All of it is in `<runtime>/notes/recordings/<id>.capture.json`,
|
|
21
|
+
* next to the audio, so a restart keeps it.
|
|
22
|
+
*
|
|
23
|
+
* ONE CAPTURE PER NOTE. Listen is refused while another capture is live: one that sent a chunk (or started) in the last
|
|
24
|
+
* LIVE_MS. A chunk with a token that is not the note's capture is refused, and the page stops listening on that answer.
|
|
25
|
+
*
|
|
26
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module.
|
|
27
|
+
*/
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const LIVE_MS = 5000;
|
|
33
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
34
|
+
const fileOf = (runtimeDir, id) => path.join(runtimeDir, 'notes', 'recordings', id + '.capture.json');
|
|
35
|
+
const cache = new Map(); // file → state, written through to the file
|
|
36
|
+
|
|
37
|
+
function load(runtimeDir, id) {
|
|
38
|
+
const f = fileOf(runtimeDir, id);
|
|
39
|
+
if (cache.has(f)) return cache.get(f);
|
|
40
|
+
let s = null;
|
|
41
|
+
try { s = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { s = null; }
|
|
42
|
+
if (!s || typeof s !== 'object') s = null;
|
|
43
|
+
cache.set(f, s);
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
function save(runtimeDir, id, s) {
|
|
47
|
+
const f = fileOf(runtimeDir, id);
|
|
48
|
+
cache.set(f, s);
|
|
49
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
50
|
+
const tmp = f + '.' + process.pid + '.tmp';
|
|
51
|
+
fs.writeFileSync(tmp, JSON.stringify(s), { mode: 0o600 });
|
|
52
|
+
fs.renameSync(tmp, f);
|
|
53
|
+
}
|
|
54
|
+
const liveAt = (s) => Math.max(Date.parse(s.started_at) || 0, Date.parse(s.last_at) || 0);
|
|
55
|
+
const isLive = (s, now) => !!(s && s.token && !s.closed && now - liveAt(s) < LIVE_MS);
|
|
56
|
+
|
|
57
|
+
/** A new capture, unless another one is live. */
|
|
58
|
+
function start(runtimeDir, id, { baseMs = 0, now = Date.now() } = {}) {
|
|
59
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
60
|
+
const s = load(runtimeDir, id);
|
|
61
|
+
if (isLive(s, now)) return { ok: false, status: 409, error: 'this meeting is already being recorded in another tab or window' };
|
|
62
|
+
const token = crypto.randomBytes(9).toString('hex');
|
|
63
|
+
const base = Math.max(0, Math.round(Number(baseMs) || 0));
|
|
64
|
+
save(runtimeDir, id, { token, base_ms: base, started_at: new Date(now).toISOString(), last_at: null, closed: false, tokened: false, seqs: {} });
|
|
65
|
+
return { ok: true, token, base_ms: base };
|
|
66
|
+
}
|
|
67
|
+
/** Refuse true when another check would refuse the next Listen: used before the note's own record step changes anything. */
|
|
68
|
+
function busy(runtimeDir, id, { now = Date.now() } = {}) { return isLive(load(runtimeDir, id), now); }
|
|
69
|
+
|
|
70
|
+
const has = (ranges, n) => (ranges || []).some(([a, b]) => n >= a && n <= b);
|
|
71
|
+
function add(ranges, n) {
|
|
72
|
+
const r = (ranges || []).concat([[n, n]]).sort((x, y) => x[0] - y[0]);
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const [a, b] of r) { const last = out[out.length - 1]; if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b); else out.push([a, b]); }
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** May this chunk be written? `dup` when its sequence number is already written. */
|
|
79
|
+
function check(runtimeDir, id, { token, source, seq } = {}) {
|
|
80
|
+
const s = load(runtimeDir, id);
|
|
81
|
+
if (!s || !s.token || s.closed || s.token !== token) return { ok: false, status: 409, error: 'this meeting is being recorded in another tab or window, or listening stopped' };
|
|
82
|
+
const n = Number(seq);
|
|
83
|
+
if (!Number.isInteger(n) || n < 0) return { ok: false, status: 400, error: 'a chunk needs a sequence number' };
|
|
84
|
+
return { ok: true, dup: has((s.seqs || {})[source], n), base_ms: s.base_ms };
|
|
85
|
+
}
|
|
86
|
+
function mark(runtimeDir, id, { source, seq, end = null, now = Date.now() } = {}) {
|
|
87
|
+
const s = load(runtimeDir, id);
|
|
88
|
+
if (!s) return;
|
|
89
|
+
s.seqs = s.seqs || {};
|
|
90
|
+
s.seqs[source] = add(s.seqs[source], Number(seq));
|
|
91
|
+
if (Number.isInteger(end)) { s.fed = s.fed || {}; s.fed[source] = Math.max(s.fed[source] || 0, end); }
|
|
92
|
+
s.last_at = new Date(now).toISOString(); s.tokened = true;
|
|
93
|
+
save(runtimeDir, id, s);
|
|
94
|
+
}
|
|
95
|
+
/** The live transcription cut this source up to `sample` (16 kHz). */
|
|
96
|
+
function setCut(runtimeDir, id, { token, source, sample } = {}) {
|
|
97
|
+
const s = load(runtimeDir, id);
|
|
98
|
+
if (!s || s.closed || (token && s.token !== token) || !Number.isInteger(sample)) return;
|
|
99
|
+
s.cut = s.cut || {};
|
|
100
|
+
if (sample <= (s.cut[source] || 0)) return;
|
|
101
|
+
s.cut[source] = sample;
|
|
102
|
+
save(runtimeDir, id, s);
|
|
103
|
+
}
|
|
104
|
+
/** After a restart the live transcription lost what it had buffered: the stretch [from, to) of a source that was written but not
|
|
105
|
+
* transcribed, ending where the incoming chunk starts. null when there is none. */
|
|
106
|
+
function untranscribed(runtimeDir, id, { source, before } = {}) {
|
|
107
|
+
const s = load(runtimeDir, id);
|
|
108
|
+
if (!s || s.closed) return null;
|
|
109
|
+
const base = Math.round((s.base_ms || 0) * 16);
|
|
110
|
+
const from = Math.max(base, (s.cut || {})[source] || 0);
|
|
111
|
+
const to = Math.min(before, (s.fed || {})[source] || 0);
|
|
112
|
+
return to > from ? { from, to } : null;
|
|
113
|
+
}
|
|
114
|
+
function tokened(runtimeDir, id, { now = Date.now() } = {}) { const s = load(runtimeDir, id); return isLive(s, now) && !!s.tokened; }
|
|
115
|
+
function close(runtimeDir, id) {
|
|
116
|
+
const s = load(runtimeDir, id);
|
|
117
|
+
if (!s || s.closed) return;
|
|
118
|
+
s.closed = true;
|
|
119
|
+
save(runtimeDir, id, s);
|
|
120
|
+
}
|
|
121
|
+
function remove(runtimeDir, id) {
|
|
122
|
+
if (!ID_RE.test(String(id || ''))) return;
|
|
123
|
+
const f = fileOf(runtimeDir, id);
|
|
124
|
+
cache.delete(f);
|
|
125
|
+
try { fs.unlinkSync(f); } catch { /* none */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { start, busy, check, mark, setCut, untranscribed, close, tokened, remove, LIVE_MS };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-live-guard.js — what the live transcript must not keep (notes-recording-reliability). Pure functions, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* stripEcho(mic, meetingLines) → the microphone line's text without the meeting's words, or '' when nothing of its own is left
|
|
5
|
+
* repeats(text, previous) → true when a cut only says again what the line before it said
|
|
6
|
+
* onceEach(text) → the text with a sentence repeated back to back kept once
|
|
7
|
+
* looksForeign(text, reported) → true when a cut that should be English came back in another language
|
|
8
|
+
*
|
|
9
|
+
* ECHO. On speakers the remote voice also reaches the microphone, so the microphone's cut holds the meeting's words a second
|
|
10
|
+
* time. lib/notes-final.js drops such lines after Stop (dropEcho); this is the same rule, live: a run of ECHO_RUN or more words
|
|
11
|
+
* that a meeting line overlapping in time also holds (and that is at least half of that line) is taken out of the microphone line, and what is left is dropped when it
|
|
12
|
+
* still overlaps the meeting line by ECHO_OVERLAP of its length with ECHO_WORDS of its words the same (dropEcho's numbers).
|
|
13
|
+
* The PM's own words on the microphone stay.
|
|
14
|
+
*
|
|
15
|
+
* REPETITION. Whisper repeats itself, more so when the previous line is in its prompt (no longer passed). A cut whose words are
|
|
16
|
+
* the previous line's words, or end it, adds nothing. A short reply ("Yes.") said again later is kept: a short line counts
|
|
17
|
+
* as a repeat only within REPEAT_MS.
|
|
18
|
+
*
|
|
19
|
+
* LANGUAGE (decision 8 of the console3 feedback plan: accented English is always English). A cut is foreign when the engine
|
|
20
|
+
* says another language, when most of its letters are not Latin, or when most of its words are Indonesian function words.
|
|
21
|
+
*/
|
|
22
|
+
const ECHO_OVERLAP = 0.5;
|
|
23
|
+
const ECHO_WORDS = 0.5;
|
|
24
|
+
const ECHO_RUN = 4;
|
|
25
|
+
const ECHO_SLACK_MS = 1500;
|
|
26
|
+
const REPEAT_MS = 15000;
|
|
27
|
+
|
|
28
|
+
const norm = (w) => String(w || '').toLowerCase().replace(/[^\p{L}\p{N}']/gu, '');
|
|
29
|
+
const tokens = (text) => String(text || '').split(/\s+/).filter(Boolean);
|
|
30
|
+
const wordsOf = (t) => new Set(String(t || '').toLowerCase().match(/[\p{L}\p{N}']{3,}/gu) || []);
|
|
31
|
+
function sameWords(a, b) {
|
|
32
|
+
const x = wordsOf(a); const y = wordsOf(b);
|
|
33
|
+
if (!x.size || !y.size) return 0;
|
|
34
|
+
let both = 0; for (const w of x) if (y.has(w)) both++;
|
|
35
|
+
return both / Math.min(x.size, y.size);
|
|
36
|
+
}
|
|
37
|
+
/** The longest run of words the two token lists share, as { at, len } in `a`. */
|
|
38
|
+
function longestRun(a, b) {
|
|
39
|
+
let best = { at: 0, len: 0 };
|
|
40
|
+
for (let i = 0; i < a.length; i++) {
|
|
41
|
+
for (let j = 0; j < b.length; j++) {
|
|
42
|
+
let k = 0;
|
|
43
|
+
while (i + k < a.length && j + k < b.length && a[i + k] && a[i + k] === b[j + k]) k++;
|
|
44
|
+
if (k > best.len) best = { at: i, len: k };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
const overlapMs = (a, b) => Math.min(a.end, b.end) - Math.max(a.t, b.t);
|
|
50
|
+
|
|
51
|
+
/** `mic` and each meeting line: { t, end, text }. → the microphone text that is the PM's own. */
|
|
52
|
+
function stripEcho(mic, meetingLines) {
|
|
53
|
+
const near = (meetingLines || []).filter((g) => overlapMs({ t: mic.t - ECHO_SLACK_MS, end: mic.end + ECHO_SLACK_MS }, g) > 0);
|
|
54
|
+
if (!near.length) return String(mic.text || '').trim();
|
|
55
|
+
let toks = tokens(mic.text);
|
|
56
|
+
for (const g of near) {
|
|
57
|
+
const theirs = tokens(g.text).map(norm);
|
|
58
|
+
const need = theirs.filter(Boolean).length * ECHO_WORDS;
|
|
59
|
+
// the runs of ECHO_RUN words or more this meeting line shares with the microphone line; they are echo together when they
|
|
60
|
+
// cover at least half of the meeting line: an echo repeats most of what the remote speaker said, a PM quoting a phrase does not.
|
|
61
|
+
// A microphone line that is wholly the meeting's words goes, however short.
|
|
62
|
+
let work = toks.slice(); const cuts = []; let covered = 0;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const mine = work.map(norm);
|
|
65
|
+
const r = longestRun(mine, theirs);
|
|
66
|
+
const whole = r.len && r.len >= mine.filter(Boolean).length && !cuts.length;
|
|
67
|
+
if (!r.len || (!whole && r.len < ECHO_RUN)) break;
|
|
68
|
+
covered += r.len; cuts.push(r);
|
|
69
|
+
work = work.slice(0, r.at).concat(new Array(r.len).fill('')).concat(work.slice(r.at + r.len));
|
|
70
|
+
if (whole) { covered = Infinity; break; }
|
|
71
|
+
}
|
|
72
|
+
if (cuts.length && covered >= need) toks = work.filter(Boolean);
|
|
73
|
+
if (!toks.length) return '';
|
|
74
|
+
}
|
|
75
|
+
const left = toks.join(' ').replace(/^[\s,.;:!?…-]+/, '').trim();
|
|
76
|
+
if (!/[\p{L}\p{N}]/u.test(left)) return '';
|
|
77
|
+
const len = Math.max(1, mic.end - mic.t);
|
|
78
|
+
if (near.some((g) => overlapMs(mic, g) / len >= ECHO_OVERLAP && sameWords(left, g.text) >= ECHO_WORDS)) return '';
|
|
79
|
+
return left;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Does `text` only repeat `previous` ({ text, t }) of the same source? `t` is the new cut's time. */
|
|
83
|
+
function repeats(text, previous, t = 0) {
|
|
84
|
+
if (!previous || !previous.text) return false;
|
|
85
|
+
const a = tokens(text).map(norm).filter(Boolean); const b = tokens(previous.text).map(norm).filter(Boolean);
|
|
86
|
+
if (!a.length || a.length > b.length) return false;
|
|
87
|
+
const tail = b.slice(b.length - a.length);
|
|
88
|
+
if (a.join(' ') !== tail.join(' ')) return false;
|
|
89
|
+
return a.length >= 3 || Math.abs(t - (previous.t || 0)) <= REPEAT_MS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A sentence said twice or more back to back inside one cut is kept once. */
|
|
93
|
+
function onceEach(text) {
|
|
94
|
+
const parts = String(text || '').match(/[^.!?…]+[.!?…]*\s*/g);
|
|
95
|
+
if (!parts || parts.length < 2) return String(text || '').trim();
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const p of parts) { const k = tokens(p).map(norm).join(' '); if (out.length && k && k === tokens(out[out.length - 1]).map(norm).join(' ')) continue; out.push(p); }
|
|
98
|
+
return out.join('').trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ID_WORDS = new Set(['yang', 'dan', 'di', 'ini', 'itu', 'tidak', 'dengan', 'untuk', 'ada', 'saya', 'kita', 'kami', 'akan', 'sudah', 'belum', 'juga',
|
|
102
|
+
'dari', 'ke', 'karena', 'jadi', 'bisa', 'apa', 'kalau', 'tapi', 'atau', 'nya', 'lagi', 'harus', 'sama', 'mau', 'gak', 'nggak', 'ya', 'kamu', 'mereka', 'dia', 'pada', 'dalam', 'seperti', 'lebih', 'masih', 'aja', 'saja']);
|
|
103
|
+
/** Did a cut that should be English come back in another language? `reported` is the engine's language name, when it says one. */
|
|
104
|
+
function looksForeign(text, reported) {
|
|
105
|
+
const t = String(text || '');
|
|
106
|
+
if (reported && !/^(en|english)$/i.test(String(reported).trim())) return true;
|
|
107
|
+
const letters = t.match(/\p{L}/gu) || [];
|
|
108
|
+
if (letters.length) {
|
|
109
|
+
const latin = letters.filter((c) => /\p{Script=Latin}/u.test(c)).length;
|
|
110
|
+
if (latin / letters.length < 0.7) return true;
|
|
111
|
+
}
|
|
112
|
+
const ws = (t.toLowerCase().match(/[a-z]+/g) || []);
|
|
113
|
+
if (ws.length >= 4 && ws.filter((w) => ID_WORDS.has(w)).length / ws.length >= 0.3) return true;
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { stripEcho, repeats, onceEach, looksForeign, sameWords, ECHO_OVERLAP, ECHO_WORDS, ECHO_RUN, REPEAT_MS };
|