create-pmos 0.64.0 → 0.65.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.64.0",
3
+ "version": "0.65.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@2196dd0
2
- assembled: 2026-10-06
1
+ kernel-source: pmos@bcf4a86
2
+ assembled: 2026-10-07
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-06T23:35:55Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
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-06T23:35:55Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
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-06T23:35:55Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-06
5
+ ## 2026-10-07
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, and get a summary."
4
+ description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -28,9 +28,9 @@ The meeting can only be heard when local transcription is set up. Until then, **
28
28
 
29
29
  ## What is caught
30
30
 
31
- Each line gets a label: decision, action, question, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, statements with a number go under **Facts mentioned**.
31
+ Each line gets a label: decision, action, question, fact, topic, statement or small talk. Decisions, actions and questions go into their sections as they are said. After **Stop**, facts and statements with a number go under **Facts mentioned**.
32
32
 
33
- - To remove an item that does not belong, press the **x** beside it.
33
+ - To remove an item that does not belong, press the **x** beside it. To take a line out of the summary, press the **x** beside it in the summary.
34
34
  - When a caught line is about something in the repository, the closest file shows beside it: **see** the file, a check mark when Jev finds that the file says the same, or **≠** the file when Jev finds that the line says something different. Point at the mark to see the file and the line.
35
35
 
36
36
  ## Set up local transcription
@@ -57,6 +57,35 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
57
57
 
58
58
  With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
59
59
 
60
+ ## Correct and tag a line
61
+
62
+ 1. In the transcript, press the **…** button on a line.
63
+ 2. Change what you need:
64
+ - **Words**: correct what was heard. The words that were heard are kept, the line shows **edited**, and **Use what was heard** puts them back.
65
+ - **Label**: decision, action, question, fact, topic, statement or small talk.
66
+ - **Summary**: **As picked**, **Always in the summary** or **Never in the summary**.
67
+ - **Tags**: words such as `#pricing #launch`.
68
+ - **People**: names, separated by commas. Press a name below the field to add a person from this repository.
69
+ 3. Press **Save**. The tags and people show on the line, on its item in your notes and under **Tagged**. **Copy as Markdown** includes them.
70
+
71
+ ## See what changed in your notes
72
+
73
+ Each change to your notes keeps the text from before it. Changes within two minutes count as one. The first text you typed is always kept.
74
+
75
+ 1. When your notes have changed, the header shows **Edited**. Press it.
76
+ 2. **History** shows each version, newest first: the lines that were added and removed, and the original at the end.
77
+ 3. To go back, press **Use this version**. The text before it stays in **History**.
78
+
79
+ ## Replay the meeting
80
+
81
+ When a meeting was heard with local transcription, its audio is kept on this machine.
82
+
83
+ 1. Open the note. **Replay** is above your notes. Press play.
84
+ 2. While it plays, the transcript line, its item and the line of your notes that you wrote at that moment are highlighted. Your notes cannot be edited while it plays: pause to edit them.
85
+ 3. Press a time in the transcript to play from there.
86
+
87
+ To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
88
+
60
89
  ## Dictate into your notes
61
90
 
62
91
  1. Put the cursor where the text must go, then press **Dictate** and speak.
@@ -66,13 +95,13 @@ With the switches on, each line goes to TypeSafe for its label. For a caught lin
66
95
  ## Copy, private and delete
67
96
 
68
97
  - **Copy as Markdown** puts the title, the summary, your notes, the caught items and the transcript on the clipboard.
69
- - In the settings, **Mark private** stops listening at once. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
98
+ - In the settings, **Mark private** stops listening at once and deletes the note's audio. A private note is not recorded again, and nothing from it goes to Jev or to an agent. **Mark not private** undoes it.
70
99
  - **Summary by agent** starts an agent session in the selected repository that writes a summary into the note. Lines that look like a secret are removed first.
71
- - **Delete** removes the note, its transcript and its summaries from this machine after you confirm. You cannot undo it.
100
+ - **Delete** removes the note, its transcript, its summaries and its audio from this machine after you confirm. You cannot undo it.
72
101
 
73
102
  ## What stays on your machine
74
103
 
75
- - Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes the file at once. The browser's own recognition is used only on the device, never in the cloud.
104
+ - Audio never leaves this machine. The page sends it to the workspace on this machine, which gives it to whisper.cpp in a temporary file and deletes that file at once. For replay, the workspace keeps one recording of each note in `notes/recordings/` in the state folder, until you delete the audio, the note, or mark the note private. The browser's own recognition is used only on the device, never in the cloud.
76
105
  - Notes are files in the workspace's own folder on this machine (`notes/` in the state folder). They are never written into a repository.
77
106
  - Only Jev, with the switches on, and **Summary by agent**, when you press it, receive any text from a note.
