create-pmos 0.241.0 → 0.242.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 (39) 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/guide/console-calendar.md +14 -0
  7. package/workspace/guide/notes.md +22 -1
  8. package/workspace/lib/meeting-material.js +151 -0
  9. package/workspace/lib/meeting-replay.js +170 -0
  10. package/workspace/lib/notes-screen.js +128 -8
  11. package/workspace/lib/route-modules.js +2 -0
  12. package/workspace/lib/routes-meeting-focus.js +5 -3
  13. package/workspace/lib/routes-meeting-material.js +143 -0
  14. package/workspace/lib/routes-meeting-replay.js +75 -0
  15. package/workspace/lib/routes-notes.js +37 -4
  16. package/workspace/package-lock.json +2 -2
  17. package/workspace/package.json +1 -1
  18. package/workspace/public/pm/c3-calendar.js +15 -3
  19. package/workspace/public/pm/c3-demo-material.js +58 -0
  20. package/workspace/public/pm/c3-demo.js +5 -1
  21. package/workspace/public/pm/c3-focus.js +83 -16
  22. package/workspace/public/pm/c3-listen.css +10 -0
  23. package/workspace/public/pm/c3-material.css +22 -0
  24. package/workspace/public/pm/c3-material.js +311 -0
  25. package/workspace/public/pm/c3-notes.js +12 -4
  26. package/workspace/public/pm/c3-replay.css +127 -0
  27. package/workspace/public/pm/c3-replay.js +413 -0
  28. package/workspace/public/pm/dest-notes.js +4 -2
  29. package/workspace/public/pm/listen-sheet.js +94 -0
  30. package/workspace/public/pm/listen-sources.js +82 -0
  31. package/workspace/public/pm/notes-aside.js +62 -0
  32. package/workspace/public/pm/notes-screen-capture.js +23 -10
  33. package/workspace/public/pm/notes-screen-view.js +18 -5
  34. package/workspace/public/pm/notes-shots.js +55 -0
  35. package/workspace/public/pm/shell-recording.js +10 -2
  36. package/workspace/public/pm/view-notes.js +48 -8
  37. package/workspace/test-lists/meeting-material.json +6 -0
  38. package/workspace/test-lists/meeting-replay.json +6 -0
  39. package/workspace/test-lists/mm-capture.json +15 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.241.0",
3
+ "version": "0.242.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@05129df
1
+ kernel-source: pmos@f07c531
2
2
  assembled: 2026-10-14
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-14T14:33:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T15:23:59Z"}
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-14T14:33:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T15:23:59Z"}
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-14T14:33:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T15:23:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -156,6 +156,20 @@ To fix the topic, press **Topics**. Press **Not this topic** and pick the topic
156
156
 
157
157
  The companion starts only while the meeting is recorded. It reads the new lines of the transcript at most every 40 seconds. It writes the sentence, the chips, the topic and the notes. If the answer is not usable, PMOS drops it and shows nothing. To stop the companion, set `PMOS_FOCUS_AGENT` to `off`. The page then shows the transcript and a quiet line, and it adds no notes. In the last 5 minutes, and after the meeting, the page shows the captured items under the material. You confirm them in the note after the meeting. Nothing goes to other people.
158
158
 
159
+ ### Meeting material
160
+
161
+ Link the documents, reports and decks that you want to show or present in a meeting. The card of a meeting that has not ended has a **Material** part. It lists each linked item with its kind, the topic it is for, where it came from (the invite, the morning brief, or you), and whether it is for this meeting or for every meeting in the series. Each item has **Present** or **Open**, and a remove button (x). Items from the invite and the morning brief have no remove button. PMOS keeps your items in its own record (`meeting-material.jsonl` in the runtime folder). It does not change your calendar invite. Removing an item does not change the document.
162
+
163
+ Press **Add material** to open the sheet:
164
+
165
+ - **Suggested for this meeting**: the documents of the level of the meeting (the initiative it is about) and the newest reports and decks, with the reason for each.
166
+ - **Find in PMOS**: type two letters or more to search the titles of documents and report decks.
167
+ - **Or paste a link**: a Google Slides, Google Docs or Confluence link, or any https address. A web page opens in a new tab. PMOS does not read it.
168
+
169
+ After you pick an item, choose **For** (this meeting, or every meeting in the series), **Topic** (one of the topics of the meeting, or the whole meeting) and **You** (**Present it** or **Only read it**, for a PMOS deck or a Google Slides link).
170
+
171
+ In **Focus**, the material shows next to the links of the invite. An item for a topic comes up on the stage when the room is on that topic. When the room moves to another topic, the stage goes back to the first link. Press an item in the **Material** switch to keep it on the stage. A PMOS deck or report opens in PMOS. A PMOS deck has **Present**, which opens the presenter view with your speaker notes, and **Audience view**, which shows the slides only, full screen. Press it before you share your screen in the call. When nothing is linked, the Material part of Focus has **Add material**.
172
+
159
173
  ### Meeting focus over Google Meet
