create-pmos 0.115.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +2 -0
  7. package/workspace/guide/today.md +21 -12
  8. package/workspace/lib/audio-resample.js +76 -0
  9. package/workspace/lib/brief.js +2 -2
  10. package/workspace/lib/day-marks.js +29 -9
  11. package/workspace/lib/goals.js +68 -10
  12. package/workspace/lib/notes-audio.js +62 -20
  13. package/workspace/lib/notes-echo.js +32 -14
  14. package/workspace/lib/notes-hires.js +94 -0
  15. package/workspace/lib/notes-polish.js +2 -3
  16. package/workspace/lib/routes-notes.js +9 -1
  17. package/workspace/notes-test-signals.js +65 -0
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/audio-capture.js +20 -6
  21. package/workspace/public/pm/components.css +39 -11
  22. package/workspace/public/pm/today-agenda.js +27 -53
  23. package/workspace/public/pm/today-copy.js +34 -15
  24. package/workspace/public/pm/today-day.js +98 -76
  25. package/workspace/public/pm/today-goals.js +159 -15
  26. package/workspace/public/pm/today-jobs.js +84 -0
  27. package/workspace/public/pm/today-mentions.js +50 -0
  28. package/workspace/public/pm/view-notes.js +15 -4
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-lists/meeting-notes.json +5 -3
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -28,11 +28,16 @@ const RATE = 16000;
28
28
  const BYTES_PER_MS = (RATE * 2) / 1000;
29
29
  const ON_TIME_MS = 1500; // a chunk this close to where its source left off is placed straight after it
30
30
  const MAX_BYTES = RATE * 2 * 60 * 60 * 6; // six hours; a longer meeting keeps its first six hours
31
+ const HI_RATE = 24000; // the replay may be stored at this rate (D92 as amended 2026-10-08)
32
+ const HI_BYTES_PER_MS = (HI_RATE * 2) / 1000;
33
+ const HI_MAX_BYTES = HI_RATE * 2 * 60 * 60 * 6;
31
34
  const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
32
35
 
33
36
  const dirOf = (runtimeDir) => path.join(runtimeDir, 'notes', 'recordings');
34
37
  const fileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.pcm');
35
38
  const SOURCES = ['mic', 'meeting'];
39
+ const hiFileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.hi.pcm');
40
+ const sideFileOf = (runtimeDir, id, suffix) => path.join(dirOf(runtimeDir), id + suffix);
36
41
  const cleanFileOf = (runtimeDir, id) => path.join(dirOf(runtimeDir), id + '.clean.pcm');
37
42
  const sourceFileOf = (runtimeDir, id, source) => path.join(dirOf(runtimeDir), id + '.' + source + '.pcm');
38
43
  const settingsFile = (runtimeDir) => path.join(runtimeDir, 'notes', 'audio-settings.json');
