create-pmos 0.112.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +3 -1
- package/workspace/guide/today.md +3 -1
- package/workspace/lib/notes-audio.js +2 -1
- package/workspace/lib/notes-echo-worker.js +6 -0
- package/workspace/lib/notes-echo.js +341 -0
- package/workspace/lib/routes-notes.js +3 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +18 -5
- package/workspace/public/pm/notes-links.js +9 -1
- package/workspace/public/pm/today-agenda.js +24 -3
- package/workspace/public/pm/today-copy.js +13 -0
- package/workspace/public/pm/today-day.js +94 -36
- package/workspace/test-lists/meeting-notes.json +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.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
|
@@ -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-08T05:30:42Z"}
|
|
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-08T05:30:42Z"}
|
|
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-08T05:30:42Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -28,7 +28,7 @@ The Notes view shows one meeting, top to bottom. A bar at the top shows whether
|
|
|
28
28
|
4. Press **Stop** to end. The summary shows at the top of your notes. To carry on, press **Listen again**: the clock carries on from where it stopped, and the time between does not count. Line times and the kept audio use the same clock.
|
|
29
29
|
5. If the workspace does not take a part of the audio, the page sends it again two times. If it still fails, a red message tells you how many seconds were not saved. Listening goes on. That part of the recording has a gap.
|
|
30
30
|
|
|
31
|
-
To avoid an echo, listen to the meeting through headphones. When the meeting plays from a speaker, your microphone hears it a second time
|
|
31
|
+
To avoid an echo, listen to the meeting through headphones. When the meeting plays from a speaker, your microphone hears it a second time. After **Stop**, the workspace removes most of that echo from the replay (see **Replay the meeting**).
|
|
32
32
|
|
|
33
33
|
The meeting can only be heard when local transcription is set up. Until then, **Listen** hears your microphone only, through the browser's own recognition on this device.
|
|
34
34
|
|
|
@@ -130,6 +130,8 @@ When a meeting was heard with local transcription, its audio is kept on this mac
|
|
|
130
130
|
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.
|
|
131
131
|
3. Press a time in the transcript to play from there.
|
|
132
132
|
|
|
133
|
+
When you listened to both your microphone and the meeting, the workspace removes the meeting's echo from your microphone after you press **Stop**. A speaker plays the meeting, your microphone hears it a second time, and this second copy is the echo. The workspace uses the meeting's own recording as the guide. This takes a few minutes for each hour of meeting. It runs in the background, and the replay changes when it is done. Your own voice stays, because it is not in the meeting's recording. A part of the meeting where the clean-up would not help stays as it was recorded. The transcript does not change, because it is made from each source on its own. Headphones are still the best fix, because they stop the echo at its source.
|
|
134
|
+
|
|
133
135
|
The replay is your microphone and the meeting added together. When both are loud at the same moment, the workspace lowers the peaks softly, so the sound does not distort. A quiet voice is not changed. The transcript is made from each source on its own, so this does not change what is written.
|
|
134
136
|
|
|
135
137
|
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.
|
package/workspace/guide/today.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Today: what needs you"
|
|
4
4
|
description: "The Agenda month and the calendar it remembers, the meeting notes of a meeting, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-08
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Today: what needs you
|
|
@@ -52,6 +52,8 @@ Each day of the month answers three questions:
|
|
|
52
52
|
|
|
53
53
|
The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
|
|
54
54
|
|
|
55
|
+
A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, the goal it counts toward, During and After, the mail and the links.
|
|
56
|
+
|
|
55
57
|
A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. PMOS starts to listen at once and keeps the note small at the bottom right. Press it to open the note as a pop-up. When the meeting has a note, the detail shows its summary and **Open notes**, which opens it in the pop-up, and the meeting's row shows **notes**. See [Notes](notes.md).
|
|
56
58
|
|
|
57
59
|
The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
|
|
@@ -107,6 +107,7 @@ function remove(runtimeDir, id) {
|
|
|
107
107
|
if (!ID_RE.test(String(id || ''))) return false;
|
|
108
108
|
for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
|
|
109
109
|
for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
|
|
110
|
+
for (const extra of ['.echo.json', '.pcm.echo.tmp']) { try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); } catch { /* none */ } }
|
|
110
111
|
try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
|
|
111
112
|
}
|
|
112
113
|
/** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
|
|
@@ -151,4 +152,4 @@ function serve(req, res, runtimeDir, id) {
|
|
|
151
152
|
fs.createReadStream(fileOf(runtimeDir, id), { start: from, end: to }).on('error', () => res.end()).pipe(res);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
155
|
+
module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, wavHeader, softLimit, replayFile: fileOf, RATE, BYTES_PER_MS, MAX_BYTES };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-echo-worker.js — runs notes-echo.cleanReplay on a worker thread (notes-audio-quality Run 2) and posts its result. */
|
|
3
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
4
|
+
const echo = require('./notes-echo');
|
|
5
|
+
|
|
6
|
+
parentPort.postMessage(echo.cleanReplay(workerData.runtimeDir, workerData.id));
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* notes-echo.js — take the meeting's echo out of the microphone in a note's replay (notes-audio-quality Run 2). Local only.
|
|
3
|
+
*
|
|
4
|
+
* cleanStream({ n, readFar, readMic, onChunk }) the canceller, over any two tracks of n samples → a report
|
|
5
|
+
* cleanReplay(runtimeDir, id) rebuild the note's replay file from its two source files → { ok, ... }
|
|
6
|
+
* cleanReplayInWorker(runtimeDir, id) the same, on a worker thread, so the server stays awake → Promise
|
|
7
|
+
* report(runtimeDir, id) what the last clean-up did, or null
|
|
8
|
+
*
|
|
9
|
+
* THE PROBLEM. The microphone hears the meeting through the speakers, so each voice is recorded twice, the second copy late.
|
|
10
|
+
* lib/notes-audio.js keeps the meeting on its own (<id>.meeting.pcm), which is exactly the signal that leaked into the microphone.
|
|
11
|
+
*
|
|
12
|
+
* THE METHOD. For a window of 30 s, find the echo path from the meeting to the microphone by least squares: the cross-correlation
|
|
13
|
+
* of the two tracks (summed over the whole window, so no frame edge biases it) gives a Toeplitz system, solved by conjugate
|
|
14
|
+
* gradient with FFTs alone. Your own voice is not correlated with the meeting, so it averages out of the fit and survives. The
|
|
15
|
+
* predicted echo is subtracted from the microphone. The path is 512 ms long, so a delay of up to about 300 ms and a room tail fit.
|
|
16
|
+
*
|
|
17
|
+
* THE TWO GUARDS (R7b). (1) A window whose parts disagree — some kept little of their energy, some a lot, because the path changed,
|
|
18
|
+
* for example the volume went down — is cut in two and each half is fitted again, down to about 7 s. (2) In every 10 s stretch,
|
|
19
|
+
* on the frames where the meeting speaks, the result must keep less than 70% of the microphone's energy (1.5 dB gained). If it
|
|
20
|
+
* does not, that stretch keeps the microphone as it was. The clean-up can lose a little echo; it cannot make a stretch louder.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT NEVER TOUCHES. The two source files (what the transcription reads) are only read. Only the mixed replay file is
|
|
23
|
+
* rewritten, through a temporary file and a rename, and only if the sources did not grow while it ran. It opens no network module.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { Worker } = require('worker_threads');
|
|
28
|
+
const notesAudio = require('./notes-audio');
|
|
29
|
+
|
|
30
|
+
const FS = notesAudio.RATE; // 16000
|
|
31
|
+
const L = 8192; // the echo path kept: 512 ms
|
|
32
|
+
const FB = 32768; // FFT size for the correlations and the prediction
|
|
33
|
+
const BLK = FB - L;
|
|
34
|
+
const FRAME = 320; // 20 ms
|
|
35
|
+
const ACTIVE = 1e-5; // mean square (full scale = 1) above which the meeting is speaking: -50 dB
|
|
36
|
+
const WINDOW = 30 * FS;
|
|
37
|
+
const MIN_PIECE = 6 * FS;
|
|
38
|
+
const CHECK = 10 * FS; // the stretch R7b is held over
|
|
39
|
+
const PART = Math.round(2.5 * FS); // the part of a window looked at for a bad fit
|
|
40
|
+
const MARGIN = 2000; // samples of data either side of a window the fit may also use
|
|
41
|
+
const SPLIT_RATIO = 0.35; // a part that kept more than this share of its energy is a bad fit …
|
|
42
|
+
const SPLIT_SPREAD = 0.25; // … when another part of the window kept this much less: the parts disagree, so the path changed
|
|
43
|
+
const KEEP_RATIO = 0.7; // a stretch must keep less than this share (1.5 dB gained), or it is left as it was
|
|
44
|
+
const MIN_ACTIVE_FRAMES = 25; // half a second of meeting speech before a part is judged
|
|
45
|
+
const ITERATIONS = 150;
|
|
46
|
+
const LOADING = 1e-3;
|
|
47
|
+
const RAMP = 800; // samples over which subtraction fades in or out between stretches
|
|
48
|
+
const TIMEOUT_MS = 30 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/* ── FFT ──────────────────────────────────────────────────────────────────────────────────────── */
|
|
51
|
+
const twiddles = new Map();
|
|
52
|
+
function table(n) {
|
|
53
|
+
let t = twiddles.get(n);
|
|
54
|
+
if (!t) {
|
|
55
|
+
t = { cos: new Float64Array(n / 2), sin: new Float64Array(n / 2) };
|
|
56
|
+
for (let i = 0; i < n / 2; i++) { t.cos[i] = Math.cos(2 * Math.PI * i / n); t.sin[i] = Math.sin(2 * Math.PI * i / n); }
|
|
57
|
+
twiddles.set(n, t);
|
|
58
|
+
}
|
|
59
|
+
return t;
|
|
60
|
+
}
|
|
61
|
+
/** In-place radix-2 FFT of (re, im). `inv` runs the inverse and divides by n. */
|
|
62
|
+
function fft(re, im, inv) {
|
|
63
|
+
const n = re.length; const t = table(n);
|
|
64
|
+
for (let i = 1, j = 0; i < n; i++) {
|
|
65
|
+
let bit = n >> 1;
|
|
66
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
67
|
+
j ^= bit;
|
|
68
|
+
if (i < j) { const a = re[i]; re[i] = re[j]; re[j] = a; const b = im[i]; im[i] = im[j]; im[j] = b; }
|
|
69
|
+
}
|
|
70
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
71
|
+
const step = n / len; const half = len >> 1;
|
|
72
|
+
for (let i = 0; i < n; i += len) {
|
|
73
|
+
for (let k = 0; k < half; k++) {
|
|
74
|
+
const wr = t.cos[k * step]; const wi = inv ? t.sin[k * step] : -t.sin[k * step];
|
|
75
|
+
const a = i + k; const b = a + half;
|
|
76
|
+
const tr = re[b] * wr - im[b] * wi; const ti = re[b] * wi + im[b] * wr;
|
|
77
|
+
re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (inv) for (let i = 0; i < n; i++) { re[i] /= n; im[i] /= n; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ── the fit ──────────────────────────────────────────────────────────────────────────────────── */
|
|
85
|
+
/** r[t] = sum x[u] x[u-t] and c[t] = sum m[u] x[u-t], for t < L and u in [from, to). Samples before 0 count as silence. */
|
|
86
|
+
function correlations(x, m, from, to) {
|
|
87
|
+
const r = new Float64Array(L); const c = new Float64Array(L);
|
|
88
|
+
const xb = new Float64Array(FB), xi = new Float64Array(FB), cur = new Float64Array(FB), ci = new Float64Array(FB), mc = new Float64Array(FB), mi = new Float64Array(FB);
|
|
89
|
+
const ar = new Float64Array(FB), ai = new Float64Array(FB), br = new Float64Array(FB), bi = new Float64Array(FB);
|
|
90
|
+
for (let s = from; s < to; s += BLK) {
|
|
91
|
+
const len = Math.min(BLK, to - s);
|
|
92
|
+
xb.fill(0); xi.fill(0); cur.fill(0); ci.fill(0); mc.fill(0); mi.fill(0);
|
|
93
|
+
let e = 0;
|
|
94
|
+
for (let j = 0; j < L + len; j++) { const t = s - L + j; if (t >= 0 && t < x.length) xb[j] = x[t]; }
|
|
95
|
+
for (let j = 0; j < len; j++) { cur[L + j] = x[s + j]; mc[L + j] = m[s + j]; e += x[s + j] * x[s + j]; }
|
|
96
|
+
if (e / len < 1e-8) continue; // the meeting is silent here: nothing to learn
|
|
97
|
+
fft(xb, xi, false); fft(cur, ci, false); fft(mc, mi, false);
|
|
98
|
+
for (let k = 0; k < FB; k++) { // CUR * conj(XB) and MC * conj(XB)
|
|
99
|
+
ar[k] = cur[k] * xb[k] + ci[k] * xi[k]; ai[k] = ci[k] * xb[k] - cur[k] * xi[k];
|
|
100
|
+
br[k] = mc[k] * xb[k] + mi[k] * xi[k]; bi[k] = mi[k] * xb[k] - mc[k] * xi[k];
|
|
101
|
+
}
|
|
102
|
+
fft(ar, ai, true); fft(br, bi, true);
|
|
103
|
+
for (let t = 0; t < L; t++) { r[t] += ar[t]; c[t] += br[t]; }
|
|
104
|
+
}
|
|
105
|
+
return { r, c };
|
|
106
|
+
}
|
|
107
|
+
/** R v for the symmetric Toeplitz matrix R whose first column is held, as the FFT of its circulant embedding. */
|
|
108
|
+
function toeplitzMul(col, v) {
|
|
109
|
+
const n = 2 * L; const re = new Float64Array(n); const im = new Float64Array(n);
|
|
110
|
+
re.set(v); fft(re, im, false);
|
|
111
|
+
for (let k = 0; k < n; k++) { const a = re[k] * col.re[k] - im[k] * col.im[k]; im[k] = re[k] * col.im[k] + im[k] * col.re[k]; re[k] = a; }
|
|
112
|
+
fft(re, im, true);
|
|
113
|
+
return re.subarray(0, L);
|
|
114
|
+
}
|
|
115
|
+
/** The impulse response from the meeting to the microphone over [from, to), or null when the meeting said nothing. */
|
|
116
|
+
function estimatePath(x, m, from, to) {
|
|
117
|
+
const { r, c } = correlations(x, m, from, to);
|
|
118
|
+
if (!(r[0] > 0)) return null;
|
|
119
|
+
const n = 2 * L; const re = new Float64Array(n); const im = new Float64Array(n);
|
|
120
|
+
for (let t = 0; t < L; t++) re[t] = r[t];
|
|
121
|
+
for (let t = 1; t < L; t++) re[n - t] = r[t];
|
|
122
|
+
fft(re, im, false);
|
|
123
|
+
const col = { re, im };
|
|
124
|
+
const lam = LOADING * r[0];
|
|
125
|
+
const h = new Float64Array(L); const res = Float64Array.from(c); const p = Float64Array.from(res);
|
|
126
|
+
let rs = 0; for (let i = 0; i < L; i++) rs += res[i] * res[i];
|
|
127
|
+
const rs0 = rs;
|
|
128
|
+
for (let it = 0; it < ITERATIONS && rs > 1e-10 * rs0; it++) {
|
|
129
|
+
const Ap = toeplitzMul(col, p); let pAp = 0;
|
|
130
|
+
for (let i = 0; i < L; i++) { Ap[i] += lam * p[i]; pAp += p[i] * Ap[i]; }
|
|
131
|
+
if (!(pAp > 0)) break;
|
|
132
|
+
const alpha = rs / pAp; let rn = 0;
|
|
133
|
+
for (let i = 0; i < L; i++) { h[i] += alpha * p[i]; res[i] -= alpha * Ap[i]; rn += res[i] * res[i]; }
|
|
134
|
+
const beta = rn / rs; rs = rn;
|
|
135
|
+
for (let i = 0; i < L; i++) p[i] = res[i] + beta * p[i];
|
|
136
|
+
}
|
|
137
|
+
return { h };
|
|
138
|
+
}
|
|
139
|
+
/** The first tap that reaches a quarter of the biggest: when the meeting's sound first arrives at the microphone. */
|
|
140
|
+
function arrival(h) {
|
|
141
|
+
let mx = 0; for (let i = 0; i < h.length; i++) mx = Math.max(mx, Math.abs(h[i]));
|
|
142
|
+
for (let i = 0; i < h.length; i++) if (Math.abs(h[i]) >= 0.25 * mx) return i;
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
/** The echo that h predicts at samples [from, to): conv(x, h) by overlap-save. */
|
|
146
|
+
function predict(x, h, from, to) {
|
|
147
|
+
const out = new Float64Array(to - from);
|
|
148
|
+
const Hr = new Float64Array(FB); const Hi = new Float64Array(FB);
|
|
149
|
+
Hr.set(h); fft(Hr, Hi, false);
|
|
150
|
+
const re = new Float64Array(FB); const im = new Float64Array(FB);
|
|
151
|
+
for (let s = from; s < to; s += BLK) {
|
|
152
|
+
const len = Math.min(BLK, to - s);
|
|
153
|
+
re.fill(0); im.fill(0);
|
|
154
|
+
for (let j = 0; j < L + len; j++) { const t = s - L + j; if (t >= 0 && t < x.length) re[j] = x[t]; }
|
|
155
|
+
fft(re, im, false);
|
|
156
|
+
for (let k = 0; k < FB; k++) { const a = re[k] * Hr[k] - im[k] * Hi[k]; im[k] = re[k] * Hi[k] + im[k] * Hr[k]; re[k] = a; }
|
|
157
|
+
fft(re, im, true);
|
|
158
|
+
for (let j = 0; j < len; j++) out[s - from + j] = re[L + j];
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ── where the meeting speaks, and what a stretch kept ───────────────────────────────────────── */
|
|
164
|
+
/** Energy of mic and of out over the 20 ms frames of [a, b) in which the meeting speaks: { eIn, eOut, frames }. */
|
|
165
|
+
function kept(far, mic, out, a, b, base) {
|
|
166
|
+
let eIn = 0; let eOut = 0; let frames = 0;
|
|
167
|
+
for (let f = a; f + FRAME <= b; f += FRAME) {
|
|
168
|
+
let ex = 0;
|
|
169
|
+
for (let i = 0; i < FRAME; i++) ex += far[f + i] * far[f + i];
|
|
170
|
+
if (ex / FRAME <= ACTIVE) continue;
|
|
171
|
+
frames++;
|
|
172
|
+
for (let i = 0; i < FRAME; i++) { eIn += mic[f + i] * mic[f + i]; eOut += out[f - base + i] * out[f - base + i]; }
|
|
173
|
+
}
|
|
174
|
+
return { eIn, eOut, frames };
|
|
175
|
+
}
|
|
176
|
+
const speaks = (far, a, b) => { let n = 0; for (let f = a; f + FRAME <= b; f += FRAME) { let ex = 0; for (let i = 0; i < FRAME; i++) ex += far[f + i] * far[f + i]; if (ex / FRAME > ACTIVE) n++; } return n; };
|
|
177
|
+
|
|
178
|
+
/** Smooth a 0/1 gain so subtraction fades over RAMP samples instead of switching. */
|
|
179
|
+
function smooth(gain) {
|
|
180
|
+
const n = gain.length; const out = new Float64Array(n); const w = 2 * RAMP + 1;
|
|
181
|
+
let acc = 0;
|
|
182
|
+
for (let i = -RAMP; i <= RAMP; i++) acc += gain[Math.min(n - 1, Math.max(0, i))];
|
|
183
|
+
for (let i = 0; i < n; i++) {
|
|
184
|
+
out[i] = acc / w;
|
|
185
|
+
acc += gain[Math.min(n - 1, i + RAMP + 1)] - gain[Math.max(0, i - RAMP)];
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The microphone over [from, to) with the echo of `far` taken out. far and mic are Float64 arrays of one span, full scale = 1.
|
|
191
|
+
* state collects { leaves: [{ delay }], skipped, eIn, eOut }. */
|
|
192
|
+
function cleanSpan(far, mic, from, to, state) {
|
|
193
|
+
const len = to - from;
|
|
194
|
+
const plain = () => Float64Array.from(mic.subarray(from, to));
|
|
195
|
+
if (speaks(far, from, to) < MIN_ACTIVE_FRAMES) return plain();
|
|
196
|
+
const p = estimatePath(far, mic, Math.max(0, from - MARGIN), Math.min(far.length, to + MARGIN));
|
|
197
|
+
if (!p) return plain();
|
|
198
|
+
const echo = predict(far, p.h, from, to);
|
|
199
|
+
const trial = new Float64Array(len);
|
|
200
|
+
for (let i = 0; i < len; i++) trial[i] = mic[from + i] - echo[i];
|
|
201
|
+
// guard 1: parts of one window that disagree — some kept little of their energy, some kept a lot — mean the path changed
|
|
202
|
+
// inside it: cut the window in two. Parts that all keep about the same (no echo at all, or one steady poor fit) do not.
|
|
203
|
+
if (len >= 2 * MIN_PIECE) {
|
|
204
|
+
let lo = Infinity; let hi = -Infinity;
|
|
205
|
+
for (let q = from; q < to; q += PART) {
|
|
206
|
+
const k = kept(far, mic, trial, q, Math.min(to, q + PART), from);
|
|
207
|
+
if (k.frames < MIN_ACTIVE_FRAMES || !(k.eIn > 0)) continue;
|
|
208
|
+
const r = k.eOut / k.eIn; lo = Math.min(lo, r); hi = Math.max(hi, r);
|
|
209
|
+
}
|
|
210
|
+
if (hi > SPLIT_RATIO && hi - lo > SPLIT_SPREAD) {
|
|
211
|
+
const mid = from + Math.floor(len / 2 / FRAME) * FRAME;
|
|
212
|
+
const left = cleanSpan(far, mic, from, mid, state); const right = cleanSpan(far, mic, mid, to, state);
|
|
213
|
+
const joined = new Float64Array(len); joined.set(left, 0); joined.set(right, mid - from);
|
|
214
|
+
return joined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// guard 2 (R7b): in every 10 s stretch the result keeps less energy than the microphone, or the stretch is left as it was
|
|
218
|
+
const gain = new Float64Array(len).fill(1);
|
|
219
|
+
const chunks = [];
|
|
220
|
+
for (let q = from; q < to; q += CHECK) chunks.push([q, Math.min(to, q + CHECK)]);
|
|
221
|
+
let off = false;
|
|
222
|
+
for (const [a, b] of chunks) {
|
|
223
|
+
const k = kept(far, mic, trial, a, b, from);
|
|
224
|
+
if (k.frames && k.eOut > KEEP_RATIO * k.eIn) { gain.fill(0, a - from, b - from); off = true; state.skipped++; }
|
|
225
|
+
}
|
|
226
|
+
const g = off ? smooth(gain) : gain;
|
|
227
|
+
const out = new Float64Array(len);
|
|
228
|
+
for (let i = 0; i < len; i++) out[i] = mic[from + i] - g[i] * echo[i];
|
|
229
|
+
if (off) { // the ramps can let a little echo back in at an edge: a stretch that still got louder is cut hard
|
|
230
|
+
for (const [a, b] of chunks) {
|
|
231
|
+
const k = kept(far, mic, out, a, b, from);
|
|
232
|
+
if (k.frames && k.eOut > k.eIn) for (let i = a - from; i < b - from; i++) out[i] = mic[from + i];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const [a, b] of chunks) { const k = kept(far, mic, out, a, b, from); state.eIn += k.eIn; state.eOut += k.eOut; }
|
|
236
|
+
state.leaves.push({ delay: arrival(p.h) });
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The canceller over two tracks of n samples. readFar(from, count) and readMic(from, count) give Float64Array of that many
|
|
241
|
+
* samples (full scale = 1; zeros past the end); onChunk(at, Float64Array) receives each cleaned stretch in order. Returns
|
|
242
|
+
* { windows, skipped, reduction_db, delay_ms }. */
|
|
243
|
+
function cleanStream({ n, readFar, readMic, onChunk }) {
|
|
244
|
+
const state = { leaves: [], skipped: 0, eIn: 0, eOut: 0 };
|
|
245
|
+
let windows = 0;
|
|
246
|
+
for (let a = 0; a < n; a += WINDOW) {
|
|
247
|
+
const b = Math.min(n, a + WINDOW);
|
|
248
|
+
const c0 = Math.max(0, Math.floor((a - (L + MARGIN)) / FRAME) * FRAME); const c1 = Math.min(n, b + MARGIN);
|
|
249
|
+
const far = readFar(c0, c1 - c0); const mic = readMic(c0, c1 - c0);
|
|
250
|
+
onChunk(a, cleanSpan(far, mic, a - c0, b - c0, state), b - a);
|
|
251
|
+
windows++;
|
|
252
|
+
}
|
|
253
|
+
const delays = state.leaves.map((l) => l.delay).sort((x, y) => x - y);
|
|
254
|
+
return {
|
|
255
|
+
windows, fits: state.leaves.length, skipped: state.skipped,
|
|
256
|
+
reduction_db: state.eOut > 0 && state.eIn > 0 ? Math.round(10 * Math.log10(state.eIn / state.eOut) * 10) / 10 : 0,
|
|
257
|
+
delay_ms: delays.length ? Math.round(delays[delays.length >> 1] * 1000 / FS) : null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/* ── the replay file ──────────────────────────────────────────────────────────────────────────── */
|
|
262
|
+
const reportFile = (runtimeDir, id) => notesAudio.replayFile(runtimeDir, id).replace(/\.pcm$/, '.echo.json');
|
|
263
|
+
function report(runtimeDir, id) { try { return JSON.parse(fs.readFileSync(reportFile(runtimeDir, id), 'utf8')); } catch { return null; } }
|
|
264
|
+
|
|
265
|
+
function readSamples(fd, size, from, count) {
|
|
266
|
+
const out = new Float64Array(count);
|
|
267
|
+
if (fd === null) return out;
|
|
268
|
+
const bytes = Math.max(0, Math.min(count * 2, size - from * 2));
|
|
269
|
+
if (bytes <= 0) return out;
|
|
270
|
+
const buf = Buffer.alloc(bytes);
|
|
271
|
+
fs.readSync(fd, buf, 0, bytes, from * 2);
|
|
272
|
+
for (let i = 0; i < bytes / 2; i++) out[i] = buf.readInt16LE(i * 2) / 32768;
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Rebuild the replay file of a note from its two source files, with the echo taken out of the microphone. Only when the note
|
|
277
|
+
* kept its audio and heard both sources. The replay is replaced by a rename, and only if the sources did not grow meanwhile.
|
|
278
|
+
* `beforeCommit` is called just before that check; it is for the suites. */
|
|
279
|
+
function cleanReplay(runtimeDir, id, { beforeCommit } = {}) {
|
|
280
|
+
const began = Date.now();
|
|
281
|
+
if (!notesAudio.getKeep(runtimeDir)) return { ok: true, skipped: 'the audio is not kept for replay' };
|
|
282
|
+
const srcs = notesAudio.sources(runtimeDir, id);
|
|
283
|
+
const mic = srcs.find((s) => s.source === 'mic'); const meet = srcs.find((s) => s.source === 'meeting');
|
|
284
|
+
if (!mic || !meet) return { ok: true, skipped: 'only one source was heard, so there is no echo to take out' };
|
|
285
|
+
if (!notesAudio.info(runtimeDir, id)) return { ok: true, skipped: 'the note has no replay file' };
|
|
286
|
+
const out = notesAudio.replayFile(runtimeDir, id); const tmp = out + '.echo.tmp';
|
|
287
|
+
let fdM = null; let fdX = null; let fdO = null;
|
|
288
|
+
try {
|
|
289
|
+
fdM = fs.openSync(mic.file, 'r'); fdX = fs.openSync(meet.file, 'r'); fdO = fs.openSync(tmp, 'w', 0o600);
|
|
290
|
+
const n = Math.floor(Math.max(mic.bytes, meet.bytes) / 2);
|
|
291
|
+
const rep = cleanStream({
|
|
292
|
+
n,
|
|
293
|
+
readFar: (from, count) => readSamples(fdX, meet.bytes, from, count),
|
|
294
|
+
readMic: (from, count) => readSamples(fdM, mic.bytes, from, count),
|
|
295
|
+
onChunk: (at, cleaned, count) => {
|
|
296
|
+
const m = readSamples(fdX, meet.bytes, at, count);
|
|
297
|
+
const buf = Buffer.alloc(count * 2);
|
|
298
|
+
for (let i = 0; i < count; i++) {
|
|
299
|
+
const a = Math.max(-32768, Math.min(32767, Math.round(cleaned[i] * 32768)));
|
|
300
|
+
buf.writeInt16LE(notesAudio.softLimit(a + Math.round(m[i] * 32768)), i * 2);
|
|
301
|
+
}
|
|
302
|
+
fs.writeSync(fdO, buf, 0, buf.length, at * 2);
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
fs.closeSync(fdO); fdO = null;
|
|
306
|
+
if (beforeCommit) beforeCommit(); // the suites use this to make listening go on at the last moment
|
|
307
|
+
// the sources must be as they were: if listening went on meanwhile, the plain replay is the truth and this one is dropped
|
|
308
|
+
const now = notesAudio.sources(runtimeDir, id);
|
|
309
|
+
const same = now.length === srcs.length && now.every((s) => { const o = srcs.find((x) => x.source === s.source); return o && o.bytes === s.bytes; });
|
|
310
|
+
if (!same) { try { fs.unlinkSync(tmp); } catch { /* gone */ } return { ok: false, error: 'listening went on while the echo was taken out' }; }
|
|
311
|
+
fs.renameSync(tmp, out);
|
|
312
|
+
const result = { ok: true, at: new Date().toISOString(), ...rep, took_ms: Date.now() - began };
|
|
313
|
+
fs.writeFileSync(reportFile(runtimeDir, id), JSON.stringify(result), { mode: 0o600 });
|
|
314
|
+
return result;
|
|
315
|
+
} catch (e) {
|
|
316
|
+
try { fs.unlinkSync(tmp); } catch { /* none */ }
|
|
317
|
+
return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
|
|
318
|
+
} finally {
|
|
319
|
+
for (const fd of [fdM, fdX, fdO]) if (fd !== null) { try { fs.closeSync(fd); } catch { /* closed */ } }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const running = new Map(); // `${dir}|${id}` → the promise of a clean-up that is under way
|
|
324
|
+
/** cleanReplay on a worker thread. A second call for the same note while one runs gets the same promise. Never rejects. */
|
|
325
|
+
function cleanReplayInWorker(runtimeDir, id) {
|
|
326
|
+
const key = runtimeDir + '|' + id;
|
|
327
|
+
if (running.has(key)) return running.get(key);
|
|
328
|
+
const p = new Promise((resolve) => {
|
|
329
|
+
let done = false;
|
|
330
|
+
const w = new Worker(path.join(__dirname, 'notes-echo-worker.js'), { workerData: { runtimeDir, id } });
|
|
331
|
+
const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
|
|
332
|
+
const timer = setTimeout(() => { w.terminate(); try { fs.unlinkSync(notesAudio.replayFile(runtimeDir, id) + '.echo.tmp'); } catch { /* none */ } finish({ ok: false, error: 'it took too long' }); }, TIMEOUT_MS);
|
|
333
|
+
w.once('message', finish);
|
|
334
|
+
w.once('error', (e) => finish({ ok: false, error: String((e && e.message) || e).slice(0, 300) }));
|
|
335
|
+
w.once('exit', () => finish({ ok: false, error: 'the worker ended without a result' }));
|
|
336
|
+
}).finally(() => running.delete(key));
|
|
337
|
+
running.set(key, p);
|
|
338
|
+
return p;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = { cleanStream, cleanReplay, cleanReplayInWorker, report, reportFile, fft, WINDOW, FS };
|
|
@@ -66,6 +66,7 @@ const notes = require('./notes.js');
|
|
|
66
66
|
const whisper = require('./notes-whisper.js');
|
|
67
67
|
const notesRepo = require('./notes-repo.js');
|
|
68
68
|
const notesAudio = require('./notes-audio.js');
|
|
69
|
+
const notesEcho = require('./notes-echo.js');
|
|
69
70
|
const notesFinal = require('./notes-final.js');
|
|
70
71
|
const notesQwen = require('./notes-qwen.js');
|
|
71
72
|
const notesLinks = require('./notes-links.js');
|
|
@@ -376,6 +377,7 @@ function engineInfo(ctx, w) {
|
|
|
376
377
|
* while it is not ready, it is asked again every WISPR_RETRY_MS, up to WISPR_DEADLINE_MS after Stop. The workspace itself
|
|
377
378
|
* makes no request: the agent session does, with the connector the PM signed in to. On by default ("Fetch Wispr Flow
|
|
378
379
|
* notes" in the settings); PMOS_NOTES_WISPR (or ctx.notesWispr) 'off' turns it off on this machine. Never for a private note. */
|
|
380
|
+
const echoOff = (ctx) => String(ctx.notesEcho !== undefined ? ctx.notesEcho : process.env.PMOS_NOTES_ECHO || '') === 'off';
|
|
379
381
|
const finalOff = (ctx) => String(ctx.notesFinal !== undefined ? ctx.notesFinal : process.env.PMOS_NOTES_FINAL || '') === 'off';
|
|
380
382
|
let finalRunner = null; let finalDir = null;
|
|
381
383
|
function finalFor(ctx) {
|
|
@@ -391,6 +393,7 @@ function finalFor(ctx) {
|
|
|
391
393
|
const dir = ctx.runtimeDir;
|
|
392
394
|
const r = notes.setFinal(dir, { id, final });
|
|
393
395
|
if (!notesAudio.getKeep(dir)) notesAudio.removeSources(dir, id);
|
|
396
|
+
else if (!echoOff(ctx)) notesEcho.cleanReplayInWorker(dir, id); // best effort, off the main thread: on any failure the replay keeps its plain mix
|
|
394
397
|
if (!r.ok) return;
|
|
395
398
|
const cur = notes.read(dir, id);
|
|
396
399
|
const w = cur.ok && cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.114.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -3608,18 +3608,31 @@
|
|
|
3608
3608
|
.td-day__mark { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 6px 10px; border: 1px solid var(--border); background: var(--muted); font-size: 14px; }
|
|
3609
3609
|
.td-day__mark[data-day-mark-state="done"] { border-color: var(--foreground); }
|
|
3610
3610
|
.td-day__ready { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 12px; }
|
|
3611
|
-
.td-day__prep { display: grid; gap:
|
|
3611
|
+
.td-day__prep { display: grid; gap: 10px; }
|
|
3612
3612
|
.td-day__ended { margin: 0; padding: 8px 12px; border-left: 2px solid var(--foreground); background: var(--muted); font-size: 14px; }
|
|
3613
3613
|
/* an ended meeting keeps its prep, quieter: the steps stay readable, the past is not a call to act */
|
|
3614
|
-
.td-day__detail:has(.td-day__ended) .td-day__prep { background: var(--muted); }
|
|
3614
|
+
.td-day__detail:has(.td-day__ended) .td-day__prep { padding: 10px 12px; background: var(--muted); }
|
|
3615
3615
|
.td-day__fold { border-top: 1px solid var(--border); padding-top: 6px; }
|
|
3616
3616
|
.td-day__fold > summary { cursor: pointer; font-size: 12px; color: var(--muted-foreground); }
|
|
3617
3617
|
.td-day__fold[open] > summary { margin-bottom: 6px; }
|
|
3618
|
-
.td-day__fold > summary:focus-visible, .td-day__fold[open] > summary:focus-visible, .td-day__ref > summary:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
|
|
3619
|
-
@media (pointer: coarse) { .td-day__fold > summary, .td-day__fold[open] > summary, .td-day__ref > summary { min-height: 44px; } }
|
|
3618
|
+
.td-day__fold > summary:focus-visible, .td-day__fold[open] > summary:focus-visible, .td-day__ref > summary:focus-visible, .td-day__details > summary:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
|
|
3619
|
+
@media (pointer: coarse) { .td-day__fold > summary, .td-day__fold[open] > summary, .td-day__ref > summary, .td-day__details > summary { min-height: 44px; } }
|
|
3620
3620
|
.td-day__fold-head { justify-self: start; }
|
|
3621
3621
|
.td-day__steprow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
3622
|
-
.td-day__step2:has(.td-day__ref), .td-day__step2:has(.td-day__notebox) { flex-direction: column; align-items: stretch; }
|
|
3622
|
+
.td-day__step2:has(.td-day__ref), .td-day__step2:has(.td-day__notebox), .td-day__step2:has(.td-day__acts) { flex-direction: column; align-items: stretch; }
|
|
3623
|
+
/* the meeting detail with one focus (today-meeting-focus): one open step, the rest one line */
|
|
3624
|
+
.td-day__meta { text-transform: none; letter-spacing: 0; font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
3625
|
+
.td-day__purpose { max-width: 64ch; text-wrap: pretty; }
|
|
3626
|
+
.td-day__step2[data-cur="false"] { border-color: transparent; background: transparent; padding-block: 2px; }
|
|
3627
|
+
.td-day__step2[data-cur="true"][data-done="false"] { border-color: var(--foreground); }
|
|
3628
|
+
.td-day__stepopen { margin-left: auto; }
|
|
3629
|
+
.td-day__free[data-tight="true"] { color: var(--warning); }
|
|
3630
|
+
.td-day__free { font-variant-numeric: tabular-nums; color: var(--muted-foreground); }
|
|
3631
|
+
.td-day__foot { position: relative; }
|
|
3632
|
+
.td-day__foot > [data-day="more-menu"] { position: absolute; right: 0; top: 2px; }
|
|
3633
|
+
.td-day__details > summary { padding-right: 96px; line-height: 28px; }
|
|
3634
|
+
.td-day__notes--quiet, .td-day__notes--live { border: 0; padding: 0; background: none; }
|
|
3635
|
+
.td-day__prepfold { border-top: 0; padding-top: 0; }
|
|
3623
3636
|
.td-day__ref { display: grid; gap: 4px; padding: 6px 8px; background: var(--muted); font-size: 14px; }
|
|
3624
3637
|
.td-day__ref > summary { display: flex; justify-content: space-between; gap: 8px; cursor: pointer; font-weight: 600; color: var(--foreground); }
|
|
3625
3638
|
.td-day__reflines { margin: 0; padding-left: 18px; display: grid; gap: 2px; color: var(--foreground); line-height: 1.45; }
|
|
@@ -87,7 +87,15 @@ export function notesBlock(e, opts = {}) {
|
|
|
87
87
|
el('div', { class: 'td-day__acts' }, button({ label: 'Open notes', size: 'sm', attrs: { 'data-notes-open': n.id }, onClick: () => openDock(n.id) })));
|
|
88
88
|
}
|
|
89
89
|
if (!notesKnown()) return null;
|
|
90
|
-
|
|
90
|
+
// opts.mode (the meeting detail, today-meeting-focus F7): 'live' leads with Take notes as the one primary button and one line;
|
|
91
|
+
// 'quiet' is the button alone, with no heading or help text. Without a mode the block reads as it always did.
|
|
92
|
+
const mode = ended ? null : opts.mode || null;
|
|
93
|
+
const take = button({ label: 'Take notes', variant: mode === 'quiet' ? 'ghost' : ended ? undefined : 'primary', size: 'sm', attrs: { 'data-notes-take': e.id || '' }, onClick: () => takeNotes(e, opts, take) });
|
|
94
|
+
if (mode) {
|
|
95
|
+
return el('div', { class: 'td-day__notes td-day__notes--' + mode, 'data-notes-block': 'none' },
|
|
96
|
+
mode === 'live' && opts.liveText ? el('p', { class: 'td-day__note' }, opts.liveText) : null,
|
|
97
|
+
el('div', { class: 'td-day__acts' }, take));
|
|
98
|
+
}
|
|
91
99
|
return el('div', { class: 'td-day__notes', 'data-notes-block': 'none' },
|
|
92
100
|
el('h4', null, 'Meeting notes'),
|
|
93
101
|
el('p', { class: 'td-day__note' }, ended ? 'No notes were taken in PMOS for this meeting.' : 'Take notes when the meeting starts. PMOS listens at once and keeps the note at the bottom right. It is linked to this event' + (opts.initiative ? ' and its initiative.' : '.')),
|
|
@@ -298,22 +298,43 @@ function keyBlock(e) {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
/** The series of a meeting: how often it met, what is to come, and the last time, folded. Null when unknown. */
|
|
301
|
-
export function memoryBlock(id) {
|
|
301
|
+
export function memoryBlock(id, { compact = false } = {}) {
|
|
302
302
|
const f = findEvent(id);
|
|
303
303
|
if (!f) return null;
|
|
304
304
|
const s = f.data.series[f.e.key];
|
|
305
305
|
if (!s) return null;
|
|
306
306
|
const last = s.last && s.last.date !== f.day.date ? s.last : null;
|
|
307
|
+
// compact (the meeting detail's Details, today-meeting-focus F5): no heading and no key presses; the presses are in More
|
|
308
|
+
const said = compact && f.e.mark ? A.key[(f.e.imp ? 'marked' : 'not') + f.e.mark] : null;
|
|
307
309
|
return el('div', { class: 'td-day__notes', 'data-ag': 'memory' },
|
|
308
|
-
el('h4', null, A.means),
|
|
310
|
+
compact ? null : el('h4', null, A.means),
|
|
309
311
|
el('p', null, A.kindWord[f.e.kind] + '. ' + (s.count > 1 ? A.series(s.past, s.coming) : A.once)),
|
|
310
312
|
...(f.e.mark ? [] : whyLines(f.e)).map((t) => el('p', null, t)),
|
|
311
|
-
keyBlock(f.e),
|
|
313
|
+
compact ? (said ? el('p', { class: 'td-day__note', 'data-ag': 'key', 'data-ag-key': f.e.imp ? 'true' : 'false' }, said) : null) : keyBlock(f.e),
|
|
312
314
|
last ? el('details', { class: 'td-ag__last', 'data-ag': 'last' }, el('summary', null, A.lastTime(dayLabel(last.date))),
|
|
313
315
|
last.notes ? el('p', null, last.notes) : el('p', { class: 'td-day__note' }, A.noLast),
|
|
314
316
|
last.open && last.open.length ? el('ul', { class: 'td-day__chk' }, ...last.open.map((x) => el('li', null, x))) : null) : null);
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
/** What the meeting detail's one line needs: how many people join, and whether the meeting is key. Null when unknown. */
|
|
320
|
+
export function meetingFacts(id) {
|
|
321
|
+
const f = findEvent(id);
|
|
322
|
+
return f ? { people: f.e.attendees || 0, key: !!f.e.imp } : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The key-mark presses of a meeting as menu items for More (the same presses as keyBlock, same marks). */
|
|
326
|
+
export function keyItems(id) {
|
|
327
|
+
const f = findEvent(id);
|
|
328
|
+
if (!f) return [];
|
|
329
|
+
const e = f.e;
|
|
330
|
+
const K = A.key;
|
|
331
|
+
const it = (label, scope, on, mark) => ({ label, attrs: { 'data-ag-mark': mark }, onSelect: () => setMark(e, scope, on) });
|
|
332
|
+
const items = e.mark ? [it(K.clear, e.mark, null, 'clear')]
|
|
333
|
+
: e.imp ? [it(K.unmark, 'one', false, 'not-one'), e.repeats ? it(K.unmarkAll, 'series', false, 'not-series') : null]
|
|
334
|
+
: [it(K.mark, 'one', true, 'one'), e.repeats ? it(K.markAll, 'series', true, 'series') : null];
|
|
335
|
+
return items.filter(Boolean);
|
|
336
|
+
}
|
|
337
|
+
|
|
317
338
|
export function meetingDetail(ctx, id, h) {
|
|
318
339
|
const f = findEvent(id);
|
|
319
340
|
if (!f) return null;
|
|
@@ -385,6 +385,19 @@ export const COPY = {
|
|
|
385
385
|
hold: 'Waiting on…',
|
|
386
386
|
notMine: 'Not mine',
|
|
387
387
|
skip: 'Skip prep',
|
|
388
|
+
// the meeting detail with one focus (initiative today-meeting-focus, PRD F1 to F7)
|
|
389
|
+
focus: {
|
|
390
|
+
more: 'More', details: 'Details', openStep: 'Open this step',
|
|
391
|
+
press: { read: 'I have read it', do: 'Mark done', note: 'Done' },
|
|
392
|
+
free: (min) => `${min} min free`,
|
|
393
|
+
before: (d, n, at) => `${d} of ${n} before ${at}`,
|
|
394
|
+
remindAt: (when, at) => `Remind me ${when} · ${at}`,
|
|
395
|
+
skipWhy: (why) => `Skip prep · ${why}`,
|
|
396
|
+
people: (n) => (n === 1 ? '1 person' : `${n} people`),
|
|
397
|
+
key: '★ Key',
|
|
398
|
+
live: 'The meeting has started. PMOS listens when you start the note, and links it to this event.',
|
|
399
|
+
prepFold: (d, n) => `Prep · ${d} of ${n} done`,
|
|
400
|
+
},
|
|
388
401
|
snoozeTo: { hour: 'In 1 hour', later: 'Later today, 16:00', due: 'Before it is due', end: 'End of the day, 17:30', m30: '30 min before it', m15: '15 min before it' },
|
|
389
402
|
holdWho: 'Who or what you wait on',
|
|
390
403
|
holdHint: 'For example: Raj, or the cost model',
|
|
@@ -31,7 +31,7 @@ import { FIXTURE } from './state.js';
|
|
|
31
31
|
import { COPY } from './today-copy.js';
|
|
32
32
|
import { goalRows, goalAdd, goalDetail, addGoal, goalsSig } from './today-goals.js';
|
|
33
33
|
import { peopleLinks } from './people-link.js';
|
|
34
|
-
import { agendaPanel, meetingDetail, memoryBlock, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
|
|
34
|
+
import { agendaPanel, meetingDetail, memoryBlock, meetingFacts, keyItems, refreshAgenda, agendaSig, onAgenda } from './today-agenda.js';
|
|
35
35
|
import { notesBlock, onNotes } from './notes-links.js';
|
|
36
36
|
|
|
37
37
|
const D = COPY.day;
|
|
@@ -62,7 +62,7 @@ const ui = {
|
|
|
62
62
|
sel: null, mtab: 'all', openSrc: null, showIgn: {}, showIgnDigests: false,
|
|
63
63
|
revising: new Set(), notes: new Map(), siteDraft: '', placeDraft: {}, busy: new Set(), scroll: false,
|
|
64
64
|
// today-act-prepare: the folded groups the PM opened, the item whose hold form is open, and its draft
|
|
65
|
-
groupsOpen: new Set(), holding: null, holdDraft: { who: '', when: 'h2' }, compose: new Map(), verdict: new Map(),
|
|
65
|
+
cur: new Map(), groupsOpen: new Set(), holding: null, holdDraft: { who: '', when: 'h2' }, compose: new Map(), verdict: new Map(),
|
|
66
66
|
};
|
|
67
67
|
let rerender = () => {};
|
|
68
68
|
let notesSub = null; // the redraw when the meeting notes list is read (pm/notes-links.js)
|
|
@@ -670,16 +670,35 @@ function markBanner(ctx, id, mark, prep) {
|
|
|
670
670
|
}
|
|
671
671
|
/** The steps the PM checks off. A step the brief already marked done stays done. A step that reads a source shows what
|
|
672
672
|
* the source says under it (PRD X2), and a note step holds the PM's own note (PRD F3), so no other file needs opening. */
|
|
673
|
-
function stepChecks(ctx, id, steps, { refs = [], mark = null } = {}) {
|
|
673
|
+
function stepChecks(ctx, id, steps, { refs = [], mark = null, focus = false } = {}) {
|
|
674
674
|
if (!steps.length) return null;
|
|
675
|
+
const F = A.focus;
|
|
676
|
+
// focus (the meeting detail, today-meeting-focus F2): only one step is open, the PM's own pick or else the first step left
|
|
677
|
+
const want = focus ? ui.cur.get(id) : null;
|
|
678
|
+
const cur = focus ? steps.find((x) => x.t === want) || steps.find((x) => !x.done) || null : null;
|
|
675
679
|
return el('ul', { class: 'td-day__steps2', 'data-day': 'steps' }, ...steps.map((st) => {
|
|
680
|
+
const isCur = !focus || (!!cur && st.t === cur.t);
|
|
676
681
|
const f = field({ kind: 'check', label: st.t, value: !!st.done, attrs: { 'data-day-step': st.t, disabled: st.fixed ? true : null },
|
|
677
682
|
onCommit: (v) => markPress(ctx.ws, id, { action: 'step', step: st.t, done: !!v }) });
|
|
678
683
|
const r = Number.isInteger(st.ref) ? refs[st.ref] : null;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
st.note ?
|
|
684
|
+
const noteEl = st.note && isCur ? noteBox(ctx, id, mark) : null;
|
|
685
|
+
let press = null;
|
|
686
|
+
if (focus && isCur && !st.done) {
|
|
687
|
+
press = button({ label: F.press[st.note ? 'note' : r ? 'read' : 'do'], variant: 'primary', size: 'sm', attrs: { 'data-day-step-press': st.t, onMousedown: (ev) => ev.preventDefault() }, onClick: async () => {
|
|
688
|
+
// a note typed in the box is saved first, in this press, so the box losing focus does not redraw the card under the pointer
|
|
689
|
+
const ta = noteEl && noteEl.querySelector('textarea');
|
|
690
|
+
if (ta && ta.value !== ((mark && mark.note) || '')) await markPress(ctx.ws, id, { action: 'note', text: ta.value }, A.noteSaved);
|
|
691
|
+
ui.cur.delete(id);
|
|
692
|
+
await markPress(ctx.ws, id, { action: 'step', step: st.t, done: true });
|
|
693
|
+
} });
|
|
694
|
+
}
|
|
695
|
+
const opener = focus && !isCur && (r || st.note)
|
|
696
|
+
? iconButton({ icon: 'chevron-down', ariaLabel: `${F.openStep}: ${st.t}`, size: 'sm', attrs: { 'data-day-step-open': st.t, class: 'td-day__stepopen' }, onClick: () => { ui.cur.set(id, st.t); rerender(); } }) : null;
|
|
697
|
+
return el('li', { class: 'td-day__step2', 'data-done': st.done ? 'true' : 'false', 'data-cur': focus ? String(isCur) : null },
|
|
698
|
+
el('div', { class: 'td-day__steprow' }, f, st.who ? el('span', { class: 'td-day__who' }, D.who[st.who] || st.who) : null, opener),
|
|
699
|
+
r && isCur ? refBlock(r, { open: !st.done, step: true }) : null,
|
|
700
|
+
noteEl,
|
|
701
|
+
press ? el('div', { class: 'td-day__acts' }, press) : null);
|
|
683
702
|
}));
|
|
684
703
|
}
|
|
685
704
|
/** One source in a few lines, in the card: what it says, and a small link to the source for when the PM wants more. */
|
|
@@ -838,27 +857,57 @@ function freeBefore(m, e) {
|
|
|
838
857
|
return Math.max(0, Math.round((e.s - m.now - busy) / MIN_MS));
|
|
839
858
|
}
|
|
840
859
|
|
|
841
|
-
/** A meeting's prep (
|
|
842
|
-
*
|
|
843
|
-
|
|
860
|
+
/** A meeting's prep (today-act-prepare A1, C1, M1, M6, D2; today-meeting-focus F1 to F3): the prep sentence, one line of how
|
|
861
|
+
* ready the PM is, the Before steps with only the first open one expanded, and the one primary press that fits. The
|
|
862
|
+
* other presses are in More. `fold` (the meeting has started) folds all of it under one line. */
|
|
863
|
+
function prepBlock(ctx, e, { fold = false } = {}) {
|
|
844
864
|
const { m } = ctx;
|
|
865
|
+
const F = A.focus;
|
|
845
866
|
const steps = e.before.map((st) => ({ t: String(st.t || ''), who: st.who, done: st.done, ref: st.ref, note: !!st.note, fixed: !!(e.steps && e.steps.before && e.steps.before.find((x) => x.t === st.t && x.done)) }));
|
|
846
867
|
const refs = Array.isArray(e.refs) ? e.refs : [];
|
|
847
868
|
const d = steps.filter((x) => x.done).length; const total = steps.length;
|
|
848
869
|
const left = total - d;
|
|
849
870
|
const free = freeBefore(m, e);
|
|
850
|
-
const
|
|
871
|
+
const tight = !!left && free < left * 15;
|
|
851
872
|
const open = !e.mark || !e.mark.state;
|
|
852
873
|
const link = Array.isArray(e.links) ? e.links.find((l) => isUrl(l.url)) : null;
|
|
853
|
-
|
|
854
|
-
el('div', { class: 'td-day__jobtop' }, verbChip('prepare'), e.ended ? null : dueChip(m, e.mark, e.s, true)),
|
|
874
|
+
const body = el('div', { class: 'td-day__prep', 'data-day': 'prep' },
|
|
855
875
|
e.prep ? el('p', { class: 'td-day__todo', 'data-day': 'todo' }, e.prep) : null,
|
|
856
876
|
markBanner(ctx, e.id, e.mark, true),
|
|
857
|
-
total ? el('div', { class: 'td-day__ready', 'data-day': 'readiness' }, meter({ done: d, total, label:
|
|
858
|
-
|
|
877
|
+
total ? el('div', { class: 'td-day__ready', 'data-day': 'readiness' }, meter({ done: d, total, label: F.before(d, total, e.time) }),
|
|
878
|
+
e.ended ? null : left ? el('span', { class: 'td-day__free', 'data-tight': String(tight), title: tight ? A.fit.short(left, free) : A.fit.time(left, free) }, F.free(free)) : el('span', { class: 'td-day__dim' }, A.fit.ok)) : null,
|
|
879
|
+
stepChecks(ctx, e.id, steps, { refs, mark: e.mark, focus: true }),
|
|
859
880
|
refsBlock(refs, steps),
|
|
860
|
-
open && link && left && !e.ended ? el('div', { class: 'td-day__acts', 'data-day': 'cta' },
|
|
861
|
-
|
|
881
|
+
open && link && left && !e.ended ? el('div', { class: 'td-day__acts', 'data-day': 'cta' }, extLink((link.label || link.url) + ' ↗', link.url, { 'data-day-link': 'prep' })) : null,
|
|
882
|
+
open && !left ? el('div', { class: 'td-day__acts', 'data-day': 'marks' }, button({ label: A.ready, variant: 'primary', size: 'sm', icon: 'check', disabled: ui.busy.has('mark:' + e.id), attrs: { 'data-day-mark': 'done' },
|
|
883
|
+
onClick: () => markPress(ctx.ws, e.id, { action: 'done' }, A.did.ready(e.title)) })) : null);
|
|
884
|
+
return fold ? el('details', { class: 'td-day__fold td-day__prepfold', 'data-day': 'prep-fold' }, el('summary', null, F.prepFold(d, total)), body) : body;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** The presses that are rarely needed, in one menu (today-meeting-focus F4): Mark ready while steps are left, Remind me, Skip
|
|
888
|
+
* prep, the key mark and, from `extra`, Add as a goal. Each press writes the same record, with the same toast and Undo, as before. */
|
|
889
|
+
function moreMenu(ctx, e, extra) {
|
|
890
|
+
const { m, ws } = ctx;
|
|
891
|
+
const F = A.focus;
|
|
892
|
+
const st = e.mark && e.mark.state;
|
|
893
|
+
const items = [];
|
|
894
|
+
if (e.hadPrep) {
|
|
895
|
+
const left = e.before.filter((x) => !x.done).length;
|
|
896
|
+
if (st !== 'done' && left > 0) items.push({ label: A.ready, attrs: { 'data-day-mark': 'done' }, onSelect: () => markPress(ws, e.id, { action: 'done' }, A.did.ready(e.title)) });
|
|
897
|
+
for (const [l, t] of snoozeTimes(m, { start: e.s })) {
|
|
898
|
+
items.push({ label: F.remindAt(l, hhmm(t)), attrs: { 'data-day-mark': 'snooze', 'data-day-snooze': hhmm(t) },
|
|
899
|
+
onSelect: () => markPress(ws, e.id, { action: 'snooze', until: new Date(t).toISOString() }, A.did.snooze(hhmm(t))) });
|
|
900
|
+
}
|
|
901
|
+
if (st !== 'dismissed') for (const w of ['nothing', 'elsewhere']) items.push({ label: F.skipWhy(A.why[w]), attrs: { 'data-day-mark': 'dismiss', 'data-day-why': w },
|
|
902
|
+
onSelect: () => markPress(ws, e.id, { action: 'dismiss', why: w }, A.did.dismiss(A.why[w])) });
|
|
903
|
+
}
|
|
904
|
+
const rest = [...keyItems(e.id), ...extra];
|
|
905
|
+
if (items.length && rest.length) items.push({ sep: true });
|
|
906
|
+
items.push(...rest);
|
|
907
|
+
if (!items.length) return null;
|
|
908
|
+
const b = button({ label: F.more, size: 'sm', variant: 'ghost', iconEnd: 'chevron-down', attrs: { 'data-day': 'more-menu', 'aria-haspopup': 'menu' },
|
|
909
|
+
onClick: () => menu({ anchor: b, ariaLabel: F.more, items }) });
|
|
910
|
+
return b;
|
|
862
911
|
}
|
|
863
912
|
|
|
864
913
|
/* ── the right column ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
@@ -878,6 +927,7 @@ function stepItem(st) {
|
|
|
878
927
|
|
|
879
928
|
function eventDetail(ctx, e) {
|
|
880
929
|
const { m } = ctx;
|
|
930
|
+
const F = A.focus;
|
|
881
931
|
const n = e.goal ? m.goalNo.get(e.goal) : null;
|
|
882
932
|
const g = n ? m.allGoals[n - 1] : null;
|
|
883
933
|
const ini = e.initiative && e.initiative.id ? e.initiative : null;
|
|
@@ -885,6 +935,8 @@ function eventDetail(ctx, e) {
|
|
|
885
935
|
const notes = e.notes || {};
|
|
886
936
|
const w = notes.wispr || null;
|
|
887
937
|
const mail = notes.mail || null;
|
|
938
|
+
const started = Number.isFinite(e.s) && e.s <= m.now && !e.ended;
|
|
939
|
+
const facts = meetingFacts(e.id);
|
|
888
940
|
const mailBlock = mail ? el('div', { class: 'td-day__notes', 'data-day': 'mail' }, el('div', { class: 'td-day__src' }, D.mail, ' · ', mail.from || '', ' ', isUrl(mail.link) ? el('a', { href: mail.link, target: '_blank', rel: 'noopener noreferrer' }, D.openLink) : null),
|
|
889
941
|
el('p', null, el('b', null, (mail.subject || '') + '. '), mail.gist || '')) : null;
|
|
890
942
|
const after = e.ended ? el('div', { class: 'td-day__notes', 'data-day': 'notes' }, el('h4', null, D.notesTitle),
|
|
@@ -893,29 +945,35 @@ function eventDetail(ctx, e) {
|
|
|
893
945
|
list(D.decisions, (w.decisions || []).map(String), 'td-day__chk--ok'),
|
|
894
946
|
list(D.actions, (w.actions || []).map((a) => (typeof a === 'string' ? a : stepItem(a))))] : note(D.noWispr),
|
|
895
947
|
mailBlock || note(D.noMail)) : null;
|
|
896
|
-
const tie = g ? el('p', { class: 'td-day__goalline', 'data-day': 'tie' }, D.countsToward(n, g.text))
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
948
|
+
const tie = g ? el('p', { class: 'td-day__goalline', 'data-day': 'tie' }, D.countsToward(n, g.text)) : null;
|
|
949
|
+
const addGoalItem = !g && m.allGoals.length < m.maxGoals
|
|
950
|
+
? [{ label: D.addAsGoal, attrs: { 'data-day-act': 'add-goal' }, onSelect: async () => { if (await addGoal(ctx, e.title, ini ? ini.id : null)) toast({ text: D.goalAdded }); } }] : [];
|
|
951
|
+
// the one line under the title: the time, how many people join, and a star for a key meeting (F5)
|
|
952
|
+
const meta = [`${e.time}${e.end ? '–' + hhmm(e.end) : ''}`, facts && facts.people ? F.people(facts.people) : null, facts && facts.key ? F.key : null].filter(Boolean).join(' · ');
|
|
953
|
+
const duringAfter = [list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem))];
|
|
954
|
+
const links = Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null;
|
|
955
|
+
const details = [
|
|
903
956
|
e.with ? peopleLinks(ctx.ws, e.with, { 'data-day': 'people' }) : null,
|
|
904
|
-
e.
|
|
905
|
-
e.hadPrep ? prepBlock(ctx, e) : null,
|
|
957
|
+
memoryBlock(e.id, { compact: true }),
|
|
906
958
|
ini ? el('div', { class: 'td-day__ini', 'data-day': 'initiative' }, el('span', null, D.initiative + ': ', el('b', { class: 'td-day__mono' }, ini.id), ini.why ? el('span', { class: 'td-day__dim' }, ' (' + D.suggested(ini.why) + ')') : null),
|
|
907
959
|
ctx.openIni ? button({ label: D.openInitiative, size: 'sm', variant: 'ghost', attrs: { 'data-day-act': 'open-initiative' }, onClick: () => ctx.openIni(ini.id) }) : null) : null,
|
|
908
960
|
tie,
|
|
909
|
-
|
|
961
|
+
duringAfter,
|
|
962
|
+
e.ended ? null : mailBlock,
|
|
963
|
+
links,
|
|
964
|
+
];
|
|
965
|
+
const more = moreMenu(ctx, e, addGoalItem);
|
|
966
|
+
return [
|
|
967
|
+
el('div', { class: 'td-day__dhead' }, el('span', { class: 'td-day__kind td-day__meta', 'data-day': 'meta' }, meta), el('h3', null, e.title)),
|
|
968
|
+
e.ended ? el('p', { class: 'td-day__ended', 'data-day': 'ended', role: 'status' }, pill(D.pill.ended, ''), ' ', A.endedAt(hhmm(e.end || e.f))) : null,
|
|
969
|
+
e.purpose ? el('p', { class: 'td-day__purpose', 'data-day': 'purpose' }, e.purpose) : null,
|
|
970
|
+
started ? notesBlock(e, { ws: ctx.ws, initiative: ini, mode: 'live', liveText: F.live }) : null,
|
|
971
|
+
e.hadPrep ? prepBlock(ctx, e, { fold: started }) : null,
|
|
910
972
|
after,
|
|
911
|
-
notesBlock(e, { ws: ctx.ws, initiative: ini }),
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
? el('details', { class: 'td-day__fold', 'data-day': 'more' }, el('summary', null, A.moreMeeting((steps.during || []).length, (steps.after || []).length)),
|
|
916
|
-
list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), e.ended ? null : mailBlock) : null,
|
|
917
|
-
!e.hadPrep ? [list(D.phases.during, (steps.during || []).map(stepItem)), list(D.phases.after, (steps.after || []).map(stepItem)), e.ended ? null : mailBlock] : null,
|
|
918
|
-
Array.isArray(e.links) && e.links.length ? el('div', { class: 'td-day__acts', 'data-day': 'links' }, ...e.links.map((l) => extLink((l.label || l.url) + ' ↗', l.url))) : null,
|
|
973
|
+
started ? null : notesBlock(e, { ws: ctx.ws, initiative: ini, mode: 'quiet' }),
|
|
974
|
+
el('div', { class: 'td-day__foot', 'data-day': 'foot' },
|
|
975
|
+
el('details', { class: 'td-day__fold td-day__details', 'data-day': 'details' }, el('summary', null, F.details), ...details),
|
|
976
|
+
more),
|
|
919
977
|
];
|
|
920
978
|
}
|
|
921
979
|
|
|
@@ -1281,6 +1339,6 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
|
1281
1339
|
|
|
1282
1340
|
export function daySig(ws) {
|
|
1283
1341
|
const c = cache.get(ws) || {};
|
|
1284
|
-
return JSON.stringify([forced, ui.open, ui.section, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
|
|
1342
|
+
return JSON.stringify([forced, ui.open, ui.section, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], [...ui.groupsOpen], [...ui.cur], ui.holding, ui.holdDraft.when, [...ui.verdict], c.error, runs.get(ws) || null, [...seen].length])
|
|
1285
1343
|
+ dataSig(c.data) + goalsSig() + agendaSig();
|
|
1286
1344
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
|
|
2
|
+
"_comment": "meeting-notes Runs 1 to 10: the Notes engine and routes (notes.test.js), local Whisper, line labels and repository checks (notes-listen.test.js), the final pass after Stop and the Wispr Flow fetch (notes-final.test.js), Qwen3-ASR kept loaded (notes-qwen.test.js), and the Notes view in a browser (pm-notes-test.js), the echo canceller for the replay (notes-echo.test.js, notes-audio-quality Run 2), plus the note pop-up patched in place while it listens (pm-note-dock-test.js).",
|
|
3
3
|
"modules": [
|
|
4
4
|
"notes.test.js",
|
|
5
5
|
"notes-listen.test.js",
|
|
6
6
|
"notes-final.test.js",
|
|
7
|
-
"notes-qwen.test.js"
|
|
7
|
+
"notes-qwen.test.js",
|
|
8
|
+
"notes-echo.test.js"
|
|
8
9
|
],
|
|
9
10
|
"panels": [
|
|
10
11
|
"pm-notes-test.js",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"notes-listen.test.js": 10,
|
|
17
18
|
"notes-final.test.js": 6,
|
|
18
19
|
"notes-qwen.test.js": 4,
|
|
19
|
-
"pm-note-dock-test.js": 20
|
|
20
|
+
"pm-note-dock-test.js": 20,
|
|
21
|
+
"notes-echo.test.js": 25
|
|
20
22
|
}
|
|
21
23
|
}
|