160
174
 
161
175
  The PMOS Chrome extension shows a small panel over a Google Meet call, so you do not need to switch to the PMOS tab. The panel is the Meeting focus page in a compact form. It has a head with the topic the room is on (or "Starts in" before the start) and the time left, the **What's being said** line, the conversation with PMOS, and **Ask PMOS quietly**. Only you see it. It is not part of the screen you share. The extension reads the address and the title of the Meet tab only. It does not read the call's audio or captions, and it talks only to the PMOS on your computer.
@@ -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, see screenshots of the shared screen beside the transcript, link a note to its meeting and its initiative, and get a summary."
5
- timestamp: 2026-10-12
5
+ timestamp: 2026-10-14
6
6
  ---
7
7
 
8
8
  # Guide: notes
@@ -201,6 +201,18 @@ The replay of a new note is kept at 24 kHz, so it keeps the high sounds of a voi
201
201
 
202
202
  The replay is your microphone and the meeting added together. When both are loud at the same moment, the workspace lowers the peaks softly, so the sound does not distort. A quiet voice is not changed. The transcript is made from each source on its own, so this does not change what is written.
203
203
 
204
+ ### The meeting replay page
205
+
206
+ Press **Replay** beside the player (in Calendar › Notes, or on the meeting's card once the meeting has ended) to open the meeting replay: the whole meeting on one full-screen page, as Focus showed it, played back. Press **Stop and open the replay** in Focus to end the meeting and open it at once.
207
+
208
+ - The **topic track** is the bar to scrub. Each stretch of talk is a numbered stretch of the bar. A mark above it is a decision, a question or a next step. A mark is amber when it needs you: a next step with no owner, a question still open. A small dot below it is a note PMOS made for you during the meeting.
209
+ - **Play** or **Pause**, **Back 10 s**, and **1×**, **1.5×** or **2×** set how it plays. A press on a stretch, a mark, a key point, a topic, a screen shot or a line plays from there.
210
+ - The **stage** shows the screen shot that was on screen at the playing time. Press it to see it large. A shot that looked like another app was set aside: it is not in the replay, and **N set aside** opens them so you can keep or delete each one.
211
+ - **Key points** lists the decisions, next steps and questions with the time each was said. A key point whose line is not found has no time and no play button. **Confirm all** makes a task of each next step on the note's initiative.
212
+ - **Topics** shows the minutes each topic took and the first time it came up. **Conversation** shows the lines and PMOS's notes in time order. The line being played is marked and kept in view. Choose **All**, **Key points** or **PMOS**, or search for a word.
213
+
214
+ The page reads only records on this machine: the note, the file Focus kept for the meeting, and the screen shots.
215
+
204
216
  ### Clean up the replay sound
205
217
 
206
218
  The replay can play a cleaned copy of the sound. The copy has less rumble and hiss, and an even loudness. It needs **ffmpeg** on your machine. To install it, run `brew install ffmpeg` in a terminal.
@@ -226,6 +238,15 @@ When you share the meeting's screen, the workspace also keeps screenshots of it,
226
238
  - In the transcript, a line said while a new screenshot came up has a small picture beside it. Press it to see the screenshot.
227
239
  - When **tesseract** is on this machine, the workspace reads the text on each screenshot, on this machine, and shows it under the large picture. To install it, run `brew install tesseract` in a terminal.
228
240
 
241
+ ### Shots of the meeting only
242
+
243
+ When you press **Listen**, a sheet asks **Where is the meeting?** The choice is remembered for the next time.
244
+
245
+ - **Zoom app** or **Another app**: the browser asks twice. The first share is **Entire screen** with system audio; PMOS uses its sound only, never its picture. Then a second sheet asks you to **Pick the meeting window**; the shots come from that window only, and it stays captured while you work in other apps. Press **Sound only, no shots** to skip the second share.
246
+ - **Google Meet in Chrome**: one share of the Meet tab, with its sound, gives the sound and the shots.
247
+ - **Pause shots**: the chip next to **REC** in Focus, and the eye icon on the recording chip in the top bar, stop the pictures and keep the sound. Press it again to take shots again.
248
+ - **Set aside.** If the picture is the whole screen and tesseract is on this machine, PMOS reads each shot. A shot whose text looks like another app (Slack or a mailbox, for example) and not like the call is set aside: it is not in the note, the replay or anything the agent reads. The note shows **N set aside**. Press it to **Keep it**, **Delete** or **Delete all**. Set-aside shots are deleted on their own after 7 days. Without tesseract, every shot is kept.
249
+
229
250
  To stop taking screenshots, clear **Capture the shared screen** in the settings. The sound of the shared screen is still used. To stop sharing only the picture during a meeting, stop the share in the browser's sharing bar; the screenshots stop. To delete one note's screenshots, press **Delete this note's screenshots** in the settings, or **Delete screenshots** in the note's **More** menu. The notes, the transcript and the audio stay. Deleting a note, or marking it private, deletes its screenshots.
230
251
 
231
252
  ## Capture highlights during the meeting
@@ -0,0 +1,151 @@
1
+ 'use strict';
2
+ /* lib/meeting-material.js — the material the PM linked to a meeting (initiative meeting-material-replay, part 1).
3
+ *
4
+ * keyOf(event) → 's:<series>' or 't:<normalized title>' (calendar-memory.js seriesKey), as lib/calendar-links.js
5
+ * append(runtimeDir, body, by) → { ok, items } | { ok: false, error } op 'add' (the default) or 'remove'
6
+ * forMeeting(runtimeDir, event) → [item] the series items of the meeting and the items for this one meeting, in the order they were added
7
+ * read(runtimeDir) → Map<identity, row> the rows that hold now (a removed item is gone)
8
+ * check(body) → null | the reason the body is refused
9
+ * classifyUrl(url) → { kind: slides|gdoc|conf|link, url } | null
10
+ *
11
+ * The calendar is read-only (PMOS never edits an invite), so the material lives beside it: <runtime>/meeting-material.jsonl, append-only.
12
+ * A row: { at, by, key, series, event?, scope: 'meeting'|'series', item: { kind, ref?, title, url?, ws? }, topic?, present, removed? }.
13
+ * An item is named by its key, its event ("this meeting"; empty for the series) and its kind and ref or url. The last row of a name wins,
14
+ * and a row with removed: true takes the item off. Nothing is ever rewritten.
15
+ *
16
+ * Kinds. PMOS items name a file of the repository by ref: pmos-doc (a document), pmos-deck (a report cycle that is a slide deck), report
17
+ * (any other report cycle). Links name an address: slides, gdoc, conf, link. The server decides a link's kind from its address. */
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const calMem = require('./calendar-memory.js');
21
+
22
+ const FILE = 'meeting-material.jsonl';
23
+ const PMOS_KINDS = ['pmos-deck', 'pmos-doc', 'report'];
24
+ const LINK_KINDS = ['slides', 'gdoc', 'conf', 'link'];
25
+ const KINDS = [...PMOS_KINDS, ...LINK_KINDS];
26
+ const SCOPES = ['meeting', 'series'];
27
+ const OPS = ['add', 'remove'];
28
+ const MAX_ITEMS = 60;
29
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
30
+
31
+ const keyOf = (e) => calMem.seriesKey({ series: e && e.series ? String(e.series) : null, title: e && e.title });
32
+ const fileOf = (runtimeDir) => path.join(runtimeDir, FILE);
33
+
34
+ /** What an address is. Only https addresses are kept; the server and the page agree on the kind. */
35
+ function classifyUrl(raw) {
36
+ const s = str(raw);
37
+ if (!s || s.length > 1000 || /\s/.test(s)) return null;
38
+ let u; try { u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : 'https://' + s); } catch { return null; }
39
+ if (u.protocol !== 'https:' || !u.hostname.includes('.')) return null;
40
+ if (u.hostname === 'docs.google.com' && /^\/presentation\/d\//.test(u.pathname)) return { kind: 'slides', url: u.href };
41
+ if (u.hostname === 'docs.google.com' && /^\/document\/d\//.test(u.pathname)) return { kind: 'gdoc', url: u.href };
42
+ if (/\.atlassian\.net$/.test(u.hostname) && u.pathname.startsWith('/wiki')) return { kind: 'conf', url: u.href };
43
+ return { kind: 'link', url: u.href };
44
+ }
45
+
46
+ /** A repository-relative file path: no leading slash, no "..", no backslash, no control character. */
47
+ const refOk = (r) => typeof r === 'string' && r.length > 0 && r.length <= 300 && !r.startsWith('/') && !r.includes('\\') && !/(^|\/)\.\.(\/|$)/.test(r) && !/[\u0000-\u001f]/.test(r);
48
+
49
+ /** The name of an item inside one meeting or series. */
50
+ const idOf = (item) => item.kind + '|' + (PMOS_KINDS.includes(item.kind) ? item.ref : item.url);
51
+ const identity = (row) => [row.key, row.scope === 'series' ? '' : row.event || '', idOf(row.item)].join('\u0000');
52
+
53
+ /** The reason a POST body is refused, or null.
54
+ * body: { op?: 'add'|'remove', event: { id?, title, series? }, scope: 'meeting'|'series', item: { kind, ref | url, title?, ws? }, topic?, present? } */
55
+ function check(body) {
56
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'the body must be a JSON object';
57
+ if (body.op != null && !OPS.includes(body.op)) return 'op is one of ' + OPS.join(', ');
58
+ const e = body.event;
59
+ if (!e || typeof e !== 'object' || !str(e.title) || str(e.title).length > 300) return 'event.title is required (one line, at most 300 characters)';
60
+ if (e.series != null && (typeof e.series !== 'string' || e.series.length > 300)) return 'event.series must be text';
61
+ if (e.id != null && (typeof e.id !== 'string' || e.id.length > 120)) return 'event.id must be text';
62
+ const scope = body.scope == null ? 'meeting' : body.scope;
63
+ if (!SCOPES.includes(scope)) return 'scope is one of ' + SCOPES.join(', ');
64
+ if (scope === 'meeting' && !str(e.id)) return 'a link for one meeting needs event.id';
65
+ const it = body.item;
66
+ if (!it || typeof it !== 'object') return 'item is required';
67
+ if (PMOS_KINDS.includes(it.kind)) { if (!refOk(it.ref)) return 'a PMOS item needs ref, the path of the file in the repository'; }
68
+ else if (it.kind == null || LINK_KINDS.includes(it.kind)) { if (!classifyUrl(it.url)) return 'a link must be an https address'; }
69
+ else return 'item.kind is one of ' + KINDS.join(', ');
70
+ if (it.title != null && (typeof it.title !== 'string' || it.title.length > 300 || /[\r\n]/.test(it.title))) return 'item.title is one line of at most 300 characters';
71
+ if (it.ws != null && (typeof it.ws !== 'string' || it.ws.length > 120)) return 'item.ws must be text';
72
+ if (body.topic != null && (typeof body.topic !== 'string' || body.topic.length > 160 || /[\r\n]/.test(body.topic))) return 'topic is one line of at most 160 characters';
73
+ if (body.present != null && typeof body.present !== 'boolean') return 'present must be true or false';
74
+ return null;
75
+ }
76
+
77
+ /** The item as it is kept: the kind a link has, its address in full, a title. */
78
+ function cleanItem(it) {
79
+ if (PMOS_KINDS.includes(it.kind)) return { kind: it.kind, ref: it.ref, title: str(it.title) || it.ref, ...(str(it.ws) ? { ws: str(it.ws) } : {}) };
80
+ const c = classifyUrl(it.url);
81
+ return { kind: c.kind, url: c.url, title: str(it.title) || c.url };
82
+ }
83
+
84
+ function lines(runtimeDir) {
85
+ if (!runtimeDir) return [];
86
+ let text = '';
87
+ try { text = fs.readFileSync(fileOf(runtimeDir), 'utf8'); } catch { return []; }
88
+ const out = [];
89
+ for (const l of text.split('\n')) {
90
+ if (!l.trim()) continue;
91
+ let x; try { x = JSON.parse(l); } catch { continue; }
92
+ if (!x || typeof x.key !== 'string' || !SCOPES.includes(x.scope) || !x.item || !KINDS.includes(x.item.kind)) continue;
93
+ if (PMOS_KINDS.includes(x.item.kind) ? !refOk(x.item.ref) : !x.item.url) continue;
94
+ out.push(x);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** The rows that hold now, in the order their items were first added (a changed item keeps its place). */
100
+ function read(runtimeDir) {
101
+ const out = new Map();
102
+ for (const row of lines(runtimeDir)) {
103
+ const id = identity(row);
104
+ if (row.removed) { out.delete(id); continue; }
105
+ out.set(id, out.has(id) ? { ...row, firstAt: out.get(id).firstAt } : { ...row, firstAt: row.at || null });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ const view = (row) => ({
111
+ id: crypto16(identity(row)), kind: row.item.kind, ref: row.item.ref || null, url: row.item.url || null, title: row.item.title || row.item.ref || row.item.url,
112
+ ws: row.item.ws || null, topic: row.topic || null, present: row.present === true, scope: row.scope, from: 'you', by: row.by || null, at: row.at || null,
113
+ });
114
+ /** A short stable id for a row, so the page can name an item without sending its path back and forth. */
115
+ function crypto16(s) { return require('crypto').createHash('sha1').update(s).digest('hex').slice(0, 12); }
116
+
117
+ /** The material of one meeting: its series items and its own, in the order they were added. event: { id?, title, series? } */
118
+ function forMeeting(runtimeDir, event) {
119
+ if (!event || !str(event.title)) return [];
120
+ const key = keyOf(event);
121
+ const id = str(event.id);
122
+ const mine = [];
123
+ for (const row of read(runtimeDir).values()) {
124
+ if (row.key !== key) continue;
125
+ if (row.scope === 'meeting' && !(id && row.event === id)) continue;
126
+ mine.push(row);
127
+ }
128
+ return mine.sort((a, b) => String(a.firstAt).localeCompare(String(b.firstAt))).map(view);
129
+ }
130
+
131
+ function append(runtimeDir, body, by) {
132
+ const bad = check(body);
133
+ if (bad) return { ok: false, error: bad };
134
+ if (!runtimeDir) return { ok: false, error: 'this server has no runtime folder, so the material cannot be saved' };
135
+ const op = body.op || 'add';
136
+ const scope = body.scope || 'meeting';
137
+ const item = cleanItem(body.item);
138
+ const line = { at: new Date().toISOString(), by: by || null, key: keyOf(body.event), series: body.event.series ? String(body.event.series) : null, event_title: str(body.event.title), scope, item };
139
+ if (scope === 'meeting') line.event = str(body.event.id);
140
+ if (op === 'remove') line.removed = true;
141
+ else {
142
+ if (str(body.topic)) line.topic = str(body.topic);
143
+ line.present = body.present === true && ['pmos-deck', 'slides', 'report'].includes(item.kind);
144
+ if (forMeeting(runtimeDir, body.event).length >= MAX_ITEMS && !read(runtimeDir).has(identity(line))) return { ok: false, error: 'a meeting holds at most ' + MAX_ITEMS + ' items' };
145
+ }
146
+ fs.mkdirSync(runtimeDir, { recursive: true });
147
+ fs.appendFileSync(fileOf(runtimeDir), JSON.stringify(line) + '\n');
148
+ return { ok: true, items: forMeeting(runtimeDir, body.event) };
149
+ }
150
+
151
+ module.exports = { keyOf, read, forMeeting, append, check, classifyUrl, KINDS, PMOS_KINDS, LINK_KINDS, FILE };
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+ /* meeting-replay.js — the pure join behind the meeting replay page (initiative meeting-material-replay, part 2).
3
+ *
4
+ * build({ note, event?, stateLines?, frames?, audio?, topics?, now? }) → the replay
5
+ *
6
+ * The replay is Focus played back, so it joins three records PMOS already keeps and invents nothing:
7
+ * the note transcript lines (with who said them), decisions, next steps and questions, the audio's length
8
+ * the Focus file <runtime>/focus/<note or event id>.jsonl, folded by lib/meeting-focus.js: topics, the stretches of talk, PMOS's notes
9
+ * the shots lib/notes-screen.js frames; a frame with `aside` truthy was set aside (it looked like another app) and is NEVER in the replay
10
+ *
11
+ * Every time is MEETING time in ms: 0 is where the recording starts, the same clock the audio, the transcript and the shots use
12
+ * (the audio's currentTime * 1000). `start_ms` is the wall clock (epoch ms) of meeting time 0, so the page can show 11:18.
13
+ *
14
+ * A KEY POINT'S TIME is the time of the transcript line it was caught from: the item's own `t` (the final notes and the minute
15
+ * bullets keep it), else the line its quote (or, failing that, its text) is found in. When none is known `t` is null and the page
16
+ * shows no play button. Nothing here reads a file or starts a process; lib/routes-meeting-replay.js reads and calls build().
17
+ *
18
+ * Output:
19
+ * { ok, note: { id, title, state, initiative, ws }, meeting: { title, at, end, attendees },
20
+ * audio: { url, ms } | null, start_ms, length_ms,
21
+ * lines: [{ i, t, who, text, source, key: { k, id } | null }] by t
22
+ * keys: [{ id, k: 'decision'|'action'|'question', text, t, owner, due, by, state: 'draft'|'owner'|'open'|'answered', warn }]
23
+ * topics: [{ id, text, src, state, minutes, n, first_t, stretches, parked, by }] visited ones first, in the order they came up
24
+ * runs: [{ topic, n, from_ms, to_ms }]
25
+ * notes: [{ id, t, cat, body, heard, why, src }] PMOS's notes with the time they were made
26
+ * shots: [{ t, w, h, why?, text? }] kept shots only
27
+ * aside_count }
28
+ */
29
+ const N = require('./notes.js');
30
+ const F = require('./meeting-focus.js');
31
+
32
+ const SHOT_TEXT_MAX = 400;
33
+ const NEAR_LINE_MS = 3000; // a key point whose time is not exactly a line's belongs to the line this close
34
+ const ANSWER_OVERLAP = 0.6;
35
+
36
+ const str = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) : '');
37
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
38
+ const words = (t) => String(t == null ? '' : t).toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').match(/[\p{L}\p{N}]+/gu) || [];
39
+ const share = (a, b) => {
40
+ const wa = words(a); const hb = new Set(words(b));
41
+ return wa.length ? wa.filter((w) => hb.has(w)).length / wa.length : 0;
42
+ };
43
+
44
+ /** The wall clock (epoch ms) of meeting time 0: a transcript line's `at` minus its `t`, else when recording began, else the meeting's start. */
45
+ function startOf(note, event) {
46
+ for (const x of (note && note.transcript) || []) {
47
+ const at = Date.parse(x && x.at);
48
+ if (x && finite(x.t) && Number.isFinite(at)) return at - x.t;
49
+ }
50
+ const rec = Date.parse(note && note.recorded_from);
51
+ if (Number.isFinite(rec)) return rec;
52
+ const ev = Date.parse(event && event.at);
53
+ return Number.isFinite(ev) ? ev : null;
54
+ }
55
+
56
+ /** The lines of the talk with a time: the final pass's segments when there are some, else the live lines; by t. */
57
+ function linesOf(note) {
58
+ let raw = [];
59
+ try { raw = N.transcriptLines(note); } catch { raw = []; }
60
+ return raw.filter((x) => x && finite(x.t) && typeof x.text === 'string' && x.text.trim())
61
+ .map((x) => ({ t: Math.round(x.t), who: str(x.who, 60), text: str(x.text, 600), source: x.source || 'speech' }))
62
+ .sort((a, b) => a.t - b.t)
63
+ .map((x, i) => ({ i, ...x, key: null }));
64
+ }
65
+
66
+ /** The decisions, next steps and questions of the note, as the Notes page shows them: the final notes when written, else the minute
67
+ * bullets, else the local summary. → { decision: [item], action: [item], question: [item] }, item = { text, quote?, t?, owner?, due?, by? } */
68
+ function caughtOf(note) {
69
+ const out = { decision: [], action: [], question: [] };
70
+ const f = note && note.final_notes;
71
+ const fromList = (kind, list) => {
72
+ for (const x of Array.isArray(list) ? list : []) {
73
+ const text = str(x && x.text, 300);
74
+ if (text) out[kind].push({ text, quote: str(x.quote, 400), t: finite(x.t) ? x.t : null, owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: str(x.by, 120) || null });
75
+ }
76
+ };
77
+ if (f && ((f.decisions || []).length || (f.actions || []).length || (f.questions || []).length)) {
78
+ fromList('decision', f.decisions); fromList('action', f.actions); fromList('question', f.questions);
79
+ return out;
80
+ }
81
+ const bl = ((note && note.bullets) || []).filter((b) => b && b.text && !b.dropped && !b.open);
82
+ if (bl.length) {
83
+ for (const b of bl) if (out[b.cat]) out[b.cat].push({ text: str(b.text, 300), quote: '', t: finite(b.t) ? b.t : null, owner: str(b.owner, 60) || null, due: str(b.due, 40) || null, by: null });
84
+ if (out.decision.length || out.action.length || out.question.length) return out;
85
+ }
86
+ const sec = (note && note.summary && note.summary.sections) || {};
87
+ const at = (x) => { const m = /^\s*\[?(\d+:\d{2}(?::\d{2})?)\]?\s*$/.exec(String((x && x.at) || '')); return m ? N.parseTime(m[1]) : null; };
88
+ const fromSec = (kind, list) => { for (const x of Array.isArray(list) ? list : []) { const text = str(x && x.text, 300); if (text) out[kind].push({ text, quote: '', t: at(x), owner: str(x.owner, 60) || null, due: str(x.due, 40) || null, by: null }); } };
89
+ fromSec('decision', sec.decisions); fromSec('action', sec.actions); fromSec('question', sec.questions);
90
+ return out;
91
+ }
92
+
93
+ /** The time of the line a key point was caught from: its own t, else the line its quote (or its text) is in; null when unknown. */
94
+ function timeOfKey(item, lines) {
95
+ if (finite(item.t)) return Math.max(0, Math.round(item.t));
96
+ const timed = lines.map((l) => ({ t: l.t, text: l.text }));
97
+ for (const probe of [item.quote, item.text]) {
98
+ if (!probe) continue;
99
+ let at = null;
100
+ try { at = N.groundQuote(timed, probe, null); } catch { at = null; }
101
+ if (finite(at)) return Math.max(0, Math.round(at));
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** Join the three records. */
107
+ function build({ note, event = null, stateLines = [], frames = [], audio = null, topics = [], now = Date.now() } = {}) {
108
+ const n = note || {};
109
+ const lines = linesOf(n);
110
+ const start = startOf(n, event);
111
+ const shots = (frames || []).filter((f) => f && finite(f.t) && !f.aside).sort((a, b) => a.t - b.t)
112
+ .map((f) => ({ t: f.t, w: f.w || 0, h: f.h || 0, ...(f.why ? { why: f.why } : {}), ...(f.text ? { text: str(f.text, SHOT_TEXT_MAX) } : {}) }));
113
+ const asideCount = (frames || []).filter((f) => f && f.aside).length;
114
+
115
+ // Focus's state: topics, stretches, PMOS's notes. Stretches are measured from meeting time 0.
116
+ const fold = F.foldState(stateLines || [], { topics, startMs: start, nowMs: start == null ? now : start + 0 });
117
+ const pmos = start == null ? [] : fold.notes.filter((x) => x && x.body && finite(x.at)).map((x) => ({ id: x.id, t: Math.max(0, Math.round(x.at - start)), cat: str(x.cat, 30), body: str(x.body, 700), heard: str(x.heard, 300), why: str(x.why, 300), src: str(x.src, 80) }))
118
+ .sort((a, b) => a.t - b.t);
119
+
120
+ // length: the audio, the clock of the note, else the last thing that has a time
121
+ let heard = 0;
122
+ if (n.ended_at || n.paused_at) { try { heard = N.heardMs(n, now) || 0; } catch { heard = 0; } } // a note still recording has no end to trust
123
+ const caught = caughtOf(n);
124
+ const keys = [];
125
+ for (const [kind, list] of [['decision', caught.decision], ['action', caught.action], ['question', caught.question]]) {
126
+ list.forEach((it, i) => {
127
+ const t = timeOfKey(it, lines);
128
+ const answered = kind === 'question' && t != null && pmos.some((p) => p.cat === 'Answer' && p.t >= t && share(it.text, p.heard || p.body) >= ANSWER_OVERLAP);
129
+ const state = kind === 'action' ? (it.owner ? 'draft' : 'owner') : kind === 'question' ? (answered ? 'answered' : 'open') : 'draft';
130
+ keys.push({ id: kind[0] + (i + 1), k: kind, text: it.text, t, owner: it.owner, due: it.due, by: it.by, state, warn: state === 'owner' || state === 'open' });
131
+ });
132
+ }
133
+ keys.sort((a, b) => (a.t == null) - (b.t == null) || (a.t || 0) - (b.t || 0));
134
+ // the line a key point was caught from carries its mark
135
+ for (const k of keys) {
136
+ if (k.t == null || !lines.length) continue;
137
+ let best = null;
138
+ for (const l of lines) if (!l.key && (best === null || Math.abs(l.t - k.t) < Math.abs(best.t - k.t))) best = l;
139
+ if (best && Math.abs(best.t - k.t) <= NEAR_LINE_MS) best.key = { k: k.k, id: k.id };
140
+ }
141
+
142
+ const lastT = (xs) => xs.reduce((m, x) => (finite(x.t) && x.t > m ? x.t : m), 0);
143
+ const length = Math.max(finite(audio && audio.ms) ? audio.ms : 0, heard, lastT(lines), lastT(shots), lastT(keys), lastT(pmos));
144
+
145
+ // stretches, in ms; a stretch still open runs to the end
146
+ const runs = fold.runs.map((r) => ({ topic: r.topic, from_ms: Math.round(r.from_min * 60000), to_ms: r.to_min == null ? length : Math.round(r.to_min * 60000) }))
147
+ .filter((r) => r.to_ms > r.from_ms || r.to_ms === length);
148
+ const firstOf = new Map();
149
+ for (const r of runs) if (!firstOf.has(r.topic)) firstOf.set(r.topic, r.from_ms);
150
+ const visited = fold.topics.filter((t) => firstOf.has(t.id)).sort((a, b) => firstOf.get(a.id) - firstOf.get(b.id));
151
+ const numOf = new Map(visited.map((t, i) => [t.id, i + 1]));
152
+ const stretches = (id) => runs.filter((r) => r.topic === id).length;
153
+ const minutesOf = (id) => Math.round(runs.filter((r) => r.topic === id).reduce((s, r) => s + (r.to_ms - r.from_ms), 0) / 60000);
154
+ const topicRows = [...visited, ...fold.topics.filter((t) => !firstOf.has(t.id))].map((t) => ({
155
+ id: t.id, text: t.text, src: t.src, state: t.state, minutes: minutesOf(t.id), n: numOf.get(t.id) || null, first_t: firstOf.has(t.id) ? firstOf.get(t.id) : null,
156
+ stretches: stretches(t.id), parked: t.state === 'parked', by: t.by || null }));
157
+ const ev = event || {};
158
+ const m = n.meeting || {};
159
+ return {
160
+ ok: true,
161
+ note: { id: n.id, title: n.title || m.title || ev.title || 'Untitled note', state: n.state || null, initiative: n.initiative || null, ws: n.ws || (n.initiative && n.initiative.ws) || null },
162
+ meeting: { title: ev.title || m.title || n.title || 'Untitled note', at: ev.at || m.start || n.recorded_from || n.created_at || null, end: ev.end || m.end || null,
163
+ attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String) },
164
+ audio: audio && audio.url ? { url: audio.url, ms: finite(audio.ms) ? audio.ms : null } : null,
165
+ start_ms: start, length_ms: length,
166
+ lines, keys, topics: topicRows, runs: runs.map((r) => ({ ...r, n: numOf.get(r.topic) || null })), notes: pmos, shots, aside_count: asideCount,
167
+ };
168
+ }
169
+
170
+ module.exports = { build, caughtOf, timeOfKey, linesOf, startOf };