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.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +35 -2
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-audio.js +28 -9
  83. package/workspace/lib/notes-capture.js +136 -0
  84. package/workspace/lib/notes-final.js +38 -13
  85. package/workspace/lib/notes-live-guard.js +117 -0
  86. package/workspace/lib/notes-replay.js +125 -0
  87. package/workspace/lib/notes-screen.js +180 -0
  88. package/workspace/lib/notes-whisper.js +139 -16
  89. package/workspace/lib/notes.js +4 -2
  90. package/workspace/lib/outbox-store.js +10 -6
  91. package/workspace/lib/pmos-verbs.js +3 -1
  92. package/workspace/lib/proactive-cli.js +7 -0
  93. package/workspace/lib/proactive.js +15 -2
  94. package/workspace/lib/repo-script.js +36 -0
  95. package/workspace/lib/report-dashboards.js +5 -2
  96. package/workspace/lib/report-frame.js +122 -0
  97. package/workspace/lib/report-schedules.js +113 -0
  98. package/workspace/lib/route-modules.js +9 -2
  99. package/workspace/lib/routes-boards.js +734 -0
  100. package/workspace/lib/routes-brief-page.js +13 -4
  101. package/workspace/lib/routes-briefing.js +22 -11
  102. package/workspace/lib/routes-c3-init.js +4 -3
  103. package/workspace/lib/routes-c3-life.js +2 -1
  104. package/workspace/lib/routes-c3-reports.js +96 -13
  105. package/workspace/lib/routes-c3-strategy.js +4 -3
  106. package/workspace/lib/routes-c3-timeline.js +3 -2
  107. package/workspace/lib/routes-calendar-import.js +3 -2
  108. package/workspace/lib/routes-calendar-views.js +9 -4
  109. package/workspace/lib/routes-doc-health.js +2 -1
  110. package/workspace/lib/routes-doc-sheet.js +2 -1
  111. package/workspace/lib/routes-forecast.js +2 -1
  112. package/workspace/lib/routes-hooks.js +3 -2
  113. package/workspace/lib/routes-initiatives.js +2 -1
  114. package/workspace/lib/routes-intake.js +2 -1
  115. package/workspace/lib/routes-notes.js +113 -15
  116. package/workspace/lib/routes-outbox.js +23 -3
  117. package/workspace/lib/routes-pm-items.js +19 -6
  118. package/workspace/lib/routes-records.js +3 -2
  119. package/workspace/lib/routes-releases.js +2 -1
  120. package/workspace/lib/routes-report-facts.js +4 -3
  121. package/workspace/lib/routes-report-families.js +2 -1
  122. package/workspace/lib/routes-roadmap-ops.js +5 -4
  123. package/workspace/lib/routes-runs.js +6 -5
  124. package/workspace/lib/routes-settings.js +51 -0
  125. package/workspace/lib/routes-signals.js +3 -2
  126. package/workspace/lib/routes-stage-tools.js +220 -0
  127. package/workspace/lib/run-answers.js +2 -1
  128. package/workspace/lib/settings-store.js +226 -0
  129. package/workspace/lib/stage-tools.js +208 -0
  130. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  131. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  132. package/workspace/lib/work-evidence.js +73 -0
  133. package/workspace/package-lock.json +2 -2
  134. package/workspace/package.json +1 -1
  135. package/workspace/public/pm/briefing-deck.js +63 -17
  136. package/workspace/public/pm/briefing-model.js +36 -7
  137. package/workspace/public/pm/c3-ambient.js +100 -0
  138. package/workspace/public/pm/c3-brief-needs.js +26 -6
  139. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  140. package/workspace/public/pm/c3-brief-today.js +56 -21
  141. package/workspace/public/pm/c3-brief.js +2 -1
  142. package/workspace/public/pm/c3-calendar.js +161 -42
  143. package/workspace/public/pm/c3-calpop.js +5 -5
  144. package/workspace/public/pm/c3-demo-boards.js +649 -0
  145. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  146. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  147. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  148. package/workspace/public/pm/c3-demo-data.js +17 -27
  149. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  150. package/workspace/public/pm/c3-demo.js +2 -1
  151. package/workspace/public/pm/c3-dock.js +1 -1
  152. package/workspace/public/pm/c3-ib-docs.js +80 -0
  153. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  154. package/workspace/public/pm/c3-ib-items.js +201 -0
  155. package/workspace/public/pm/c3-ib-releases.js +51 -0
  156. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  157. package/workspace/public/pm/c3-ib-triage.js +178 -0
  158. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  159. package/workspace/public/pm/c3-ini-records.js +197 -0
  160. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  161. package/workspace/public/pm/c3-ini-ui.js +229 -0
  162. package/workspace/public/pm/c3-ini-work.js +139 -0
  163. package/workspace/public/pm/c3-initiatives.css +218 -0
  164. package/workspace/public/pm/c3-initiatives.js +741 -0
  165. package/workspace/public/pm/c3-plan.js +2 -2
  166. package/workspace/public/pm/c3-reports.css +47 -47
  167. package/workspace/public/pm/c3-reports.js +223 -185
  168. package/workspace/public/pm/c3-settings.css +74 -0
  169. package/workspace/public/pm/c3-settings.js +703 -0
  170. package/workspace/public/pm/c3-shell.js +102 -36
  171. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  172. package/workspace/public/pm/c3-terminal.js +2 -2
  173. package/workspace/public/pm/calendar-views.js +26 -13
  174. package/workspace/public/pm/components.css +319 -213
  175. package/workspace/public/pm/console3.css +118 -23
  176. package/workspace/public/pm/dest-notes.js +26 -4
  177. package/workspace/public/pm/notes-screen-capture.js +165 -0
  178. package/workspace/public/pm/notes-screen-view.js +98 -0
  179. package/workspace/public/pm/panel-trees.js +5 -2
  180. package/workspace/public/pm/pm-work-view.js +233 -146
  181. package/workspace/public/pm/shell-bell.js +3 -2
  182. package/workspace/public/pm/shell-recording.js +148 -17
  183. package/workspace/public/pm/tokens.css +2 -2
  184. package/workspace/public/pm/view-notes.js +53 -11
  185. package/workspace/public/pm/welcome.js +197 -89
  186. package/workspace/public/welcome.html +6 -4
  187. package/workspace/server.js +105 -6
  188. package/workspace/skills/day-check.skill +1 -1
  189. package/workspace/skills/jira-sync.skill +6 -4
  190. package/workspace/skills/morning-brief.skill +1 -1
  191. package/workspace/test-files.json +14 -3
  192. package/workspace/test-lists/c3-ib-items.json +9 -0
  193. package/workspace/test-lists/c3-ib-more.json +9 -0
  194. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  195. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  196. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  197. package/workspace/test-lists/console3-briefing.json +9 -0
  198. package/workspace/test-lists/console3-foundation.json +9 -0
  199. package/workspace/test-lists/console3-reports.json +3 -3
  200. package/workspace/test-lists/meeting-notes.json +12 -4
  201. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  202. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  203. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  204. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  205. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  206. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  207. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  208. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  209. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  210. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  211. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  212. 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 };