create-pmos 0.133.0 → 0.135.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/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +35 -2
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +136 -0
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +139 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +4 -3
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +96 -13
- package/workspace/lib/routes-c3-strategy.js +4 -3
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +113 -15
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +3 -2
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +5 -4
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +26 -13
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +118 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +148 -17
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +53 -11
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +14 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +12 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
|
@@ -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 };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-replay.js — notes-live-settle Run 1: what a replayed meeting shows, and what a settle tier would cost on this machine.
|
|
3
|
+
*
|
|
4
|
+
* words(text) → [word] lower case, letters and digits only
|
|
5
|
+
* overlap(a, b) → 0..1 the share of the shorter line's words that the other line also has
|
|
6
|
+
* duplicates(lines, { repeatMs, echoMs }) → [{ kind, a, b, overlap }] lines said twice: `repeat` (one source, close together)
|
|
7
|
+
* or `echo` (the microphone and the meeting, at the same moment)
|
|
8
|
+
* lag(seen) → { median_ms, p90_ms, max_ms, n } how long after its audio a live line appeared
|
|
9
|
+
* createSettleMeter({ transcribe, windowMs?, pauseMs?, now? }) → { feed(source, pcm), idle(), report(elapsedMs) }
|
|
10
|
+
*
|
|
11
|
+
* WHY. The PM reported duplicated audio and a duplicated transcript (notes-recording-reliability). That fix was measured on a
|
|
12
|
+
* scripted fixture; bin/notes-replay.js plays a real kept meeting through the live path again, and this module says what came out.
|
|
13
|
+
* The plan's near-realtime correction (console3 feedback plan 3.1, "settle tier") is built only if this machine keeps up: at each
|
|
14
|
+
* settle point the last WINDOW_MS of a source is transcribed again with the final pass's engine. The meter does that while the
|
|
15
|
+
* replay runs and measures the share of the meeting's time the engine was busy (`busy_share`, the real-time factor of the settle
|
|
16
|
+
* tier). The plan's gate: under 0.5, and no settle point skipped because the engine was still busy.
|
|
17
|
+
*
|
|
18
|
+
* WHAT IT NEVER DOES. It stores nothing and makes no request; `transcribe` is lib/notes-final.js transcribeClip, local.
|
|
19
|
+
*/
|
|
20
|
+
const whisper = require('./notes-whisper.js');
|
|
21
|
+
|
|
22
|
+
const RATE = whisper.RATE;
|
|
23
|
+
const REPEAT_MS = 15000; // a line said again by the same source within this time is a repeat
|
|
24
|
+
const ECHO_MS = 5000; // a microphone line and a meeting line this close are the same moment
|
|
25
|
+
const MIN_OVERLAP = 0.8;
|
|
26
|
+
const MIN_WORDS = 3; // a shorter line ("Yes.") said twice is a conversation, not a duplicate, unless it is the same text
|
|
27
|
+
const WINDOW_MS = 30000; // the plan's 20 to 40 s
|
|
28
|
+
const PAUSE_MS = 1500; // the plan's settle point: a pause of 1.5 s
|
|
29
|
+
const GATE = 0.5;
|
|
30
|
+
|
|
31
|
+
const words = (text) => String(text || '').toLowerCase().match(/[\p{L}\p{N}']+/gu) || [];
|
|
32
|
+
function overlap(a, b) {
|
|
33
|
+
const wa = words(a); const wb = words(b);
|
|
34
|
+
if (!wa.length || !wb.length) return 0;
|
|
35
|
+
const [short, long] = wa.length <= wb.length ? [wa, wb] : [wb, wa];
|
|
36
|
+
const bag = new Map(); for (const w of long) bag.set(w, (bag.get(w) || 0) + 1);
|
|
37
|
+
let hit = 0; for (const w of short) { const c = bag.get(w) || 0; if (c) { hit++; bag.set(w, c - 1); } }
|
|
38
|
+
return hit / short.length;
|
|
39
|
+
}
|
|
40
|
+
const endOf = (l) => (Number.isFinite(l.t_end) ? l.t_end : l.t);
|
|
41
|
+
|
|
42
|
+
/** Lines said twice. `lines` are transcript lines { t, t_end?, text, source }, in any order; dropped lines are left out. */
|
|
43
|
+
function duplicates(lines, { repeatMs = REPEAT_MS, echoMs = ECHO_MS } = {}) {
|
|
44
|
+
const ls = (lines || []).filter((l) => l && !l.dropped && Number.isFinite(l.t) && words(l.text).length).slice().sort((x, y) => x.t - y.t);
|
|
45
|
+
const out = [];
|
|
46
|
+
for (let i = 0; i < ls.length; i++) {
|
|
47
|
+
for (let j = i + 1; j < ls.length && ls[j].t - endOf(ls[i]) <= Math.max(repeatMs, echoMs); j++) {
|
|
48
|
+
const a = ls[i]; const b = ls[j];
|
|
49
|
+
const same = words(a.text).join(' ') === words(b.text).join(' ');
|
|
50
|
+
const o = overlap(a.text, b.text);
|
|
51
|
+
const long = Math.min(words(a.text).length, words(b.text).length) >= MIN_WORDS;
|
|
52
|
+
if (!(same || (long && o >= MIN_OVERLAP))) continue;
|
|
53
|
+
const gap = b.t - endOf(a);
|
|
54
|
+
if (a.source === b.source && gap <= repeatMs) out.push({ kind: 'repeat', a, b, overlap: Math.round(o * 100) / 100 });
|
|
55
|
+
else if (a.source !== b.source && gap <= echoMs) out.push({ kind: 'echo', a, b, overlap: Math.round(o * 100) / 100 });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const pct = (xs, q) => { if (!xs.length) return null; const s = xs.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor((s.length - 1) * q))]; };
|
|
62
|
+
/** `seen` is [{ late_ms }], one per live line: how long after the end of its audio the line appeared. */
|
|
63
|
+
function lag(seen) {
|
|
64
|
+
const xs = (seen || []).map((s) => s.late_ms).filter(Number.isFinite);
|
|
65
|
+
return { n: xs.length, median_ms: pct(xs, 0.5), p90_ms: pct(xs, 0.9), max_ms: xs.length ? Math.max(...xs) : null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The settle tier, measured. `feed` is called with each one-second chunk of a source as it is replayed.
|
|
70
|
+
* A settle point is a pause of PAUSE_MS after speech, or the other source starting to speak; at a settle point the last WINDOW_MS of
|
|
71
|
+
* that source goes to `transcribe(pcm)`. One run at a time: a settle point that comes while the engine is busy is skipped and counted.
|
|
72
|
+
*/
|
|
73
|
+
function createSettleMeter({ transcribe, windowMs = WINDOW_MS, pauseMs = PAUSE_MS, now = Date.now } = {}) {
|
|
74
|
+
const st = new Map(); // source → { buf: [Buffer], bytes, voiced: bool, quietMs, floor: [rms] }
|
|
75
|
+
const runs = []; let skipped = 0; let points = 0; let busy = null; let busyMs = 0; let engine = null; const errors = [];
|
|
76
|
+
let lastVoiced = null;
|
|
77
|
+
const keep = Math.round(windowMs * RATE / 1000) * 2;
|
|
78
|
+
const of = (source) => { if (!st.has(source)) st.set(source, { buf: [], bytes: 0, voiced: false, quietMs: 0, floor: [] }); return st.get(source); };
|
|
79
|
+
function point(source) {
|
|
80
|
+
points++;
|
|
81
|
+
if (busy) { skipped++; return; }
|
|
82
|
+
const s = of(source);
|
|
83
|
+
const pcm = Buffer.concat(s.buf).subarray(-keep);
|
|
84
|
+
const audioMs = (pcm.length / 2 / RATE) * 1000;
|
|
85
|
+
const began = now();
|
|
86
|
+
let started; try { started = Promise.resolve(transcribe(pcm)); } catch (e) { started = Promise.reject(e); }
|
|
87
|
+
busy = started.then((r) => {
|
|
88
|
+
const took = now() - began; busyMs += took;
|
|
89
|
+
if (r && r.ok) { engine = r.engine || engine; runs.push({ source, audio_ms: Math.round(audioMs), took_ms: took }); } else errors.push(String((r && r.error) || 'no answer').slice(0, 200));
|
|
90
|
+
}, (e) => { busyMs += now() - began; errors.push(String((e && e.message) || e).slice(0, 200)); }).finally(() => { busy = null; });
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
feed(source, pcm) {
|
|
94
|
+
const s = of(source);
|
|
95
|
+
s.buf.push(pcm); s.bytes += pcm.length;
|
|
96
|
+
while (s.bytes - s.buf[0].length >= keep) s.bytes -= s.buf.shift().length;
|
|
97
|
+
// a 100 ms frame is speech above three times the quiet of the last minute before it (its 10th percentile), and never below
|
|
98
|
+
// 180; until five seconds were heard, above 180
|
|
99
|
+
for (const v of whisper.levels(pcm)) {
|
|
100
|
+
const q = s.floor.length >= 50 ? pct(s.floor.filter((x) => x > 0), 0.1) || 0 : 0;
|
|
101
|
+
s.floor.push(v); if (s.floor.length > 600) s.floor.shift();
|
|
102
|
+
const voiced = v > Math.max(180, q * 3);
|
|
103
|
+
if (voiced) {
|
|
104
|
+
if (lastVoiced && lastVoiced !== source && of(lastVoiced).voiced) { of(lastVoiced).voiced = false; point(lastVoiced); }
|
|
105
|
+
s.voiced = true; s.quietMs = 0; lastVoiced = source;
|
|
106
|
+
} else if (s.voiced) {
|
|
107
|
+
s.quietMs += 100;
|
|
108
|
+
if (s.quietMs >= pauseMs) { s.voiced = false; point(source); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
idle() { return busy || Promise.resolve(); },
|
|
113
|
+
report(elapsedMs) {
|
|
114
|
+
const rtf = runs.map((r) => r.took_ms / Math.max(1, r.audio_ms));
|
|
115
|
+
const share = elapsedMs > 0 ? busyMs / elapsedMs : null;
|
|
116
|
+
const keepsUp = share !== null && share < GATE && skipped === 0 && runs.length > 0;
|
|
117
|
+
return { engine, points, runs: runs.length, skipped, errors: errors.slice(0, 5), busy_ms: busyMs, elapsed_ms: elapsedMs,
|
|
118
|
+
busy_share: share === null ? null : Math.round(share * 1000) / 1000,
|
|
119
|
+
window_rtf_median: rtf.length ? Math.round(pct(rtf, 0.5) * 1000) / 1000 : null, window_rtf_p90: rtf.length ? Math.round(pct(rtf, 0.9) * 1000) / 1000 : null,
|
|
120
|
+
gate: GATE, keeps_up: keepsUp };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { words, overlap, duplicates, lag, createSettleMeter, REPEAT_MS, ECHO_MS, WINDOW_MS, PAUSE_MS, GATE };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-screen.js — the screenshots a note keeps of the screen the PM shared in the meeting (notes-screen-capture). Local only.
|
|
3
|
+
*
|
|
4
|
+
* add(runtimeDir, { id, t, buf, w, h }) → { ok, frame } | { ok: false, status, error } keep one shot at meeting time t (ms)
|
|
5
|
+
* list(runtimeDir, id) → [{ t, file, w, h, bytes, why?, text? }] sorted by t; empty when the note has none
|
|
6
|
+
* serve(req, res, runtimeDir, id, t) the shot at t as its image, cached for good; 404 JSON when there is none
|
|
7
|
+
* remove(runtimeDir, id) → boolean delete the note's screenshots
|
|
8
|
+
* getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
|
|
9
|
+
* ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
|
|
10
|
+
* ocrIdle() → Promise resolves when the text queue is empty (tests)
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT KEEPS. A folder next to the note's audio, `<runtime>/notes/recordings/<id>.screen/`: one image file per shot, named by
|
|
13
|
+
* its meeting time in milliseconds (`000123456.jpg`), and `index.json`, an array of { t, file, w, h, bytes, why?, text? } sorted by t,
|
|
14
|
+
* written whole through a temporary file and renamed, so a stop in the middle never leaves half an index. A shot at the same
|
|
15
|
+
* time as an earlier one replaces it. A note keeps at most MAX_SHOTS shots or MAX_BYTES bytes; a shot past either is refused
|
|
16
|
+
* with 409 and a plain sentence, and the shots already kept stay.
|
|
17
|
+
*
|
|
18
|
+
* WHICH BYTES. Only a JPEG, a PNG or a WebP, found by the file's first bytes and not by what the page says it is, and at most
|
|
19
|
+
* MAX_SHOT_BYTES each. The page sends a JPEG (public/pm), so a shot is far under the limit.
|
|
20
|
+
*
|
|
21
|
+
* ON-SCREEN TEXT. When tesseract is on this machine (PMOS_TESSERACT names it, else the PATH and where Homebrew puts it), each
|
|
22
|
+
* kept shot is read for its text, one at a time and in the background, and the text is added to that shot's row in the index.
|
|
23
|
+
* A failed or timed-out read leaves the shot with no text and is not an error. Without tesseract nothing is read.
|
|
24
|
+
*
|
|
25
|
+
* WHAT IT NEVER DOES. It sends nothing and opens no network module. The only program it runs is the tesseract the PM installed,
|
|
26
|
+
* on a file in the note's own folder, with no shell and a time limit. The routes (lib/routes-notes.js) keep no shot for a private
|
|
27
|
+
* note, for a note that is not listening, or when the PM switched capturing off, and delete the folder when a note is made
|
|
28
|
+
* private or deleted.
|
|
29
|
+
*/
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { execFile } = require('child_process');
|
|
33
|
+
|
|
34
|
+
const MAX_SHOT_BYTES = 2 * 1024 * 1024;
|
|
35
|
+
const MAX_SHOTS = 2000;
|
|
36
|
+
const MAX_BYTES = 500 * 1024 * 1024;
|
|
37
|
+
const MAX_T = 999999999; // the file name holds nine digits of milliseconds
|
|
38
|
+
const OCR_TIMEOUT_MS = 30000;
|
|
39
|
+
const OCR_MAX_CHARS = 4000;
|
|
40
|
+
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
41
|
+
const FILE_RE = /^\d{9}\.(jpg|png|webp)$/;
|
|
42
|
+
const TYPES = { jpg: 'image/jpeg', png: 'image/png', webp: 'image/webp' };
|
|
43
|
+
|
|
44
|
+
const dirOf = (runtimeDir, id) => path.join(runtimeDir, 'notes', 'recordings', id + '.screen');
|
|
45
|
+
const indexOf = (runtimeDir, id) => path.join(dirOf(runtimeDir, id), 'index.json');
|
|
46
|
+
const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'screen-settings.json');
|
|
47
|
+
|
|
48
|
+
function readSettings(runtimeDir) { try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s && typeof s === 'object' ? s : {}; } catch { return {}; } }
|
|
49
|
+
function writeAtomic(file, text) {
|
|
50
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
51
|
+
const tmp = file + '.' + process.pid + '.tmp';
|
|
52
|
+
fs.writeFileSync(tmp, text, { mode: 0o600 });
|
|
53
|
+
fs.renameSync(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
/** "Capture the shared screen": on until the PM turns it off, for this machine. */
|
|
56
|
+
function getOn(runtimeDir) { return readSettings(runtimeDir).on !== false; }
|
|
57
|
+
function setOn(runtimeDir, on) {
|
|
58
|
+
if (typeof on !== 'boolean') return { ok: false, error: 'say screen: true or screen: false' };
|
|
59
|
+
writeAtomic(settingsFile(runtimeDir), JSON.stringify({ ...readSettings(runtimeDir), on }));
|
|
60
|
+
return { ok: true, on };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The image type the first bytes name: 'jpg' | 'png' | 'webp' | null. */
|
|
64
|
+
function kindOf(buf) {
|
|
65
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12) return null;
|
|
66
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'jpg';
|
|
67
|
+
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'png';
|
|
68
|
+
if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP') return 'webp';
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readIndex(runtimeDir, id) {
|
|
73
|
+
try {
|
|
74
|
+
const a = JSON.parse(fs.readFileSync(indexOf(runtimeDir, id), 'utf8'));
|
|
75
|
+
if (!Array.isArray(a)) return [];
|
|
76
|
+
return a.filter((f) => f && Number.isFinite(f.t) && typeof f.file === 'string' && FILE_RE.test(f.file)).sort((x, y) => x.t - y.t);
|
|
77
|
+
} catch { return []; }
|
|
78
|
+
}
|
|
79
|
+
const writeIndex = (runtimeDir, id, rows) => writeAtomic(indexOf(runtimeDir, id), JSON.stringify(rows));
|
|
80
|
+
const dimension = (n) => (Number.isFinite(Number(n)) && Number(n) > 0 ? Math.min(20000, Math.round(Number(n))) : 0);
|
|
81
|
+
|
|
82
|
+
/** Keep one shot. `t` is the meeting time in ms, `buf` the image bytes, `w` and `h` its size in pixels. */
|
|
83
|
+
function add(runtimeDir, { id, t, buf, w, h, why } = {}) {
|
|
84
|
+
if (!ID_RE.test(String(id || ''))) return { ok: false, status: 400, error: 'no such note' };
|
|
85
|
+
const time = typeof t === 'number' ? t : NaN;
|
|
86
|
+
if (!Number.isFinite(time) || time < 0 || time > MAX_T) return { ok: false, status: 400, error: 'a screenshot needs its meeting time' };
|
|
87
|
+
const at = Math.round(time);
|
|
88
|
+
if (!Buffer.isBuffer(buf) || !buf.length) return { ok: false, status: 400, error: 'there is no picture to keep' };
|
|
89
|
+
if (buf.length > MAX_SHOT_BYTES) return { ok: false, status: 413, error: 'that screenshot is larger than 2 MB' };
|
|
90
|
+
const kind = kindOf(buf);
|
|
91
|
+
if (!kind) return { ok: false, status: 400, error: 'a screenshot must be a JPEG, PNG or WebP picture' };
|
|
92
|
+
const rows = readIndex(runtimeDir, id);
|
|
93
|
+
const old = rows.find((f) => f.t === at);
|
|
94
|
+
const others = rows.filter((f) => f !== old);
|
|
95
|
+
if (others.length >= MAX_SHOTS) return { ok: false, status: 409, error: 'this note already keeps ' + MAX_SHOTS + ' screenshots, the most it holds' };
|
|
96
|
+
if (others.reduce((n, f) => n + (f.bytes || 0), 0) + buf.length > MAX_BYTES) return { ok: false, status: 409, error: 'this note\'s screenshots have reached 500 MB, the most it holds' };
|
|
97
|
+
const file = String(at).padStart(9, '0') + '.' + kind;
|
|
98
|
+
const dir = dirOf(runtimeDir, id);
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
const target = path.join(dir, file);
|
|
101
|
+
const tmp = target + '.' + process.pid + '.tmp';
|
|
102
|
+
fs.writeFileSync(tmp, buf, { mode: 0o600 });
|
|
103
|
+
fs.renameSync(tmp, target);
|
|
104
|
+
if (old && old.file !== file) { try { fs.unlinkSync(path.join(dir, old.file)); } catch { /* none */ } }
|
|
105
|
+
const frame = { t: at, file, w: dimension(w), h: dimension(h), bytes: buf.length };
|
|
106
|
+
// why the page kept it (the screen changed, a line pointed at it, a highlight): plain words, kept short
|
|
107
|
+
const reason = typeof why === 'string' ? why.replace(/\s+/g, ' ').trim().slice(0, 120) : '';
|
|
108
|
+
if (reason) frame.why = reason;
|
|
109
|
+
writeIndex(runtimeDir, id, others.concat([frame]).sort((x, y) => x.t - y.t));
|
|
110
|
+
queueOcr(runtimeDir, id, frame);
|
|
111
|
+
return { ok: true, frame };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function list(runtimeDir, id) {
|
|
115
|
+
if (!ID_RE.test(String(id || ''))) return [];
|
|
116
|
+
return readIndex(runtimeDir, id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The shot at meeting time `t` as its image. A shot is never changed once kept, so the browser may keep it for good. */
|
|
120
|
+
function serve(req, res, runtimeDir, id, t) {
|
|
121
|
+
const miss = () => { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no screenshot at that time' })); };
|
|
122
|
+
if (!ID_RE.test(String(id || '')) || t === null || t === undefined || t === '' || !Number.isFinite(Number(t))) return miss();
|
|
123
|
+
const at = Math.round(Number(t));
|
|
124
|
+
const f = readIndex(runtimeDir, id).find((x) => x.t === at);
|
|
125
|
+
if (!f) return miss();
|
|
126
|
+
const file = path.join(dirOf(runtimeDir, id), f.file);
|
|
127
|
+
let size;
|
|
128
|
+
try { size = fs.statSync(file).size; } catch { return miss(); }
|
|
129
|
+
res.writeHead(200, { 'Content-Type': TYPES[path.extname(f.file).slice(1)] || 'application/octet-stream', 'Content-Length': size, 'Cache-Control': 'private, max-age=31536000, immutable' });
|
|
130
|
+
if (req.method === 'HEAD') { res.end(); return; }
|
|
131
|
+
fs.createReadStream(file).on('error', () => res.end()).pipe(res);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Delete the note's screenshots. True when a folder was there. */
|
|
135
|
+
function remove(runtimeDir, id) {
|
|
136
|
+
if (!ID_RE.test(String(id || ''))) return false;
|
|
137
|
+
const dir = dirOf(runtimeDir, id);
|
|
138
|
+
const had = fs.existsSync(dir);
|
|
139
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
140
|
+
return had;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ── on-screen text ──────────────────────────────────────────────────────────────────────────────── */
|
|
144
|
+
const isExec = (f) => { try { fs.accessSync(f, fs.constants.X_OK); return fs.statSync(f).isFile(); } catch { return false; } };
|
|
145
|
+
/** The tesseract on this machine: PMOS_TESSERACT, else the PATH, else where Homebrew puts it. */
|
|
146
|
+
function ocrDetect({ env = process.env, fallbacks = ['/opt/homebrew/bin', '/usr/local/bin'] } = {}) {
|
|
147
|
+
const named = env.PMOS_TESSERACT;
|
|
148
|
+
if (named) return isExec(named) ? { bin: named } : null;
|
|
149
|
+
for (const d of String(env.PATH || '').split(path.delimiter).concat(fallbacks)) { if (!d) continue; const f = path.join(d, 'tesseract'); if (isExec(f)) return { bin: f }; }
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const tidy = (s) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, OCR_MAX_CHARS);
|
|
153
|
+
|
|
154
|
+
let queue = Promise.resolve();
|
|
155
|
+
let waiting = 0;
|
|
156
|
+
function queueOcr(runtimeDir, id, frame) {
|
|
157
|
+
const found = ocrDetect();
|
|
158
|
+
if (!found) return;
|
|
159
|
+
waiting++;
|
|
160
|
+
queue = queue.then(() => readText(found.bin, runtimeDir, id, frame)).catch(() => {}).then(() => { waiting--; });
|
|
161
|
+
}
|
|
162
|
+
function readText(bin, runtimeDir, id, frame) {
|
|
163
|
+
const file = path.join(dirOf(runtimeDir, id), frame.file);
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
execFile(bin, [file, 'stdout'], { timeout: OCR_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
|
|
166
|
+
const text = err ? '' : tidy(stdout);
|
|
167
|
+
if (text) {
|
|
168
|
+
// patch the row only if the same shot is still there (it may have been replaced or deleted while it was read)
|
|
169
|
+
const rows = readIndex(runtimeDir, id);
|
|
170
|
+
const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes);
|
|
171
|
+
if (row) { row.text = text; try { writeIndex(runtimeDir, id, rows); } catch { /* the folder went away */ } }
|
|
172
|
+
}
|
|
173
|
+
resolve();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/** Resolves when every text read queued so far has ended. */
|
|
178
|
+
function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
|
|
179
|
+
|
|
180
|
+
module.exports = { add, list, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|