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 +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/guide/notes.md +35 -6
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/routes-notes.js +33 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +82 -0
- package/workspace/public/pm/view-notes.js +228 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
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-
|
|
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-
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-07T00:08:29Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
package/workspace/guide/notes.md
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
113
|
+
const nextTitle = title !== undefined ? cleanTitle(title) : note.title;
|
|
114
|
+
let nextBody = note.body;
|
|
101
115
|
if (body !== undefined) {
|
|
102
|
-
|
|
103
|
-
if (Buffer.byteLength(
|
|
104
|
-
|
|
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 (!
|
|
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
|
|
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? }
|
|
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
|
-
|
|
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
|
|
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);
|
|
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) {
|
package/workspace/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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': '' },
|
|
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
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
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
|
}
|