@@ -75,7 +80,7 @@ function softLimit(x) {
75
80
  /** Add one chunk at meeting time `t`. Samples already there (the other source) are added to, then limited by softLimit. With
76
81
  * `mixed: false` only the source's own file is written: the PM keeps no audio for replay, and the final pass deletes it when done.
77
82
  * The per-source files are never limited: they are what the transcription reads. */
78
- function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
83
+ function write(runtimeDir, { id, source, pcm, pcm24 = null, t, mixed = true }) {
79
84
  if (!ID_RE.test(String(id || '')) || !pcm || !pcm.length || pcm.length % 2) return { ok: false, error: 'nothing to keep' };
80
85
  const f = fileOf(runtimeDir, id);
81
86
  const key = f + '|' + source;
@@ -86,7 +91,10 @@ function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
86
91
  if (at >= MAX_BYTES) return { ok: true, kept: false };
87
92
  const len = Math.min(pcm.length, MAX_BYTES - at);
88
93
  fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
89
- if (mixed) {
94
+ // With 24 kHz audio the replay is the 24 kHz mix and no 16 kHz mix is written; the 16 kHz source files are written either way
95
+ const hiOk = mixed && pcm24 && pcm24.length && pcm24.length % 2 === 0;
96
+ if (hiOk) writeHi(runtimeDir, id, source, pcm24, t);
97
+ if (mixed && !hiOk) {
90
98
  const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
91
99
  try {
92
100
  const old = Buffer.alloc(len);
@@ -105,9 +113,38 @@ function write(runtimeDir, { id, source, pcm, t, mixed = true }) {
105
113
  return { ok: true, kept: true, at_ms: at / BYTES_PER_MS };
106
114
  }
107
115
 
108
- function info(runtimeDir, id) {
116
+ /** Add one chunk of 24 kHz audio to the note's 24 kHz mix at meeting time `t`, placed as write() places the 16 kHz audio. */
117
+ function writeHi(runtimeDir, id, source, pcm24, t) {
118
+ const f = hiFileOf(runtimeDir, id);
119
+ const key = f + '|' + source;
120
+ let at = Math.max(0, Math.round((Number(t) || 0) * HI_BYTES_PER_MS / 2) * 2);
121
+ const prev = next.get(key);
122
+ if (prev !== undefined && at <= prev + ON_TIME_MS * HI_BYTES_PER_MS) at = prev;
123
+ if (at >= HI_MAX_BYTES) return;
124
+ const len = Math.min(pcm24.length, HI_MAX_BYTES - at);
125
+ const fd = fs.openSync(f, fs.existsSync(f) ? 'r+' : 'w+', 0o600);
126
+ try {
127
+ const old = Buffer.alloc(len);
128
+ fs.readSync(fd, old, 0, len, at);
129
+ const out = Buffer.alloc(len);
130
+ for (let i = 0; i < len; i += 2) out.writeInt16LE(softLimit(old.readInt16LE(i) + pcm24.readInt16LE(i)), i);
131
+ fs.writeSync(fd, out, 0, len, at);
132
+ } finally { fs.closeSync(fd); }
133
+ next.set(key, at + len);
134
+ }
135
+
136
+ /** The file the replay is made from: the 24 kHz mix when the note has one, else the 16 kHz mix. { file, bytes, rate } | null. */
137
+ function baseOf(runtimeDir, id) {
109
138
  if (!ID_RE.test(String(id || ''))) return null;
110
- 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; }
139
+ for (const [file, rate] of [[hiFileOf(runtimeDir, id), HI_RATE], [fileOf(runtimeDir, id), RATE]]) {
140
+ try { const st = fs.statSync(file); if (st.size) return { file, bytes: st.size, rate }; } catch { /* none */ }
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function info(runtimeDir, id) {
146
+ const b = baseOf(runtimeDir, id);
147
+ return b ? { bytes: b.bytes, ms: Math.round(b.bytes / (b.rate * 2 / 1000)), rate: b.rate } : null;
111
148
  }
112
149
  function removeSources(runtimeDir, id) {
113
150
  if (!ID_RE.test(String(id || ''))) return;
@@ -115,10 +152,14 @@ function removeSources(runtimeDir, id) {
115
152
  }
116
153
  function remove(runtimeDir, id) {
117
154
  if (!ID_RE.test(String(id || ''))) return false;
118
- for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|')) next.delete(k);
155
+ for (const k of [...next.keys()]) if (k.startsWith(fileOf(runtimeDir, id) + '|') || k.startsWith(hiFileOf(runtimeDir, id) + '|')) next.delete(k);
119
156
  for (const src of SOURCES) { try { fs.unlinkSync(sourceFileOf(runtimeDir, id, src)); } catch { /* none */ } }
120
- for (const extra of ['.echo.json', '.pcm.echo.tmp', '.clean.pcm', '.clean.pcm.tmp']) { try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); } catch { /* none */ } }
121
- try { fs.unlinkSync(fileOf(runtimeDir, id)); return true; } catch { return false; }
157
+ let removed = false;
158
+ for (const extra of ['.echo.json', '.pcm.echo.tmp', '.hi.pcm.echo.tmp', '.hi.pcm', '.clean.pcm', '.clean.pcm.tmp']) {
159
+ try { fs.unlinkSync(path.join(dirOf(runtimeDir), id + extra)); if (extra === '.hi.pcm') removed = true; } catch { /* none */ }
160
+ }
161
+ try { fs.unlinkSync(fileOf(runtimeDir, id)); removed = true; } catch { /* none */ }
162
+ return removed;
122
163
  }
123
164
  /** The note's audio, one file per source: [{ source, file, bytes, ms }], the sources that were heard. */
124
165
  function sources(runtimeDir, id) {
@@ -131,27 +172,28 @@ function sources(runtimeDir, id) {
131
172
  return out;
132
173
  }
133
174
 
134
- /** The file the replay plays: the cleaned copy when the PM switched cleaning on and the copy is as long as the mix (a copy made
135
- * before listening went on is shorter, so it is stale and the plain mix plays); otherwise the mix. { file, bytes, cleaned } | null. */
175
+ /** The file the replay plays: the cleaned copy when the PM switched cleaning on and the copy is as long as the replay's base (a copy
176
+ * made before listening went on is shorter, so it is stale and the plain replay plays); otherwise the base. { file, bytes, rate,
177
+ * cleaned } | null. */
136
178
  function replayOf(runtimeDir, id) {
137
- const inf = info(runtimeDir, id);
138
- if (!inf) return null;
179
+ const base = baseOf(runtimeDir, id);
180
+ if (!base) return null;
139
181
  if (getClean(runtimeDir)) {
140
- try { const st = fs.statSync(cleanFileOf(runtimeDir, id)); if (st.size === inf.bytes) return { file: cleanFileOf(runtimeDir, id), bytes: st.size, cleaned: true }; } catch { /* none */ }
182
+ try { const st = fs.statSync(cleanFileOf(runtimeDir, id)); if (st.size === base.bytes) return { file: cleanFileOf(runtimeDir, id), bytes: st.size, rate: base.rate, cleaned: true }; } catch { /* none */ }
141
183
  }
142
- return { file: fileOf(runtimeDir, id), bytes: inf.bytes, cleaned: false };
184
+ return { file: base.file, bytes: base.bytes, rate: base.rate, cleaned: false };
143
185
  }
144
- /** True when a cleaned copy exists and is as long as the mix. */
186
+ /** True when a cleaned copy exists and is as long as the replay's base. */
145
187
  function hasClean(runtimeDir, id) {
146
- const inf = info(runtimeDir, id);
147
- try { return !!inf && fs.statSync(cleanFileOf(runtimeDir, id)).size === inf.bytes; } catch { return false; }
188
+ const base = baseOf(runtimeDir, id);
189
+ try { return !!base && fs.statSync(cleanFileOf(runtimeDir, id)).size === base.bytes; } catch { return false; }
148
190
  }
149
191
 
150
- function wavHeader(dataBytes) {
192
+ function wavHeader(dataBytes, rate = RATE) {
151
193
  const h = Buffer.alloc(44);
152
194
  h.write('RIFF', 0); h.writeUInt32LE(36 + dataBytes, 4); h.write('WAVE', 8);
153
195
  h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22);
154
- h.writeUInt32LE(RATE, 24); h.writeUInt32LE(RATE * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
196
+ h.writeUInt32LE(rate, 24); h.writeUInt32LE(rate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
155
197
  h.write('data', 36); h.writeUInt32LE(dataBytes, 40);
156
198
  return h;
157
199
  }
@@ -159,7 +201,7 @@ function wavHeader(dataBytes) {
159
201
  function serve(req, res, runtimeDir, id) {
160
202
  const inf = replayOf(runtimeDir, id);
161
203
  if (!inf) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'this note has no audio' })); return; }
162
- const header = wavHeader(inf.bytes);
204
+ const header = wavHeader(inf.bytes, inf.rate);
163
205
  const total = header.length + inf.bytes;
164
206
  let start = 0; let end = total - 1; let partial = false;
165
207
  const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || '').trim());
@@ -178,4 +220,4 @@ function serve(req, res, runtimeDir, id) {
178
220
  fs.createReadStream(inf.file, { start: from, end: to }).on('error', () => res.end()).pipe(res);
179
221
  }
180
222
 
181
- module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: fileOf, cleanFile: cleanFileOf, RATE, BYTES_PER_MS, MAX_BYTES };
223
+ module.exports = { write, info, remove, removeSources, sources, serve, getKeep, setKeep, getClean, setClean, hasClean, replayOf, wavHeader, softLimit, replayFile: (dir, id) => (baseOf(dir, id) || { file: fileOf(dir, id) }).file, mixFile: fileOf, hiFile: hiFileOf, sideFile: sideFileOf, baseOf, cleanFile: cleanFileOf, HI_RATE, RATE, BYTES_PER_MS, MAX_BYTES };
@@ -4,7 +4,7 @@
4
4
  * cleanStream({ n, readFar, readMic, onChunk }) the canceller, over any two tracks of n samples → a report
5
5
  * cleanReplay(runtimeDir, id) rebuild the note's replay file from its two source files → { ok, ... }
6
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
7
+ * report(runtimeDir, id) what the last clean-up did (or why it failed), or null
8
8
  *
9
9
  * THE PROBLEM. The microphone hears the meeting through the speakers, so each voice is recorded twice, the second copy late.
10
10
  * lib/notes-audio.js keeps the meeting on its own (<id>.meeting.pcm), which is exactly the signal that leaked into the microphone.
@@ -26,6 +26,7 @@ const fs = require('fs');
26
26
  const path = require('path');
27
27
  const { Worker } = require('worker_threads');
28
28
  const notesAudio = require('./notes-audio');
29
+ const hires = require('./notes-hires');
29
30
 
30
31
  const FS = notesAudio.RATE; // 16000
31
32
  const L = 8192; // the echo path kept: 512 ms
@@ -259,7 +260,7 @@ function cleanStream({ n, readFar, readMic, onChunk }) {
259
260
  }
260
261
 
261
262
  /* ── the replay file ──────────────────────────────────────────────────────────────────────────── */
262
- const reportFile = (runtimeDir, id) => notesAudio.replayFile(runtimeDir, id).replace(/\.pcm$/, '.echo.json');
263
+ const reportFile = (runtimeDir, id) => notesAudio.sideFile(runtimeDir, id, '.echo.json');
263
264
  function report(runtimeDir, id) { try { return JSON.parse(fs.readFileSync(reportFile(runtimeDir, id), 'utf8')); } catch { return null; } }
264
265
 
265
266
  function readSamples(fd, size, from, count) {
@@ -282,9 +283,18 @@ function cleanReplay(runtimeDir, id, { beforeCommit } = {}) {
282
283
  const srcs = notesAudio.sources(runtimeDir, id);
283
284
  const mic = srcs.find((s) => s.source === 'mic'); const meet = srcs.find((s) => s.source === 'meeting');
284
285
  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';
286
+ const base = notesAudio.baseOf(runtimeDir, id);
287
+ if (!base) return { ok: true, skipped: 'the note has no replay file' };
288
+ // A note with a 24 kHz replay is cleaned at 16 kHz, from the source files, and the cleaned speech is then put together with the
289
+ // band above 8 kHz of the 24 kHz recording (lib/notes-hires.js). An older note has a 16 kHz replay and is rebuilt as it always was.
290
+ const hi = base.rate === notesAudio.HI_RATE;
291
+ const mixPath = notesAudio.mixFile(runtimeDir, id); const hiPath = notesAudio.hiFile(runtimeDir, id);
292
+ const out = hi ? hiPath : mixPath; const tmp = mixPath + '.echo.tmp'; const tmpHi = hiPath + '.echo.tmp';
287
293
  let fdM = null; let fdX = null; let fdO = null;
294
+ const fail = (error) => {
295
+ for (const f of [tmp, tmpHi]) { try { fs.unlinkSync(f); } catch { /* none */ } }
296
+ return { ok: false, error: String(error).slice(0, 300) };
297
+ };
288
298
  try {
289
299
  fdM = fs.openSync(mic.file, 'r'); fdX = fs.openSync(meet.file, 'r'); fdO = fs.openSync(tmp, 'w', 0o600);
290
300
  const n = Math.floor(Math.max(mic.bytes, meet.bytes) / 2);
@@ -303,22 +313,30 @@ function cleanReplay(runtimeDir, id, { beforeCommit } = {}) {
303
313
  },
304
314
  });
305
315
  fs.closeSync(fdO); fdO = null;
316
+ if (hi) {
317
+ const c = hires.compose({ cleanFile: tmp, hiFile: hiPath, outFile: tmpHi });
318
+ if (!c.ok) { const r = fail(c.error); writeReport(runtimeDir, id, r); return r; }
319
+ }
306
320
  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 });
321
+ // the sources and the replay must be as they were: if listening went on meanwhile, the replay as it is is the truth and this one is dropped
322
+ const now = notesAudio.sources(runtimeDir, id); const nowBase = notesAudio.baseOf(runtimeDir, id);
323
+ const same = now.length === srcs.length && now.every((s) => { const o = srcs.find((x) => x.source === s.source); return o && o.bytes === s.bytes; })
324
+ && (!hi || (nowBase && nowBase.bytes === base.bytes));
325
+ if (!same) return fail('listening went on while the echo was taken out');
326
+ if (hi) { fs.renameSync(tmpHi, hiPath); try { fs.unlinkSync(tmp); } catch { /* none */ } } else fs.renameSync(tmp, mixPath);
327
+ const result = { ok: true, at: new Date().toISOString(), rate: hi ? notesAudio.HI_RATE : notesAudio.RATE, ...rep, took_ms: Date.now() - began };
328
+ writeReport(runtimeDir, id, result);
314
329
  return result;
315
330
  } catch (e) {
316
- try { fs.unlinkSync(tmp); } catch { /* none */ }
317
- return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
331
+ return fail((e && e.message) || e);
318
332
  } finally {
319
333
  for (const fd of [fdM, fdX, fdO]) if (fd !== null) { try { fs.closeSync(fd); } catch { /* closed */ } }
320
334
  }
321
335
  }
336
+ /** What the last clean-up did, kept beside the audio so a failure is not silent. */
337
+ function writeReport(runtimeDir, id, result) {
338
+ try { fs.writeFileSync(reportFile(runtimeDir, id), JSON.stringify({ at: new Date().toISOString(), ...result }), { mode: 0o600 }); } catch { /* best effort */ }
339
+ }
322
340
 
323
341
  const running = new Map(); // `${dir}|${id}` → the promise of a clean-up that is under way
324
342
  /** cleanReplay on a worker thread. A second call for the same note while one runs gets the same promise. Never rejects. */
@@ -329,7 +347,7 @@ function cleanReplayInWorker(runtimeDir, id) {
329
347
  let done = false;
330
348
  const w = new Worker(path.join(__dirname, 'notes-echo-worker.js'), { workerData: { runtimeDir, id } });
331
349
  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);
350
+ const timer = setTimeout(() => { w.terminate(); for (const t of [notesAudio.mixFile(runtimeDir, id) + '.echo.tmp', notesAudio.hiFile(runtimeDir, id) + '.echo.tmp']) { try { fs.unlinkSync(t); } catch { /* none */ } } finish({ ok: false, error: 'it took too long' }); }, TIMEOUT_MS);
333
351
  w.once('message', finish);
334
352
  w.once('error', (e) => finish({ ok: false, error: String((e && e.message) || e).slice(0, 300) }));
335
353
  w.once('exit', () => finish({ ok: false, error: 'the worker ended without a result' }));
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /* notes-hires.js — put the cleaned speech back into the 24 kHz replay (notes-audio-quality Run 4, D92 as amended 2026-10-08).
3
+ *
4
+ * compose({ cleanFile, hiFile, outFile }) → { ok, samples } | { ok: false, error }
5
+ *
6
+ * THE PROBLEM. The echo clean-up (lib/notes-echo.js) works at 16 kHz, on the two source files, so it cleans the band below 8 kHz.
7
+ * The replay the PM hears is the 24 kHz mix, which also holds the band from 8 to 12 kHz: the air and the hiss of the voices. Rebuilding
8
+ * the replay from the 16 kHz clean-up alone would throw that band away and bring the replay back to 16 kHz quality.
9
+ *
10
+ * THE METHOD. Split the 24 kHz mix at the filter's edge (7.2 kHz): low = the mix taken down to 16 kHz and back up to 24 kHz, high = the
11
+ * mix minus low. The replay is then the cleaned 16 kHz mix taken up to 24 kHz, plus high. The low band is the cleaned speech; the high
12
+ * band is exactly what was recorded. Running it again on its own output changes nothing, because the high band of the result is the
13
+ * high band of the recording, so a second final pass (Listen again) can use it.
14
+ *
15
+ * WHAT IT NEVER DOES. It reads the two files and writes the output file; the caller renames it into place. It does not touch the
16
+ * 16 kHz source files. It opens no network module.
17
+ */
18
+ const fs = require('fs');
19
+ const resample = require('./audio-resample');
20
+ const { softLimit } = require('./notes-audio');
21
+
22
+ const HI = 24000;
23
+ const LO = 16000;
24
+ const BLOCK_HI = 480000; // 20 s of the 24 kHz mix at a time
25
+ const BLOCK_LO = BLOCK_HI * LO / HI;
26
+ const TOLERANCE_S = 0.5; // the two files may differ in length by this much: more means they are not the same recording
27
+
28
+ const toFloat = (i16) => { const f = new Float32Array(i16.length); for (let i = 0; i < i16.length; i++) f[i] = i16[i] / 32768; return f; };
29
+
30
+ /** A queue of int16 samples that grows at the back and is eaten from the front. */
31
+ function queue() {
32
+ let a = new Int16Array(0);
33
+ return { get length() { return a.length; }, push(x) { const m = new Int16Array(a.length + x.length); m.set(a); m.set(x, a.length); a = m; },
34
+ take(n) { const out = a.subarray(0, n); a = a.slice(n); return out; } };
35
+ }
36
+
37
+ function readBlock(fd, size, from, count) {
38
+ const bytes = Math.max(0, Math.min(count * 2, size - from * 2));
39
+ const out = new Int16Array(bytes / 2);
40
+ if (bytes <= 0) return out;
41
+ const buf = Buffer.alloc(bytes);
42
+ fs.readSync(fd, buf, 0, bytes, from * 2);
43
+ for (let i = 0; i < out.length; i++) out[i] = buf.readInt16LE(i * 2);
44
+ return out;
45
+ }
46
+
47
+ function compose({ cleanFile, hiFile, outFile }) {
48
+ let fdC = null; let fdH = null; let fdO = null;
49
+ try {
50
+ const sizeC = fs.statSync(cleanFile).size; const sizeH = fs.statSync(hiFile).size;
51
+ const nC = Math.floor(sizeC / 2); const nH = Math.floor(sizeH / 2);
52
+ if (!nH) return { ok: false, error: 'the 24 kHz recording is empty' };
53
+ if (Math.abs(nC / LO - nH / HI) > TOLERANCE_S) return { ok: false, error: 'the two recordings differ in length by more than ' + TOLERANCE_S + ' s' };
54
+ fdC = fs.openSync(cleanFile, 'r'); fdH = fs.openSync(hiFile, 'r'); fdO = fs.openSync(outFile, 'w', 0o600);
55
+ const down = resample.makeResampler(HI, LO); const up = resample.makeResampler(LO, HI); const upClean = resample.makeResampler(LO, HI);
56
+ const raw = queue(); const low = queue(); const clean = queue();
57
+ let written = 0;
58
+ const emit = (n) => {
59
+ if (n <= 0) return;
60
+ const r = raw.take(n); const l = low.take(n); const c = clean.take(n);
61
+ const buf = Buffer.alloc(n * 2);
62
+ for (let i = 0; i < n; i++) buf.writeInt16LE(softLimit(c[i] + (r[i] - l[i])), i * 2);
63
+ fs.writeSync(fdO, buf, 0, buf.length, written * 2); written += n;
64
+ };
65
+ const drain = () => emit(Math.min(raw.length, low.length, clean.length));
66
+ for (let a = 0, b = 0; a < nH || b < nC; a += BLOCK_HI, b += BLOCK_LO) {
67
+ const h = readBlock(fdH, sizeH, a, BLOCK_HI); const c = readBlock(fdC, sizeC, b, BLOCK_LO);
68
+ raw.push(h);
69
+ low.push(up.push(toFloat(down.push(toFloat(h)))));
70
+ clean.push(upClean.push(toFloat(c)));
71
+ drain();
72
+ }
73
+ low.push(up.push(toFloat(down.flush()))); low.push(up.flush());
74
+ clean.push(upClean.flush());
75
+ drain();
76
+ // the last few samples may lack a partner (the filters stop a hair short): the band-limited parts are then taken as silent
77
+ const rest = raw.length;
78
+ if (rest > 0) {
79
+ if (rest > 8) return { ok: false, error: 'the recordings did not line up to the end' };
80
+ const r = raw.take(rest); const l = low.take(Math.min(rest, low.length)); const c = clean.take(Math.min(rest, clean.length));
81
+ const buf = Buffer.alloc(rest * 2);
82
+ for (let i = 0; i < rest; i++) buf.writeInt16LE(softLimit((i < c.length ? c[i] : 0) + (i < l.length ? r[i] - l[i] : 0)), i * 2);
83
+ fs.writeSync(fdO, buf, 0, buf.length, written * 2); written += rest;
84
+ }
85
+ fs.closeSync(fdO); fdO = null;
86
+ return { ok: true, samples: written };
87
+ } catch (e) {
88
+ return { ok: false, error: String((e && e.message) || e).slice(0, 300) };
89
+ } finally {
90
+ for (const fd of [fdC, fdH, fdO]) if (fd !== null) { try { fs.closeSync(fd); } catch { /* closed */ } }
91
+ }
92
+ }
93
+
94
+ module.exports = { compose, HI, LO };
@@ -19,7 +19,6 @@ const path = require('path');
19
19
  const notesAudio = require('./notes-audio');
20
20
  const { runProgram } = require('./notes-final');
21
21
 
22
- const RATE = notesAudio.RATE;
23
22
  const FILTER = 'highpass=f=80,afftdn=nr=10:nf=-45,loudnorm=I=-16:TP=-1.5:LRA=11';
24
23
  const TIMEOUT_MS = 20 * 60 * 1000;
25
24
  const LENGTH_SLACK = 0.02; // the copy may differ from the mix by this share of its length, no more
@@ -50,8 +49,8 @@ function build(runtimeDir, id, opts = {}) {
50
49
  const input = notesAudio.replayFile(runtimeDir, id); const out = notesAudio.cleanFile(runtimeDir, id); const tmp = out + '.tmp';
51
50
  const began = Date.now();
52
51
  try {
53
- const r = await run(found.bin, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-f', 's16le', '-ar', String(RATE), '-ac', '1', '-i', input,
54
- '-af', FILTER, '-f', 's16le', '-ar', String(RATE), '-ac', '1', tmp], { timeout: TIMEOUT_MS });
52
+ const r = await run(found.bin, ['-nostdin', '-hide_banner', '-loglevel', 'error', '-y', '-f', 's16le', '-ar', String(inf.rate), '-ac', '1', '-i', input,
53
+ '-af', FILTER, '-f', 's16le', '-ar', String(inf.rate), '-ac', '1', tmp], { timeout: TIMEOUT_MS });
55
54
  if (!r.ok) { try { fs.unlinkSync(tmp); } catch { /* none */ } return { ok: false, error: r.error || 'ffmpeg failed' }; }
56
55
  const size = fs.statSync(tmp).size;
57
56
  const now = notesAudio.info(runtimeDir, id);
@@ -654,13 +654,21 @@ async function handle(req, res, url, ctx) {
654
654
  if (b64.length > Math.ceil(MAX_PCM_BYTES / 3) * 4) return done({ ok: false, status: 413, error: 'that audio chunk is too long' });
655
655
  const pcm = Buffer.from(b64, 'base64');
656
656
  if (pcm.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
657
+ // the same time at 24 kHz, for the replay (D92 as amended 2026-10-08); the transcription never reads it
658
+ const b24 = typeof body.pcm24 === 'string' ? body.pcm24 : '';
659
+ if (b24.length > Math.ceil((MAX_PCM_BYTES * 1.5) / 3) * 4) return done({ ok: false, status: 413, error: 'that audio chunk is too long' });
660
+ const pcm24 = b24 ? Buffer.from(b24, 'base64') : null;
661
+ if (pcm24 && pcm24.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
657
662
  if (!liveDetect(ctx).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
658
663
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
659
664
  const t = Math.max(0, notes.meetingMs(note.note) - ms);
660
665
  // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
661
666
  arm(ctx, id);
662
667
  // each source is also kept apart for the final pass after Stop, which deletes it when the PM keeps no audio (Run 9)
663
- { try { notesAudio.write(dir, { id, source, pcm, t, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
668
+ // a 24 kHz chunk must cover the same time as the 16 kHz chunk (to a tenth of a second); one that does not is not kept
669
+ const sameTime = !!pcm24 && pcm24.length > 0 && Math.abs(pcm24.length / 2 / notesAudio.HI_RATE - pcm.length / 2 / whisper.RATE) <= 0.1;
670
+ if (pcm24 && pcm24.length && !sameTime) audioErrors.set(id, { at: new Date().toISOString(), error: 'the 24 kHz audio of one chunk did not match its 16 kHz audio, so the replay has a gap there' });
671
+ { try { notesAudio.write(dir, { id, source, pcm, pcm24: sameTime ? pcm24 : null, t, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
664
672
  return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
665
673
  }
666
674
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+ /* notes-test-signals.js — synthetic sound for the notes audio suites (notes-echo.test.js, notes-hires.test.js). Not a test itself.
3
+ *
4
+ * speechLike(seed, secs, talkShare) → { x, active } noise through three resonances, in phrases with pauses
5
+ * makePath(seed, delayMs, gainDb) an echo path: a direct arrival and a decaying tail
6
+ * conv(x, h) conv by overlap-add over FFTs
7
+ * scene({ secs, delayMs, gainDb, tanh, change, noiseDb, seed }) → { far, near, mic, echo, n }
8
+ * the meeting `far`, the PM `near`, and the microphone that hears both
9
+ */
10
+ const E = require('./lib/notes-echo');
11
+
12
+ const FS = 16000;
13
+ function rng(seed) { let a = seed >>> 0; return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
14
+ const gauss = (r) => Math.sqrt(-2 * Math.log(r() + 1e-12)) * Math.cos(2 * Math.PI * r());
15
+ /** Speech-like: noise through three resonances, in phrases of 1 to 3 s with pauses. { x, active }. */
16
+ function speechLike(seed, secs, talkShare) {
17
+ const r = rng(seed); const n = secs * FS; const x = new Float64Array(n); const active = new Uint8Array(n);
18
+ const res = [[500, 80], [1500, 120], [2600, 200]].map(([f, bw]) => { const R = Math.exp(-Math.PI * bw / FS); return { a1: 2 * R * Math.cos(2 * Math.PI * f / FS), a2: -R * R, y1: 0, y2: 0 }; });
19
+ let t = 0;
20
+ while (t < n) {
21
+ const talk = Math.round((1 + 2 * r()) * FS); const quiet = Math.round(talk * (1 - talkShare) / talkShare * (0.5 + r())); const syll = 3 + 3 * r();
22
+ for (let i = 0; i < talk && t + i < n; i++) {
23
+ const env = 0.55 + 0.45 * Math.sin(2 * Math.PI * syll * i / FS) ** 2; const edge = Math.min(1, i / 800, (talk - i) / 800); const e = gauss(r);
24
+ let y = 0; for (const q of res) { const o = e * 0.2 + q.a1 * q.y1 + q.a2 * q.y2; q.y2 = q.y1; q.y1 = o; y += o; }
25
+ x[t + i] = y * env * edge * 0.04; active[t + i] = 1;
26
+ }
27
+ t += talk + quiet;
28
+ }
29
+ let pk = 0; for (let i = 0; i < n; i++) pk = Math.max(pk, Math.abs(x[i]));
30
+ for (let i = 0; i < n; i++) x[i] = x[i] / pk * 0.4;
31
+ return { x, active };
32
+ }
33
+ /** An echo path: a direct arrival after delayMs, then a decaying tail, rolled off at the top. */
34
+ function makePath(seed, delayMs, gainDb) {
35
+ const d = Math.round(delayMs * FS / 1000); const len = d + 4800; const r = rng(seed); const h = new Float64Array(len); const g = 10 ** (gainDb / 20);
36
+ for (let i = 0; i < len - d; i++) h[d + i] = (i === 0 ? 1 : 0.35 * gauss(r) * Math.exp(-i / FS / 0.08)) * g;
37
+ let y = 0; for (let i = 0; i < len; i++) { y = 0.55 * y + 0.45 * h[i]; h[i] = y; }
38
+ return h;
39
+ }
40
+ /** conv(x, h) by overlap-add over FFTs. */
41
+ function conv(x, h) {
42
+ const F = 16384; const B = F - h.length; if (B <= 0) throw new Error('path too long');
43
+ const out = new Float64Array(x.length); const Hr = new Float64Array(F); const Hi = new Float64Array(F); Hr.set(h); E.fft(Hr, Hi, false);
44
+ for (let s = 0; s < x.length; s += B) {
45
+ const re = new Float64Array(F); const im = new Float64Array(F);
46
+ for (let i = 0; i < B && s + i < x.length; i++) re[i] = x[s + i];
47
+ E.fft(re, im, false);
48
+ for (let k = 0; k < F; k++) { const a = re[k] * Hr[k] - im[k] * Hi[k]; im[k] = re[k] * Hi[k] + im[k] * Hr[k]; re[k] = a; }
49
+ E.fft(re, im, true);
50
+ for (let i = 0; i < F && s + i < out.length; i++) out[s + i] += re[i];
51
+ }
52
+ return out;
53
+ }
54
+ /** A scene: the meeting `far`, the PM `near`, and the microphone that hears both, plus the echo alone. */
55
+ function scene({ secs, delayMs = 120, gainDb = -9, tanh = 0, change = null, noiseDb = -50, seed = 1 }) {
56
+ const far = speechLike(11 + seed, secs, 0.65); const near = speechLike(29 + seed, secs, 0.45); const n = far.x.length;
57
+ const driven = tanh ? Float64Array.from(far.x, (v) => Math.tanh(tanh * v) / tanh) : far.x;
58
+ const echo = conv(driven, makePath(5, delayMs, gainDb));
59
+ if (change) { const second = conv(driven, makePath(6, delayMs + 20, gainDb - 6)); for (let i = Math.round(change * FS); i < n; i++) echo[i] = second[i]; }
60
+ const r = rng(77); const nz = 10 ** (noiseDb / 20) * 0.05; const mic = new Float64Array(n);
61
+ for (let i = 0; i < n; i++) mic[i] = echo[i] + near.x[i] * 0.5 + nz * gauss(r);
62
+ return { far, near, mic, echo, n };
63
+ }
64
+
65
+ module.exports = { FS, rng, gauss, speechLike, makePath, conv, scene };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.115.0",
9
+ "version": "0.117.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -32,14 +32,28 @@ export function makeResampler(inRate, outRate = RATE) {
32
32
  const w = 0.42 + 0.5 * Math.cos(Math.PI * d / half) + 0.08 * Math.cos(2 * Math.PI * d / half); // Blackman, centred
33
33
  return sinc * w;
34
34
  };
35
- const w = new Float64Array(2 * half + 2);
35
+ // The weights depend only on the fractional part of the centre, so they are made once for each fraction (3 or 160 of them for the
36
+ // rates in use) and kept: 48 kHz to 16 kHz, or 24 kHz to 16 kHz, would otherwise compute a sine and two cosines per tap per sample.
37
+ const phases = new Map();
38
+ const phaseOf = (frac) => {
39
+ const key = Math.round(frac * 1e6);
40
+ let ph = phases.get(key);
41
+ if (!ph) {
42
+ const c = Math.floor(frac * 1e6 + 0.5) / 1e6; const lo = Math.ceil(c - half) - 0; const hi = Math.floor(c + half);
43
+ const w = new Float64Array(hi - lo + 1); let norm = 0;
44
+ for (let i = lo; i <= hi; i++) { const k = weight(i - c); w[i - lo] = k; norm += k; }
45
+ for (let i = 0; i < w.length; i++) w[i] /= norm;
46
+ ph = { lo, w };
47
+ if (phases.size < 4096) phases.set(key, ph);
48
+ }
49
+ return ph;
50
+ };
36
51
  function run(limit, out) {
37
52
  while (centre < limit && Math.floor(centre) + half < base + buf.length) {
38
- const lo = Math.ceil(centre - half); const hi = Math.floor(centre + half);
39
- let sum = 0; let norm = 0;
40
- for (let i = lo; i <= hi; i++) { const k = weight(i - centre); w[i - lo] = k; norm += k; }
41
- for (let i = lo; i <= hi; i++) { const j = i - base; if (j >= 0 && j < buf.length) sum += buf[j] * w[i - lo]; }
42
- out.push(toS16(sum / norm));
53
+ const fl = Math.floor(centre); const ph = phaseOf(centre - fl);
54
+ const first = fl + ph.lo - base; let sum = 0;
55
+ for (let k = 0; k < ph.w.length; k++) { const j = first + k; if (j >= 0 && j < buf.length) sum += buf[j] * ph.w[k]; }
56
+ out.push(toS16(sum));
43
57
  centre += ratio;
44
58
  }
45
59
  }
@@ -3499,6 +3499,7 @@
3499
3499
  .td-day__ts[data-step-state="done"] .td-day__seg { background: currentColor; }
3500
3500
  .td-day__ts[data-step-state="now"] .td-day__seg { background: var(--foreground); animation: td-day-pulse 1.2s infinite; }
3501
3501
  @media (prefers-reduced-motion: reduce) { .td-day__ts[data-step-state="now"] .td-day__seg { animation: none; } }
3502
+ .td-day__spacer { flex: 1 1 0; align-self: stretch; min-width: 0; }
3502
3503
  .td-day__status { flex: 1 1 0; min-width: 80px; text-align: right; font-size: 12px; color: var(--muted-foreground); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3503
3504
  .td-day__check { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted-foreground); }
3504
3505
  .td-day__run { margin: 0; font-size: 12px; line-height: 16px; color: var(--foreground); }
@@ -3549,6 +3550,19 @@
3549
3550
  .td-day__group { display: grid; gap: 2px; }
3550
3551
  .td-day__newdot { display: inline-block; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warning); }
3551
3552
  .td-day__chip { min-width: 84px; padding: 3px 8px; font: 500 11px/1.2 var(--mono); text-align: center; background: var(--muted); color: var(--foreground); }
3553
+ /* a mention drawn as a card of its source (initiative today-goals-jobs, PRD M1): layout only, no brand colour */
3554
+ .td-day__mc { display: grid; gap: 4px; min-width: 0; text-align: left; font-weight: 400; }
3555
+ .td-day__mc > span { display: block; min-width: 0; }
3556
+ .td-day__mc-head { display: inline-flex; align-items: center; gap: 6px; font: 11px var(--mono); letter-spacing: 0.04em; color: var(--muted-foreground); }
3557
+ .td-day__mc-main { display: flex; align-items: flex-start; gap: 8px; }
3558
+ .td-day__av { display: grid; place-items: center; flex: none; width: 32px; height: 32px; background: var(--muted); color: var(--foreground); font: 600 12px var(--mono); }
3559
+ .td-day__mc-col { display: grid; gap: 2px; min-width: 0; }
3560
+ .td-day__mc-name { font-size: 14px; line-height: 20px; font-weight: 600; color: var(--foreground); }
3561
+ .td-day__mc-time { margin-left: 6px; font: 12px var(--mono); color: var(--muted-foreground); }
3562
+ .td-day__mc-text { font-size: 14px; line-height: 20px; color: var(--foreground); overflow-wrap: anywhere; }
3563
+ .td-day__mc-quote { padding: 2px 8px; border-left: 3px solid var(--border); font-size: 12px; line-height: 16px; color: var(--muted-foreground); overflow-wrap: anywhere; }
3564
+ .td-day__mc-frame { display: grid; place-items: center; flex: none; width: 64px; height: 44px; border: 1px solid var(--border); background: var(--muted); color: var(--foreground); }
3565
+ .td-day__mcbox { padding: 8px; border: 1px solid var(--border); }
3552
3566
 
3553
3567
  /* the detail */
3554
3568
  .td-day__dhead { display: grid; gap: 4px; }
@@ -3659,33 +3673,32 @@
3659
3673
  /* the Agenda month (today-agenda.js, G25 to G27): weeks of 8 columns, the week number and seven day cells */
3660
3674
  .td-ag__mhead { display: flex; align-items: center; gap: 6px; }
3661
3675
  .td-ag__mlabel { min-width: 120px; font-size: 14px; text-align: center; color: var(--foreground); }
3662
- .td-ag__lens { flex-wrap: wrap; max-width: 100%; }
3676
+ .td-ag__bar { display: flex; align-items: center; gap: 8px; }
3677
+ .td-ag__month { display: grid; gap: 6px; width: 100%; min-width: 0; }
3663
3678
  .td-ag__grid { display: grid; gap: 2px; }
3664
3679
  .td-ag__wk { display: grid; grid-template-columns: 36px repeat(7, minmax(0, 1fr)); gap: 2px; }
3665
3680
  .td-ag__wk--head { font: 11px var(--mono); color: var(--muted-foreground); text-transform: uppercase; }
3666
3681
  .td-ag__wd { padding: 0 4px; }
3667
- .td-day .td-ag__wno { height: auto; min-height: 64px; padding: 0; font: 11px var(--mono); letter-spacing: 0; color: var(--muted-foreground); }
3682
+ .td-day .td-ag__wno { height: auto; min-height: 44px; padding: 0; font: 11px var(--mono); letter-spacing: 0; color: var(--muted-foreground); }
3668
3683
  .td-day .td-ag__wno[aria-pressed="true"] { background: var(--accent); color: var(--foreground); border-color: var(--ring); }
3669
3684
  .td-day .td-ag__cell {
3670
- display: grid; align-items: start; justify-content: stretch; height: auto; min-height: 64px; padding: 4px;
3685
+ display: grid; align-items: start; justify-content: stretch; height: auto; min-height: 44px; padding: 4px;
3671
3686
  border: 1px solid var(--border); background: var(--background); text-align: left; text-transform: none; letter-spacing: 0;
3672
3687
  font-weight: 400; white-space: normal;
3673
3688
  }
3689
+ .td-day .td-ag__cell[data-load="1"] { background: color-mix(in oklab, var(--background) 85%, var(--ring)); }
3690
+ .td-day .td-ag__cell[data-load="2"] { background: color-mix(in oklab, var(--background) 65%, var(--ring)); }
3691
+ .td-day .td-ag__cell[data-load="3"] { background: color-mix(in oklab, var(--background) 40%, var(--ring)); }
3674
3692
  .td-day .td-ag__cell[data-out="true"] { opacity: 0.45; }
3675
3693
  .td-day .td-ag__cell[data-weekend="true"] { background: var(--muted); }
3676
3694
  .td-day .td-ag__cell[data-today="true"] { border: 2px solid var(--foreground); }
3677
3695
  .td-day .td-ag__cell[aria-pressed="true"] { background: var(--accent); border-color: var(--ring); }
3678
3696
  .td-day .td-ag__cell[data-lit="true"] { box-shadow: inset 0 0 0 2px var(--warning); }
3679
- .td-ag__in { display: grid; gap: 3px; min-width: 0; }
3697
+ .td-ag__in { display: flex; align-items: center; justify-content: space-between; gap: 3px; min-width: 0; width: 100%; }
3698
+ .td-ag__star { font-size: 12px; line-height: 16px; color: var(--foreground); }
3680
3699
  .td-ag__num { font: 12px var(--mono); color: var(--foreground); font-variant-numeric: tabular-nums; }
3681
3700
  .td-ag__cell[data-past="true"] .td-ag__num { color: var(--muted-foreground); }
3682
- .td-ag__strip { position: relative; height: 6px; background: var(--muted); }
3683
- .td-ag__blk { position: absolute; top: 0; bottom: 0; left: var(--td-ag-l, 0); width: var(--td-ag-w, 0); background: var(--muted-foreground); }
3684
- .td-ag__blk--imp { background: var(--foreground); }
3685
- .td-ag__blk--free { background: var(--ring); }
3686
- .td-ag__key, .td-ag__meta, .td-ag__nonotes { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; }
3687
- .td-ag__key { font-weight: 600; color: var(--foreground); }
3688
- .td-ag__meta { color: var(--muted-foreground); }
3701
+ .td-ag__nonotes { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; line-height: 14px; }
3689
3702
  .td-ag__nonotes { color: var(--warning); }
3690
3703
  .td-ag__ready { justify-self: start; padding: 0 4px; font: 11px/16px var(--mono); border: 1px solid var(--border); color: var(--foreground); }
3691
3704
  .td-ag__ready[data-ag-ready="warn"] { border-color: var(--warning); color: var(--warning); }
@@ -3714,6 +3727,21 @@
3714
3727
  margin: 0;
3715
3728
  }
3716
3729
  .td-goal__prep { display: inline-flex; align-items: center; gap: 8px; }
3730
+ /* goals own jobs (initiative today-goals-jobs): the square for who acts, the YOU and AGENT tags, a goal's context and jobs */
3731
+ .td-day__sq { display: inline-block; flex: none; width: 10px; height: 10px; border: 1.5px solid var(--foreground); }
3732
+ .td-day__sq[data-who="agent"] { background: var(--foreground); }
3733
+ .td-day__marks2 { display: inline-flex; align-items: center; gap: 6px; }
3734
+ .td-day__tag { display: inline-block; margin-left: 4px; padding: 0 4px; border: 1px solid currentColor; font: 600 10px/1.4 var(--mono); letter-spacing: 0.04em; vertical-align: middle; }
3735
+ .td-day__tag[data-who="agent"] { background: color-mix(in oklab, currentColor 18%, transparent); }
3736
+ .td-day__who2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 14px; color: var(--foreground); }
3737
+ .td-day__jobmeta { display: grid; gap: 8px; padding: 10px 12px; border: 1px solid var(--border); background: var(--background); }
3738
+ .td-day__goaladd { display: grid; gap: 6px; }
3739
+ .td-goal__jobs { gap: 6px; }
3740
+ .td-goal__donewhen { margin: 0; font-size: 14px; color: var(--foreground); }
3741
+ .td-goal__ctxfields { display: grid; gap: 6px; }
3742
+ .td-goal__ctxfold { margin-top: 6px; }
3743
+ .td-goal__nogoal { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); }
3744
+ .td-goal__pick { display: block; min-width: 140px; max-width: 220px; }
3717
3745
  .td-card {
3718
3746
  display: flex;
3719
3747
  flex-direction: column;