78
107
 
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+ /* notes-audio.js — the meeting audio a note keeps for replay (meeting-notes Run 3, D92). Local only.
3
+ *
4
+ * write(runtimeDir, { id, source, pcm, t }) add one chunk of 16 kHz mono 16-bit samples at meeting time t (ms)
5
+ * info(runtimeDir, id) → { bytes, ms } | null
6
+ * remove(runtimeDir, id) delete the note's audio
7
+ * serve(req, res, runtimeDir, id) the audio as a WAV file, with byte ranges so the page can seek
8
+ * getKeep(runtimeDir) · setKeep(runtimeDir, bool) "Keep the audio for replay", on by default, for this machine
9
+ *
10
+ * WHAT IT KEEPS. One file per note, `<runtime>/notes/recordings/<id>.pcm`: raw samples with no header, sample k at
11
+ * k / 16000 seconds after the note's first Listen. The microphone and the meeting are added into the same samples, so the
12
+ * file is the meeting as the PM heard it. A chunk is placed straight after the previous chunk of its source when it
13
+ * arrives on time or early, so the small jitter of the page's timer leaves no clicks; a chunk that arrives much later (Listen again)
14
+ * is placed at its own time, and the gap stays silent.
15
+ *
16
+ * WHAT IT NEVER DOES. It sends nothing and opens no network module. The routes (lib/routes-notes.js) write nothing for a
17
+ * private note, delete the file when a note is made private or deleted, and write nothing when the PM switches keeping off.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ const RATE = 16000;
23
+ const BYTES_PER_MS = (RATE * 2) / 1000;
24
+ const ON_TIME_MS = 1500; // a chunk this close to where its source left off is placed straight after it
25
+ const MAX_BYTES = RATE * 2 * 60 * 60 * 6; // six hours; a longer meeting keeps its first six hours
26
+ const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
27
+
28
+ const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
29
+ const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
30
+ const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
31
+ const next = new Map(); // `${file}|${source}` → the byte offset where that source's last chunk ended
32
+
33
+ function getKeep(runtimeDir) {
34
+ try { const s = JSON.parse(fs.readFileSync(settingsFile(runtimeDir), 'utf8')); return s.keep !== false; } catch { return true; }
35
+ }
36
+ function setKeep(runtimeDir, keep) {
37
+ if (typeof keep !== 'boolean') return { ok: false, error: 'say keep: true or keep: false' };
38
+ const f = settingsFile(runtimeDir);
39
+ fs.mkdirSync(path.dirname(f), { recursive: true });
40
+ const tmp = f + '.' + process.pid + '.tmp';
41
+ fs.writeFileSync(tmp, JSON.stringify({ keep }), { mode: 0o600 });
42
+ fs.renameSync(tmp, f);
43
+ return { ok: true, keep };
44
+ }
45
+
46
+ /** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, and clipped. */
47
+ function write(runtimeDir, { id, source, pcm, t }) {
48
+ if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
49
+ const f = fileOf(runtimeDir, id);
50
+ const key = f + '|' + source;
51
+ let at = Math.max(0, Math.round((Number(t) || 0) * BYTES_PER_MS / 2) * 2);
52
+ const prev = next.get(key);
53
+ // a source never overlaps itself: a chunk that would start before its source left off, or just after, goes straight after it
54
+ if (prev !== undefined && at <= prev + ON_TIME_MS * BYTES_PER_MS) at = prev;
55
+ if (at >= MAX_BYTES) return { ok: true, kept: false };
56
+ const len = Math.min(pcm.length, MAX_BYTES - at);
57
+ fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
58
+ const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
59
+ try {
60
+ const old = Buffer.alloc(len);
61
+ fs.readSync(fd, old, 0, len, at);
62
+ const out = Buffer.alloc(len);
63
+ for (let i = 0; i < len; i += 2) out.writeInt16LE(Math.max(-32768, Math.min(32767, old.readInt16LE(i) + pcm.readInt16LE(i))), i);
64
+ fs.writeSync(fd, out, 0, len, at);
65
+ } finally { fs.closeSync(fd); }
66
+ next.set(key, at + len);
67
+ return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
68
+ }
69
+
70
+ function info(runtimeDir, id) {
71
+ if (!ID_RE.test(String(id || ''))) return null;
72
+ try { const st = fs.statSync(fileOf(runtimeDir, id)); return st.size ? { bytes: st.size, ms: Math.round(st.size / BYTES_PER_MS) } : null; } catch { return null; }
73
+ }
74
+ function remove(runtimeDir, id) {
75
+ if (!ID_RE.test(String(id || ''))) return false;
76
+ for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
77
+ try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
78
+ }
79
+
80
+ function wavHeader(dataBytes) {
81
+ const h = Buffer.alloc(44);
82
+ h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
83
+ h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
84
+ h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
85
+ h.write('data', 36); h.writeUInt32LE(dataBytes, 40);
86
+ return h;
87
+ }
88
+ /** The audio as audio/wav. A `Range: bytes=a-b` request gets 206 with that part, which is how the page seeks. */
89
+ function serve(req, res, runtimeDir, id) {
90
+ const inf = info(runtimeDir, id);
91
+ if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
92
+ const header = wavHeader(inf.bytes);
93
+ const total = header.length + inf.bytes;
94
+ let start = 0; let end = total - 1; let partial = false;
95
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || '').trim());
96
+ if (m && (m[1] || m[2])) {
97
+ if (m[1]) { start = Number(m[1]); if (m[2]) end = Math.min(total - 1, Number(m[2])); }
98
+ else { start = Math.max(0, total - Number(m[2])); }
99
+ if (start > end || start >= total) { res.writeHead(416, { 'Content-Range': 'bytes */' + total }); res.end(); return; }
100
+ partial = true;
101
+ }
102
+ res.writeHead(partial ? 206 : 200, { 'Content-Type': 'audio/wav', 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, 'Cache-Control': 'no-store',
103
+ ...(partial ? { 'Content-Range': 'bytes ' + start + '-' + end + '/' + total } : {}) });
104
+ if (req.method === 'HEAD') { res.end(); return; }
105
+ if (start < header.length) res.write(header.subarray(start, Math.min(header.length, end + 1)));
106
+ const from = Math.max(0, start - header.length); const to = end - header.length;
107
+ if (to < from) { res.end(); return; }
108
+ fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
109
+ }
110
+
111
+ module.exports = { write, info, remove, serve, getKeep, setKeep, wavHeader, RATE, BYTES_PER_MS, MAX_BYTES };
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
- /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 and 2).
2
+ /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 3).
3
3
  *
4
4
  * list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
5
5
  * record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t }) · setPrivate(dir, { id, private })
6
6
  * categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
7
7
  * updateSegment(dir, { id, index, text, patch }) · setLabel(dir, { id, index, cat }) · renameSpeaker(dir, { id, source, name })
8
+ * editLine(dir, { id, index, cat?, summary?, tags?, people?, text? }) the PM's corrections to one line (Run 3, D92)
9
+ * versionsOf(note) → [{ at, title, body, original }] every kept version of the notes, oldest first (Run 3, D92)
8
10
  * dictation(text) → { ops } the cleaned phrase as insert operations, spoken commands resolved
9
11
  * cleanSentences(text) → [sentence] a recognised phrase, cleaned and split into sentences
10
12
  * summarize(note) → { markdown, sections, counts } the local summary: no model, the same for the same input
@@ -15,6 +17,13 @@
15
17
  * never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
16
18
  * the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
17
19
  *
20
+ * CORRECTIONS AND VERSIONS (Run 3, D92). The PM can correct a line: its label (the six, or fact), whether it is in the
21
+ * summary, its tags, the people it names and its text. A corrected text keeps the first text the transcript recorded as
22
+ * `orig_text`, and its repository check is dropped because it was about the old words. Every save of the notes keeps the
23
+ * state before it in `versions`; a save within VERSION_FOLD_MS of the last kept version keeps nothing new, so a burst of
24
+ * typing is one version, and the first version is never replaced. While a note has been recorded, each new line of the
25
+ * notes gets the meeting time it was first saved at (`line_times`), so a replay can show what was typed when.
26
+ *
18
27
  * SPEECH. This file never sees audio. Words reach it from local Whisper (lib/notes-whisper.js, D91) or from the browser's
19
28
  * on-device recognition (processLocally, no cloud fallback, D85). A transcript line carries its source: `mic` and
20
29
  * `meeting` from Whisper, `speech` from the browser. The note names each source ("You", "Meeting"); the PM can rename it.
@@ -43,6 +52,10 @@ const MAX_BODY = 400 * 1024;
43
52
  const MAX_PHRASE = 4000;
44
53
  const MAX_SEGMENTS = 20000;
45
54
  const MAX_AGENT_SUMMARY = 64 * 1024;
55
+ const VERSION_FOLD_MS = 2 * 60 * 1000;
56
+ const MAX_VERSIONS = 200;
57
+ const MAX_TAGS = 12;
58
+ const MAX_LINE_TIMES = 2000;
46
59
  const notesDir = (dir) => path.join(dir, 'notes');
47
60
  const handoffDir = (dir) => path.join(notesDir(dir), 'handoff');
48
61
  const fileOf = (dir, id) => path.join(notesDir(dir), id + '.json');
@@ -97,15 +110,51 @@ function read(dir, id) {
97
110
  function save(dir, { id, title, body, now = Date.now() } = {}) {
98
111
  const note = load(dir, id);
99
112
  if (!note) return err(404, 'no such note');
100
- if (title !== undefined) note.title = cleanTitle(title);
113
+ const nextTitle = title !== undefined ? cleanTitle(title) : note.title;
114
+ let nextBody = note.body;
101
115
  if (body !== undefined) {
102
- const b = String(body == null ? '' : body);
103
- if (Buffer.byteLength(b) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
104
- note.body = b;
116
+ nextBody = String(body == null ? '' : body);
117
+ if (Buffer.byteLength(nextBody) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
118
+ }
119
+ if (nextTitle !== note.title || nextBody !== (note.body || '')) {
120
+ keepVersion(note, now);
121
+ if (nextBody !== (note.body || '')) stampLines(note, nextBody, now);
105
122
  }
123
+ note.title = nextTitle; note.body = nextBody;
106
124
  note.updated_at = iso(now);
107
125
  store(dir, note);
108
- return { ok: true, updated_at: note.updated_at };
126
+ return { ok: true, updated_at: note.updated_at, versions: (note.versions || []).length };
127
+ }
128
+ /** Keep the note's text before this save, unless the last kept version is younger than VERSION_FOLD_MS. Notes with no
129
+ * text yet are not a version. The first version is never dropped, so the original stays. */
130
+ function keepVersion(note, now) {
131
+ if (!String(note.body || '').trim()) return;
132
+ const vs = note.versions || (note.versions = []);
133
+ const last = vs[vs.length - 1];
134
+ if (last && now - Date.parse(last.kept_at) < VERSION_FOLD_MS) return;
135
+ vs.push({ at: note.body_at || note.updated_at, kept_at: iso(now), title: note.title || '', body: note.body || '' });
136
+ if (vs.length > MAX_VERSIONS) vs.splice(1, 1);
137
+ note.body_at = iso(now);
138
+ }
139
+ /** Each line of the notes that is new in this save gets the meeting time it appeared at. */
140
+ function stampLines(note, body, now) {
141
+ if (!note.recorded_from) return;
142
+ const t = now - Date.parse(note.recorded_from);
143
+ if (!(t >= 0)) return;
144
+ const prev = note.line_times || {};
145
+ const next = {};
146
+ for (const raw of String(body).split(/\r?\n/)) {
147
+ const l = raw.trim();
148
+ if (!l || next[l] !== undefined) continue;
149
+ next[l] = prev[l] !== undefined ? prev[l] : Math.round(t);
150
+ if (Object.keys(next).length >= MAX_LINE_TIMES) break;
151
+ }
152
+ note.line_times = next;
153
+ }
154
+ /** Every kept version, oldest first, then the current text. The first is the original. */
155
+ function versionsOf(note) {
156
+ const vs = (note.versions || []).map((v, i) => ({ at: v.at, title: v.title, body: v.body, original: i === 0 }));
157
+ return vs;
109
158
  }
110
159
  function remove(dir, id) {
111
160
  if (!load(dir, id)) return err(404, 'no such note');
@@ -176,11 +225,47 @@ function setLabel(dir, { id, index, cat, drop = false, now = Date.now() } = {})
176
225
  if (!note) return err(404, 'no such note');
177
226
  const seg = (note.transcript || [])[index];
178
227
  if (!seg) return err(404, 'no such line');
179
- if (!CATS.includes(cat)) return err(400, 'the label must be one of: ' + CATS.join(', '));
228
+ if (!PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
180
229
  seg.cat = cat; seg.cat_by = 'pm'; if (drop) seg.dropped = true; else delete seg.dropped; note.updated_at = iso(now);
181
230
  store(dir, note);
182
231
  return { ok: true, segment: seg };
183
232
  }
233
+ const cleanTag = (t) => String(t == null ? '' : t).replace(/^[#\s]+/, '').replace(/\s+/g, '-').replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40);
234
+ const cleanPerson = (p) => String(p == null ? '' : p).replace(/^[@\s]+/, '').replace(/\s+/g, ' ').trim().slice(0, 60);
235
+ const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = x.toLowerCase(); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
236
+ /** The PM's corrections to one line. Each field is optional; what is not given is kept. */
237
+ function editLine(dir, { id, index, cat, summary, tags, people, text, now = Date.now() } = {}) {
238
+ const note = load(dir, id);
239
+ if (!note) return err(404, 'no such note');
240
+ const seg = (note.transcript || [])[index];
241
+ if (!seg) return err(404, 'no such line');
242
+ if (cat !== undefined && !PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
243
+ if (summary !== undefined && summary !== null && typeof summary !== 'boolean') return err(400, 'summary must be true, false or null');
244
+ if (tags !== undefined && !Array.isArray(tags)) return err(400, 'tags must be a list');
245
+ if (people !== undefined && !Array.isArray(people)) return err(400, 'people must be a list');
246
+ let resummarize = false;
247
+ if (text !== undefined) {
248
+ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
249
+ if (!t) return err(400, 'a line cannot be empty: remove it from its section instead');
250
+ if (t.length > MAX_PHRASE) return err(413, 'that line is too long');
251
+ if (t !== seg.text) {
252
+ if (seg.orig_text === undefined) seg.orig_text = seg.text;
253
+ if (t === seg.orig_text) { delete seg.orig_text; delete seg.edited_at; } else seg.edited_at = iso(now);
254
+ seg.text = t; delete seg.check; resummarize = true;
255
+ }
256
+ }
257
+ if (cat !== undefined) { seg.cat = cat; seg.cat_by = 'pm'; delete seg.dropped; resummarize = true; }
258
+ if (summary !== undefined) { if (summary === null) delete seg.in_summary; else seg.in_summary = summary; resummarize = true; }
259
+ if (tags !== undefined) { const v = uniq(tags.map(cleanTag)).slice(0, MAX_TAGS); if (v.length) seg.tags = v; else delete seg.tags; }
260
+ if (people !== undefined) { const v = uniq(people.map(cleanPerson)).slice(0, MAX_TAGS); if (v.length) seg.people = v; else delete seg.people; }
261
+ if (resummarize && note.summary && note.summary.source === 'local') {
262
+ const sm = summarize(note);
263
+ note.summary = { source: 'local', at: iso(now), markdown: sm.markdown, sections: sm.sections };
264
+ }
265
+ note.updated_at = iso(now);
266
+ store(dir, note);
267
+ return { ok: true, segment: seg, summary: note.summary || null };
268
+ }
184
269
  function renameSpeaker(dir, { id, source, name, now = Date.now() } = {}) {
185
270
  const note = load(dir, id);
186
271
  if (!note) return err(404, 'no such note');
@@ -325,6 +410,8 @@ function isQuestion(s) {
325
410
  }
326
411
  /* ── the local label (Run 2) ───────────────────────────────────────────────────────────────────── */
327
412
  const CATS = ['decision', 'action', 'question', 'topic', 'statement', 'small talk'];
413
+ // the labels the PM may set: the six a rule or Jev may give, and fact, which only the PM gives
414
+ const PM_CATS = ['decision', 'action', 'question', 'fact', 'topic', 'statement', 'small talk'];
328
415
  const SMALL_TALK = /^(?:(?:ok(?:ay)?|so|and|well|um|uh)[, ]+)*(?:hi|hello|hey|morning|good (?:morning|afternoon|evening)|thanks?(?: you)?|thank you|bye|see you|cheers|can you (?:hear|see) me|you(?:'re| are) (?:on )?mute|one sec(?:ond)?|sorry|ok(?:ay)?|yeah|yes|no|right|cool|great|perfect|sounds good|let me share my screen|is everyone here|let's (?:start|begin|get started))\b/i;
329
416
  const TOPIC = /\b(?:let's (?:talk about|move on|discuss|look at|go through|turn to)|(?:the )?main (?:thing|topic|item|point)|agenda|next (?:topic|item)|moving on|today we(?:'re| are| will)|the (?:goal|point|idea|context|background|reason) (?:is|here)|what (?:this|that) means is|to give (?:you )?some context)\b/i;
330
417
  /** The local label of one line. Decisions, actions and questions use the summary's own rules, so both agree. */
@@ -340,7 +427,7 @@ function categoryOf(text, names) {
340
427
  return 'statement';
341
428
  }
342
429
  /** A statement worth listing under "Facts mentioned": it has a number, or the repository check found its passage. */
343
- const isFact = (x) => !x.dropped && x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc));
430
+ const isFact = (x) => !x.dropped && (x.cat === 'fact' || (x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc))));
344
431
  const fmtTime = (ms) => {
345
432
  if (ms == null || !Number.isFinite(ms)) return null;
346
433
  const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60;
@@ -362,7 +449,8 @@ function bodySentences(body) {
362
449
  function summarize(note) {
363
450
  const tr = ((note && note.transcript) || []).filter((x) => x && x.text);
364
451
  const typed = bodySentences(note && note.body);
365
- const items = tr.map((x, i) => ({ i, text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped }));
452
+ const items = tr.map((x, i) => ({ i, index: (note.transcript || []).indexOf(x), text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped,
453
+ in_summary: x.in_summary === true ? true : x.in_summary === false ? false : null }));
366
454
  for (const s of typed) items.push({ i: items.length, text: s, t: null, src: 'notes' });
367
455
  const terms = items.map((x) => [...new Set(termsOf(x.text))]);
368
456
  const names = namesIn(items.map((x) => x.text));
@@ -396,8 +484,11 @@ function summarize(note) {
396
484
  const k = Math.max(3, Math.min(7, Math.round(pool.length / 12)));
397
485
  const chosen = [];
398
486
  const jac = (a, b) => { const A = new Set(a); const B = new Set(b); let i = 0; for (const w of A) if (B.has(w)) i++; return i / ((A.size + B.size - i) || 1); };
487
+ // the PM's choice wins: a line put in the summary is in it, a line taken out never is
488
+ for (const x of pool) if (x.in_summary === true) chosen.push(x);
399
489
  for (const x of pool.slice().sort((a, b) => score[b.i] - score[a.i] || a.i - b.i)) {
400
490
  if (chosen.length >= k || score[x.i] <= 0) break;
491
+ if (x.in_summary === false || chosen.includes(x)) continue;
401
492
  if (chosen.some((c) => jac(terms[c.i], terms[x.i]) > 0.5)) continue;
402
493
  chosen.push(x);
403
494
  }
@@ -410,7 +501,7 @@ function summarize(note) {
410
501
  const questions = items.filter((x) => kindOf(x, 'question')).filter(once);
411
502
  const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
412
503
  const sections = {
413
- overview: chosen.map((x) => ({ text: x.text, at: cite(x) })),
504
+ overview: chosen.map((x) => (x.src === 'transcript' ? { text: x.text, at: cite(x), index: x.index } : { text: x.text, at: cite(x) })),
414
505
  decisions: decisions.map((x) => ({ text: x.text, at: cite(x) })),
415
506
  actions: actions.map((x) => ({ text: x.text, at: cite(x), owner: x.owner, due: x.due })),
416
507
  questions: questions.map((x) => ({ text: x.text, at: cite(x) })),
@@ -459,9 +550,11 @@ function toMarkdown(note, { redact = false, summary = true } = {}) {
459
550
  if (String(note.body || '').trim()) out.push('## Notes', '', ...String(note.body).split(/\r?\n/).map(line), '');
460
551
  const sum = note.agent && note.agent.markdown ? note.agent.markdown : note.summary && note.summary.markdown;
461
552
  if (summary && sum) out.push('## Summary', '', ...sum.split(/\r?\n/).map(line), '');
462
- if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text)), '');
553
+ if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text + tagWords(x))), '');
463
554
  return { markdown: out.join('\n'), withheld };
464
555
  }
556
+ /** " #tag @Person" for a line, or "". */
557
+ const tagWords = (x) => [...(x.tags || []).map((t) => '#' + t), ...(x.people || []).map((p) => '@' + p)].map((w) => ' ' + w).join('');
465
558
  function promptFor(handoff, out) {
466
559
  return 'Summarize a meeting note for me. Read the file ' + handoff + ' (a PMOS meeting note: my typed notes and a transcript recognised on my machine). '
467
560
  + 'Write the summary in Markdown to the file ' + out + ' with these sections: ### Overview (three to six bullets on what the meeting was about and concluded), '
@@ -507,5 +600,5 @@ function importAgent(dir, note) {
507
600
  return true;
508
601
  }
509
602
 
510
- module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, renameSpeaker, speakerOf, categoryOf, isFact, CATS, setPrivate, dictation, cleanSentences, spokenPunctuation,
603
+ module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
511
604
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 and 2; lib/route-modules.js).
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
5
  * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
@@ -11,9 +11,12 @@
11
11
  * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
12
12
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
13
13
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
+ * POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
15
+ * GET /api/notes/recording?id=… → audio/wav, with byte ranges the note's kept audio (Run 3, D92)
16
+ * POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
14
17
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
18
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
- * { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
19
+ * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
17
20
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
18
21
  * POST /api/notes/private { id, private } → { ok, private }
19
22
  * POST /api/notes/summarize { id } → { ok, summary } the local summary
@@ -34,6 +37,7 @@ const guard = require('./pm-guard.js');
34
37
  const notes = require('./notes.js');
35
38
  const whisper = require('./notes-whisper.js');
36
39
  const notesRepo = require('./notes-repo.js');
40
+ const notesAudio = require('./notes-audio.js');
37
41
  const jevNotes = require('./jev-notes.js');
38
42
  const tsSettings = require('./typesafe-settings.js');
39
43
 
@@ -43,7 +47,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
43
47
  const prefixes = ['/api/notes'];
44
48
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
45
49
  '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
46
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
50
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
47
51
 
48
52
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
49
53
  const chains = new Map(); // note id → the promise of its enrichment so far
@@ -109,7 +113,7 @@ function engineInfo(ctx, w) {
109
113
  const d = whisper.detect(ctx.runtimeDir);
110
114
  const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
111
115
  language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
112
- model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
116
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
113
117
  if (!w) return { whisper: out, jev: null };
114
118
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
115
119
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -128,10 +132,18 @@ async function handle(req, res, url, ctx) {
128
132
  if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
129
133
  const nid = url.searchParams.get('id');
130
134
  const r = notes.read(dir, nid);
131
- if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
135
+ if (r.ok) { r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); }
132
136
  send(ctx, res, r);
133
137
  return true;
134
138
  }
139
+ if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
140
+ const nid = url.searchParams.get('id');
141
+ const r = notes.read(dir, nid);
142
+ if (!r.ok) { send(ctx, res, r); return true; }
143
+ if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no audio' }, 404); return true; }
144
+ notesAudio.serve(req, res, dir, nid);
145
+ return true;
146
+ }
135
147
  if (p === '/api/notes/engine') {
136
148
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
137
149
  const wsId = url.searchParams.get('ws');
@@ -180,17 +192,29 @@ async function handle(req, res, url, ctx) {
180
192
  if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
181
193
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
182
194
  const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
195
+ // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
196
+ if (notesAudio.getKeep(dir)) { try { notesAudio.write(dir, { id, source, pcm, t }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
183
197
  return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
184
198
  }
185
199
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
186
200
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
201
+ if (p === '/api/notes/line') {
202
+ const pick = (k) => (body[k] === undefined ? undefined : body[k]);
203
+ return done(notes.editLine(dir, { id, index: Number(body.index), cat: pick('cat'), summary: pick('summary'), tags: pick('tags'), people: pick('people'), text: pick('text') }));
204
+ }
205
+ if (p === '/api/notes/recording-delete') {
206
+ const r = notes.read(dir, id);
207
+ if (!r.ok) return done(r);
208
+ return done({ ok: true, deleted: notesAudio.remove(dir, id) });
209
+ }
187
210
  if (p === '/api/notes/switch') {
188
211
  const o = wsOf();
189
212
  // the spoken language belongs to this machine, not to a repository
190
- if (body.language !== undefined || body.model !== undefined) {
213
+ if (body.language !== undefined || body.model !== undefined || body.keep_audio !== undefined) {
191
214
  const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
192
215
  const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
193
- if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
216
+ const k = r.ok && m.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
217
+ if (!r.ok || !m.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || k.error }, 400); return true; }
194
218
  if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
195
219
  }
196
220
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
@@ -211,11 +235,11 @@ async function handle(req, res, url, ctx) {
211
235
  }
212
236
  if (p === '/api/notes/private') {
213
237
  const r = notes.setPrivate(dir, { id, private: body.private });
214
- if (r.ok && r.private && transcriber) transcriber.drop(id);
238
+ if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
215
239
  return done(r);
216
240
  }
217
241
  if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
218
- if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); return done(notes.remove(dir, id)); }
242
+ if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
219
243
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
220
244
  let w = null;
221
245
  if (body.ws != null) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -4929,6 +4929,88 @@
4929
4929
  white-space: pre-wrap;
4930
4930
  font-size: 14px;
4931
4931
  }
4932
+ .nt-now {
4933
+ background: var(--accent);
4934
+ }
4935
+ .nt-ln.nt-now {
4936
+ color: var(--foreground);
4937
+ }
4938
+ .nt-tags {
4939
+ display: flex;
4940
+ flex-wrap: wrap;
4941
+ gap: 4px;
4942
+ margin-top: 4px;
4943
+ }
4944
+ .nt-tag {
4945
+ margin-left: 4px;
4946
+ padding: 0 4px;
4947
+ border: 1px solid var(--border);
4948
+ font-size: 11px;
4949
+ color: var(--muted-foreground);
4950
+ }
4951
+ .nt-tags .nt-tag {
4952
+ margin-left: 0;
4953
+ }
4954
+ .nt-edited {
4955
+ font-size: 11px;
4956
+ font-style: italic;
4957
+ color: var(--muted-foreground);
4958
+ }
4959
+ .nt-heard {
4960
+ display: flex;
4961
+ flex-direction: column;
4962
+ align-items: flex-start;
4963
+ gap: 4px;
4964
+ }
4965
+ .nt-ver {
4966
+ display: flex;
4967
+ flex-direction: column;
4968
+ align-items: flex-start;
4969
+ gap: 8px;
4970
+ padding: 12px 0;
4971
+ border-bottom: 1px solid var(--border);
4972
+ }
4973
+ .nt-diff {
4974
+ align-self: stretch;
4975
+ font-family: var(--mono);
4976
+ font-size: 12px;
4977
+ line-height: 18px;
4978
+ white-space: pre-wrap;
4979
+ }
4980
+ .nt-diff__ln--same {
4981
+ color: var(--muted-foreground);
4982
+ }
4983
+ .nt-diff__ln--add {
4984
+ color: var(--foreground);
4985
+ background: var(--accent);
4986
+ }
4987
+ .nt-diff__ln--del {
4988
+ color: var(--muted-foreground);
4989
+ text-decoration: line-through;
4990
+ }
4991
+ .nt-replay {
4992
+ display: flex;
4993
+ align-items: center;
4994
+ flex-wrap: wrap;
4995
+ gap: 12px;
4996
+ padding-bottom: 12px;
4997
+ border-bottom: 1px solid var(--border);
4998
+ }
4999
+ .nt-audio {
5000
+ flex: 1 1 320px;
5001
+ min-width: 0;
5002
+ height: 32px;
5003
+ }
5004
+ .nt-notelines {
5005
+ display: flex;
5006
+ flex-direction: column;
5007
+ min-height: 96px;
5008
+ font-size: 14px;
5009
+ line-height: 22px;
5010
+ }
5011
+ .nt-noteline {
5012
+ white-space: pre-wrap;
5013
+ }
4932
5014
  @media (max-width: 760px) {
4933
5015
  .nt-body2 {
4934
5016
  grid-template-columns: 1fr;
@@ -1,4 +1,4 @@
1
- /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 and 2).
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
2
2
  *
3
3
  * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
4
  * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
@@ -17,10 +17,17 @@
17
17
  * LABELS AND CHECKS. The server labels each line locally; behind the D91 gates Jev labels it and compares a captured line
18
18
  * with the closest repository passage. The page shows the result in words and reads it by polling the note.
19
19
  *
20
+ * CORRECT, TRACK AND REPLAY (Run 3, D92). The ellipsis on a transcript line opens its editor: the words (the words that
21
+ * were heard stay kept and the line shows "edited"), the label, whether it is in the summary, tags and people. Tags and
22
+ * people show on the line, on its item and under Tagged. Each save of the notes keeps the text before it, and History
23
+ * shows every version as a line diff, the original first. When the note kept its audio, Replay plays it; while it plays,
24
+ * the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
25
+ * jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
26
+ *
20
27
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
21
- * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=…
28
+ * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
22
29
  * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
23
- * · summarize · summarize-agent · delete
30
+ * · summarize · summarize-agent · delete · line · recording-delete
24
31
  */
25
32
  import { el } from './dom.js';
26
33
  import { askConfirm, askText } from './dialogs.js';
@@ -49,6 +56,9 @@ let engine = null; // { ws, whisper, jev, at }
49
56
  let showList = false;
50
57
  let pollTimer = null; let agentTimer = null;
51
58
  let busy = '';
59
+ let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
60
+ let people = null; // { ws, names } — the repository's people, offered in the line editor
61
+ const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
52
62
 
53
63
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
54
64
  const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
@@ -107,6 +117,42 @@ export function caught(n) {
107
117
  return out;
108
118
  }
109
119
  const checkWords = (c) => (!c ? '' : c.verdict === 'differs' ? 'differs from ' + c.doc : c.verdict === 'agrees' ? 'consistent with ' + c.doc : 'see ' + c.doc);
120
+ /** " #tag @Person" for a line, or ''. Pure. */
121
+ export const tagWords = (x) => [...((x && x.tags) || []).map((t) => ' #' + t), ...((x && x.people) || []).map((p) => ' @' + p)].join('');
122
+ /** The lines with tags or people that are not already a caught item, in the order they were said. Pure. */
123
+ export function tagged(n) {
124
+ const c = caught(n);
125
+ const inItems = new Set(Object.values(c).flat().map((x) => x.index));
126
+ return (n.transcript || []).map((x, index) => ({ ...x, index }))
127
+ .filter((x) => x && x.text && !x.dropped && !inItems.has(x.index) && ((x.tags && x.tags.length) || (x.people && x.people.length)));
128
+ }
129
+ /** The index of the last item whose time is at or before `ms`, or -1. `times` is in meeting order. Pure. */
130
+ export function nowIndex(times, ms) {
131
+ let best = -1;
132
+ for (let i = 0; i < times.length; i++) if (typeof times[i] === 'number' && times[i] <= ms && (best < 0 || times[i] >= times[best])) best = i;
133
+ return best;
134
+ }
135
+ /** The note line written at meeting time `ms`: of the lines that have a time at or before it, the latest. Pure. */
136
+ export function noteLineAt(body, lineTimes, ms) {
137
+ const lines = String(body || '').split(/\r?\n/);
138
+ return nowIndex(lines.map((l) => (l.trim() && lineTimes ? lineTimes[l.trim()] : undefined)), ms);
139
+ }
140
+ /** A line diff from `a` to `b`: [{ op: 'same'|'del'|'add', text }]. Longest common subsequence of lines. Pure. */
141
+ export function lineDiff(a, b) {
142
+ const x = String(a || '').split(/\r?\n/); const y = String(b || '').split(/\r?\n/);
143
+ if (x.length * y.length > 250000) return [...x.map((t) => ({ op: 'del', text: t })), ...y.map((t) => ({ op: 'add', text: t }))];
144
+ const L = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
145
+ for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
146
+ const out = []; let i = 0; let j = 0;
147
+ while (i < x.length && j < y.length) {
148
+ if (x[i] === y[j]) { out.push({ op: 'same', text: x[i] }); i++; j++; }
149
+ else if (L[i + 1][j] >= L[i][j + 1]) out.push({ op: 'del', text: x[i++] });
150
+ else out.push({ op: 'add', text: y[j++] });
151
+ }
152
+ while (i < x.length) out.push({ op: 'del', text: x[i++] });
153
+ while (j < y.length) out.push({ op: 'add', text: y[j++] });
154
+ return out;
155
+ }
110
156
  /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
111
157
  export function noteMarkdown(n) {
112
158
  const out = ['# ' + (n.title || 'Untitled note'), ''];
@@ -118,9 +164,11 @@ export function noteMarkdown(n) {
118
164
  for (const [k, title] of [...SECTIONS, ['fact', 'Facts mentioned']]) {
119
165
  if (!c[k].length) continue;
120
166
  out.push('## ' + title, '', ...c[k].map((x) => '- ' + (k === 'action' && x.source && x.source !== 'speech' ? speakerOf(n, x.source) + ': ' : '') + x.text
121
- + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
167
+ + tagWords(x) + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
122
168
  }
123
- if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text), '');
169
+ const tg = tagged(n);
170
+ if (tg.length) out.push('## Tagged', '', ...tg.map((x) => '- ' + x.text + tagWords(x) + ' [' + fmtTime(x.t) + ']'), '');
171
+ if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
124
172
  return out.join('\n');
125
173
  }
126
174
  /** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
@@ -151,7 +199,7 @@ async function loadList(ctx) {
151
199
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
152
200
  ctx.setState({});
153
201
  }
154
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title]);
202
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
155
203
  async function loadNote(ctx, id) {
156
204
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
157
205
  if (r && r.ok) {
@@ -159,6 +207,7 @@ async function loadNote(ctx, id) {
159
207
  // what the PM is typing wins over what the server last saved
160
208
  if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
161
209
  r.note.__audio = r.audio || null;
210
+ r.note.__recording = r.recording || null;
162
211
  cache.set(id, r.note);
163
212
  if (!had || sig(had) !== sig(r.note)) ctx.setState({});
164
213
  } else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
@@ -210,6 +259,8 @@ async function flushSave(ctx) {
210
259
  if (r && r.ok) {
211
260
  savedAt = r.updated_at; saveError = null;
212
261
  const n = cache.get(id); if (n) { n.title = title; n.body = body; n.updated_at = r.updated_at; touchRow(n); }
262
+ // a new version was kept: read it, so History shows it
263
+ if (n && typeof r.versions === 'number' && r.versions !== (n.versions || []).length) loadNote(ctx, id);
213
264
  } else { dirty = true; saveError = (r && r.error) || 'not saved'; }
214
265
  if (dirty && !saveError) scheduleSave(ctx); // typed while the save was in flight
215
266
  paintSaveState();
@@ -237,14 +288,14 @@ async function selectNote(ctx, id) {
237
288
  showList = false;
238
289
  if (id === currentId) { ctx.setState({}); return; }
239
290
  await stopAll(ctx); await flushSave(ctx);
240
- currentId = id; editor = null; savedAt = null; saveError = null; banner = null;
291
+ currentId = id; editor = null; savedAt = null; saveError = null; banner = null; dropPlayer();
241
292
  ctx.setState({});
242
293
  loadNote(ctx, id);
243
294
  }
244
295
  async function deleteNote(ctx, n) {
245
296
  const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
246
297
  if (!sure) return;
247
- await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false;
298
+ await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false; dropPlayer();
248
299
  const r = await pmPost('/api/notes/delete', { id: n.id });
249
300
  if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
250
301
  else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
@@ -254,7 +305,7 @@ async function setPrivate(ctx, n, priv) {
254
305
  // a private press drops what is still buffered: nothing more is sent, transcribed or summarized
255
306
  if (priv) await stopAll(ctx, { discard: true });
256
307
  const r = await pmPost('/api/notes/private', { id: n.id, private: priv });
257
- if (r && r.ok) { n.private = r.private; touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this note goes to Jev or to any agent.' } : null; }
308
+ if (r && r.ok) { n.private = r.private; if (priv) { n.__recording = null; dropPlayer(); } touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, its audio was deleted, and nothing from this note goes to Jev or to any agent.' } : null; }
258
309
  else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
259
310
  ctx.setState({});
260
311
  }
@@ -310,6 +361,133 @@ async function renameSpeaker(ctx, n, source) {
310
361
  ctx.setState({});
311
362
  }
312
363
 
364
+ /* ── corrections, history and replay (Run 3, D92) ─────────────────────────────────────────────────── */
365
+ async function peopleNames(ctx) {
366
+ const ws = wsOf(ctx);
367
+ if (people && people.ws === ws) return people.names;
368
+ const r = await pmGet('/api/people' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
369
+ const names = r && r.ok && Array.isArray(r.people) ? [...new Set(r.people.map((x) => x && x.name).filter(Boolean))].slice(0, 24) : [];
370
+ people = { ws, names };
371
+ return names;
372
+ }
373
+ const splitTags = (v) => String(v || '').split(/[\s,]+/).map((t) => t.replace(/^#+/, '')).filter(Boolean);
374
+ const splitPeople = (v) => String(v || '').split(',').map((t) => t.replace(/^\s*@/, '').trim()).filter(Boolean);
375
+ /** The editor for one transcript line: its words, label, place in the summary, tags and people. */
376
+ async function editLine(ctx, n, index) {
377
+ const x = (n.transcript || [])[index];
378
+ if (!x) return;
379
+ const text = field({ label: 'Words', kind: 'textarea', value: x.text, attrs: { 'data-nt-ed-text': '' } });
380
+ const cat = field({ label: 'Label', kind: 'select', value: LABELS.some(([v]) => v === x.cat) ? x.cat : 'statement', options: LABELS.map(([value, label]) => ({ value, label })), attrs: { 'data-nt-ed-cat': '' } });
381
+ const sumv = x.in_summary === true ? 'in' : x.in_summary === false ? 'out' : 'auto';
382
+ const sum = field({ label: 'Summary', kind: 'select', value: sumv, options: [{ value: 'auto', label: 'As picked' }, { value: 'in', label: 'Always in the summary' }, { value: 'out', label: 'Never in the summary' }], attrs: { 'data-nt-ed-summary': '' } });
383
+ const tags = field({ label: 'Tags', value: (x.tags || []).map((t) => '#' + t).join(' '), placeholder: '#pricing #trial', attrs: { 'data-nt-ed-tags': '' } });
384
+ const ppl = field({ label: 'People', value: (x.people || []).join(', '), placeholder: 'Names, separated by commas', attrs: { 'data-nt-ed-people': '' } });
385
+ const sugg = el('div', { class: 'nt-row', 'data-nt-ed-suggest': '' });
386
+ const addPerson = (name) => {
387
+ const have = splitPeople(ppl.control.value);
388
+ if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) ppl.control.value = [...have, name].join(', ');
389
+ };
390
+ const heard = x.orig_text !== undefined
391
+ ? el('div', { class: 'nt-heard', 'data-nt-ed-orig': '' }, el('p', { class: 'nt-hint' }, 'Heard as: ' + x.orig_text),
392
+ button({ label: 'Use what was heard', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => { text.control.value = x.orig_text; }, attrs: { 'data-nt-ed-restore': '' } }))
393
+ : null;
394
+ let h = null;
395
+ const save = async () => {
396
+ const patch = { id: n.id, index, tags: splitTags(tags.control.value), people: splitPeople(ppl.control.value) };
397
+ const t = text.control.value.replace(/\s+/g, ' ').trim();
398
+ if (t !== x.text) patch.text = t;
399
+ if (cat.control.value !== x.cat) patch.cat = cat.control.value;
400
+ if (sum.control.value !== sumv) patch.summary = sum.control.value === 'in' ? true : sum.control.value === 'out' ? false : null;
401
+ const r = await pmPost('/api/notes/line', patch);
402
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; banner = null; h.close(); }
403
+ else banner = { tone: 'bad', text: (r && r.error) || 'the line was not changed' };
404
+ ctx.setState({});
405
+ };
406
+ h = sheet({ title: 'Correct this line', sub: speakerOf(n, x.source) + ', ' + fmtTime(x.t), body: [text, heard, cat, sum, tags, ppl, sugg], attrs: { 'data-nt-line-sheet': '' },
407
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', onClick: save, attrs: { 'data-nt-ed-save': '' } })] });
408
+ const names = await peopleNames(ctx);
409
+ if (h.open && names.length) sugg.replaceChildren(...names.map((name) => button({ label: name, icon: 'plus', variant: 'ghost', size: 'sm', onClick: () => addPerson(name), attrs: { 'data-nt-ed-person': name } })));
410
+ }
411
+ async function unsummarize(ctx, n, index) {
412
+ const r = await pmPost('/api/notes/line', { id: n.id, index, summary: false });
413
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
414
+ ctx.setState({});
415
+ }
416
+ /** Every kept version of the notes, newest first, each as the lines it changed from the one before. */
417
+ function openHistory(ctx, n) {
418
+ const vs = n.versions || [];
419
+ const now = { at: n.updated_at, title: editor && editor.id === n.id ? editor.title.value : n.title, body: editor && editor.id === n.id ? editor.body.value : n.body, current: true };
420
+ const all = [...vs, now];
421
+ const when = (iso) => dayOf(iso) + ', ' + nowClock(iso);
422
+ let h = null;
423
+ const restore = (v) => {
424
+ h.close();
425
+ if (!editor || editor.id !== n.id) return;
426
+ editor.title.value = v.title || ''; editor.body.value = v.body || '';
427
+ banner = { tone: 'ok', text: 'The version from ' + when(v.at) + ' is back in your notes. The text before it is kept in History.' };
428
+ scheduleSave(ctx); ctx.setState({});
429
+ };
430
+ const items = all.map((v, i) => {
431
+ const diff = i === 0 ? String(v.body || '').split(/\r?\n/).map((t) => ({ op: 'same', text: t })) : lineDiff(all[i - 1].body, v.body);
432
+ const head = v.current ? 'Now' : i === 0 ? 'Original, ' + when(v.at) : 'Version ' + (i + 1) + ', ' + when(v.at);
433
+ return el('div', { class: 'nt-ver', 'data-nt-version': v.current ? 'now' : String(i) },
434
+ el('div', { class: 'nt-set__head' }, head, i > 0 && all[i - 1].title !== v.title ? el('span', { class: 'nt-hint' }, ' · title: ' + (v.title || 'Untitled')) : null),
435
+ el('div', { class: 'nt-diff' }, diff.map((d) => el('div', { class: 'nt-diff__ln nt-diff__ln--' + d.op, 'data-nt-diff': d.op },
436
+ el('span', { class: 'nt-sr' }, d.op === 'add' ? 'added: ' : d.op === 'del' ? 'removed: ' : ''), d.text || ' '))),
437
+ v.current ? null : button({ label: 'Use this version', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => restore(v), attrs: { 'data-nt-restore': String(i) } }));
438
+ }).reverse();
439
+ h = sheet({ title: 'History of your notes', sub: vs.length + ' earlier version' + (vs.length === 1 ? '' : 's') + '. The original is kept.', body: items, attrs: { 'data-nt-history-sheet': '' } });
440
+ }
441
+ function dropPlayer() { if (player) { try { player.el.pause(); } catch { /* gone */ } player = null; } }
442
+ /** The replay of the note's kept audio. One <audio> per note, kept across redraws so playback goes on. */
443
+ function playerFor(ctx, n) {
444
+ const rec = n.__recording;
445
+ if (!rec || n.private) { if (player && player.id === n.id) dropPlayer(); return null; }
446
+ if (player && (player.id !== n.id || (player.bytes !== rec.bytes && !player.playing))) dropPlayer();
447
+ if (player) return player;
448
+ const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes });
449
+ const p = { id: n.id, bytes: rec.bytes, el: a, playing: false };
450
+ a.addEventListener('play', () => { p.playing = true; ctx.setState({}); });
451
+ a.addEventListener('pause', () => { p.playing = false; ctx.setState({}); });
452
+ a.addEventListener('ended', () => { p.playing = false; ctx.setState({}); });
453
+ a.addEventListener('timeupdate', paintNow);
454
+ a.addEventListener('seeked', paintNow);
455
+ player = p;
456
+ return p;
457
+ }
458
+ function seek(ctx, n, ms) {
459
+ const p = playerFor(ctx, n);
460
+ if (!p) return;
461
+ try { p.el.currentTime = Math.max(0, ms / 1000); p.el.play().catch(() => {}); } catch { /* not loaded yet */ }
462
+ paintNow();
463
+ }
464
+ /** Highlight the transcript line, its item and the note line at the replay's time. Classes only: no redraw. */
465
+ function paintNow() {
466
+ if (typeof document === 'undefined') return;
467
+ for (const x of document.querySelectorAll('.nt-now')) x.classList.remove('nt-now');
468
+ const n = currentId ? cache.get(currentId) : null;
469
+ if (!player || !n || player.id !== n.id) return;
470
+ const ms = player.el.currentTime * 1000;
471
+ if (!player.playing && !(ms > 0)) return;
472
+ const i = nowIndex((n.transcript || []).map((x) => x.t), ms);
473
+ if (i >= 0) {
474
+ for (const x of document.querySelectorAll('[data-nt-seg="' + i + '"], [data-nt-item="' + i + '"], [data-nt-ov="' + i + '"]')) x.classList.add('nt-now');
475
+ const ln = document.querySelector('[data-nt-seg="' + i + '"]');
476
+ if (ln && player.playing && ln.scrollIntoView) ln.scrollIntoView({ block: 'nearest' });
477
+ }
478
+ const k = noteLineAt(editor && editor.id === n.id ? editor.body.value : n.body, n.line_times, ms);
479
+ if (k >= 0) { const nl = document.querySelector('[data-nt-noteline="' + k + '"]'); if (nl) nl.classList.add('nt-now'); }
480
+ }
481
+ async function deleteAudio(ctx, n) {
482
+ const sure = await askConfirm({ title: 'Delete this note\'s audio?', body: 'The recording of "' + (n.title || 'Untitled note') + '" is deleted from this machine. The notes and the transcript stay. This cannot be undone.', confirmLabel: 'Delete audio', danger: true });
483
+ if (!sure) return;
484
+ if (player && player.id === n.id) dropPlayer();
485
+ const r = await pmPost('/api/notes/recording-delete', { id: n.id });
486
+ if (r && r.ok) { n.__recording = null; banner = { tone: 'ok', text: 'The audio was deleted. The notes and the transcript are kept.' }; }
487
+ else banner = { tone: 'bad', text: (r && r.error) || 'the audio was not deleted' };
488
+ ctx.setState({});
489
+ }
490
+
313
491
  /* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
314
492
  async function openSource(kind) {
315
493
  const md = navigator.mediaDevices;
@@ -491,7 +669,9 @@ async function chooseSources(ctx, n) {
491
669
  const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
492
670
  const body = [mic, meet,
493
671
  el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
494
- el('p', { class: 'nt-consent', 'data-nt-consent': '' }, 'Speech is transcribed on this ' + (local ? 'machine' : 'device') + '. Let people know you\'re taking notes.')];
672
+ el('p', { class: 'nt-consent', 'data-nt-consent': '' }, local
673
+ ? 'Speech is transcribed on this machine' + (eng.whisper.keep_audio === false ? ', and no audio is kept' : ', and the audio is kept on this machine so you can replay the meeting') + '. Let people know you\'re taking notes.'
674
+ : 'Speech is recognised by the browser on this device, and no audio is kept. Let people know you\'re taking notes.')];
495
675
  let h = null;
496
676
  const go = () => {
497
677
  const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
@@ -504,7 +684,7 @@ async function chooseSources(ctx, n) {
504
684
  }
505
685
  async function setSwitch(ctx, patch) {
506
686
  const ws = wsOf(ctx);
507
- if (!ws && patch.language === undefined && patch.model === undefined) return;
687
+ if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined) return;
508
688
  const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
509
689
  if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
690
  ctx.setState({});
@@ -531,7 +711,12 @@ function openSettings(ctx, n) {
531
711
  onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
532
712
  w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
533
713
  w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
534
- onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
714
+ onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null,
715
+ w ? field({ label: 'Keep the audio for replay', kind: 'check', value: w.keep_audio !== false,
716
+ onCommit: (v) => setSwitch(ctx, { keep_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-keep-audio': '' } }) : null,
717
+ w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
718
+ ? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
719
+ : 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null));
535
720
  if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
536
721
  else {
537
722
  const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
@@ -546,6 +731,7 @@ function openSettings(ctx, n) {
546
731
  el('div', { class: 'nt-row' },
547
732
  button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
548
733
  button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
734
+ n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
549
735
  button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
550
736
  return parts;
551
737
  };
@@ -584,7 +770,9 @@ function listening(n) {
584
770
  function header(ctx, n, ed) {
585
771
  const live = listening(n);
586
772
  const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
587
- const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()));
773
+ const nv = (n.versions || []).length;
774
+ const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
775
+ nv ? button({ label: 'Edited', icon: 'history', variant: 'ghost', size: 'sm', onClick: () => openHistory(ctx, n), attrs: { 'data-nt-history': String(nv), title: nv + ' earlier version' + (nv === 1 ? '' : 's') + ' kept' } }) : null);
588
776
  const acts = [];
589
777
  if (live) {
590
778
  acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
@@ -622,6 +810,11 @@ function checkMark(x) {
622
810
  if (c.verdict === 'agrees') return el('span', { class: 'nt-ok', 'data-nt-check': 'agrees', title: 'Jev rates it ' + (c.words || 'likely') + ' that ' + where + ' already says this', role: 'img', 'aria-label': 'consistent with ' + baseName(c.doc) }, icon('check'));
623
811
  return el('span', { class: 'nt-rel', 'data-nt-check': 'related', title: 'The closest passage in this repository: ' + where }, 'see ' + baseName(c.doc));
624
812
  }
813
+ function chips(x) {
814
+ return [...(x.tags || []).map((t) => el('span', { class: 'nt-tag', 'data-nt-tag': t }, '#' + t)),
815
+ ...(x.people || []).map((p) => el('span', { class: 'nt-tag nt-tag--person', 'data-nt-person': p }, '@' + p))];
816
+ }
817
+ const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-edited', 'data-nt-edited': '', title: 'Heard as: ' + x.orig_text }, 'edited') : null);
625
818
  function sectionOf(ctx, n, key, title, items) {
626
819
  if (!items.length) return null;
627
820
  return el('section', { class: 'nt-sec', 'data-nt-sec': key },
@@ -629,9 +822,9 @@ function sectionOf(ctx, n, key, title, items) {
629
822
  el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
630
823
  el('span', { class: 'nt-item__text' },
631
824
  key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
632
- x.text, ' ', checkMark(x)),
825
+ x.text, ' ', checkMark(x), ' ', editedMark(x), chips(x)),
633
826
  el('span', { class: 'nt-at' }, fmtTime(x.t)),
634
- key === 'fact' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
827
+ key === 'fact' || key === 'tagged' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
635
828
  }
636
829
  function summarySection(ctx, n) {
637
830
  const a = n.agent;
@@ -646,13 +839,19 @@ function summarySection(ctx, n) {
646
839
  else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
647
840
  if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
648
841
  return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
649
- el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, x.text)))),
842
+ el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain', 'data-nt-ov': x.index === undefined ? null : String(x.index) }, el('span', { class: 'nt-item__text' }, x.text),
843
+ x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the summary', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
650
844
  el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
651
845
  }
652
846
  function noteColumn(ctx, n, ed) {
653
847
  const live = listening(n);
654
848
  const c = caught(n);
655
- const parts = [summarySection(ctx, n), ed.bodyWrap];
849
+ const replaying = player && player.id === n.id && player.playing;
850
+ const notes = replaying
851
+ ? el('div', { class: 'nt-notelines', 'data-nt-notelines': '' }, String(ed.body.value || '').split(/\r?\n/).map((l, k) => el('div', { class: 'nt-noteline', 'data-nt-noteline': String(k) }, l || ' ')),
852
+ el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to edit them.'))
853
+ : ed.bodyWrap;
854
+ const parts = [summarySection(ctx, n), notes];
656
855
  const sp = speech && speech.id === n.id ? speech : null;
657
856
  const bar = [];
658
857
  if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
@@ -663,6 +862,7 @@ function noteColumn(ctx, n, ed) {
663
862
  if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
664
863
  const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
665
864
  if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
865
+ const tg = sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)); if (tg) secs.push(tg);
666
866
  if (secs.length) parts.push(...secs);
667
867
  else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
668
868
  : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
@@ -677,8 +877,13 @@ function transcriptColumn(ctx, n) {
677
877
  const spk = x.source === 'meeting'
678
878
  ? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
679
879
  : el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
680
- return el('div', { class: 'nt-ln' + (['decision', 'action', 'question'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
681
- el('div', { class: 'nt-ln__meta' }, spk, el('span', { class: 'nt-at' }, fmtTime(x.t)), lab), el('div', null, x.text));
880
+ const at = n.__recording && !live && typeof x.t === 'number'
881
+ ? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek': String(i), title: 'Play from here' } })
882
+ : el('span', { class: 'nt-at' }, fmtTime(x.t));
883
+ return el('div', { class: 'nt-ln' + (['decision', 'action', 'question', 'fact'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
884
+ el('div', { class: 'nt-ln__meta' }, spk, at, lab, editedMark(x),
885
+ iconButton({ icon: 'ellipsis', ariaLabel: 'Correct this line', onClick: () => editLine(ctx, n, i), size: 'sm', attrs: { 'data-nt-edit': String(i) } })),
886
+ el('div', null, x.text), x.tags || x.people ? el('div', { class: 'nt-tags' }, chips(x)) : null);
682
887
  });
683
888
  const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
684
889
  if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
@@ -716,7 +921,11 @@ export function renderBoard(inner, ctx) {
716
921
  wrap.appendChild(header(ctx, n, ed));
717
922
  if (showList) wrap.appendChild(listPane(ctx));
718
923
  if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
924
+ const pl = !listening(n) ? playerFor(ctx, n) : null;
925
+ if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el,
926
+ el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
719
927
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
928
+ if (pl) queueMicrotask(paintNow);
720
929
  if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
721
930
  requestAnimationFrame(fitBody);
722
931
  }