create-pmos 0.64.0 → 0.65.1

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.
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
- /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 and 2).
2
+ /* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 3).
3
3
  *
4
4
  * list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
5
5
  * record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t }) · setPrivate(dir, { id, private })
6
6
  * categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
7
7
  * updateSegment(dir, { id, index, text, patch }) · setLabel(dir, { id, index, cat }) · renameSpeaker(dir, { id, source, name })
8
+ * editLine(dir, { id, index, cat?, summary?, tags?, people?, text? }) the PM's corrections to one line (Run 3, D92)
9
+ * versionsOf(note) → [{ at, title, body, original }] every kept version of the notes, oldest first (Run 3, D92)
8
10
  * dictation(text) → { ops } the cleaned phrase as insert operations, spoken commands resolved
9
11
  * cleanSentences(text) → [sentence] a recognised phrase, cleaned and split into sentences
10
12
  * summarize(note) → { markdown, sections, counts } the local summary: no model, the same for the same input
@@ -15,6 +17,13 @@
15
17
  * never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
16
18
  * the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
17
19
  *
20
+ * CORRECTIONS AND VERSIONS (Run 3, D92). The PM can correct a line: its label (the six, or fact), whether it is in the
21
+ * summary, its tags, the people it names and its text. A corrected text keeps the first text the transcript recorded as
22
+ * `orig_text`, and its repository check is dropped because it was about the old words. Every save of the notes keeps the
23
+ * state before it in `versions`; a save within VERSION_FOLD_MS of the last kept version keeps nothing new, so a burst of
24
+ * typing is one version, and the first version is never replaced. While a note has been recorded, each new line of the
25
+ * notes gets the meeting time it was first saved at (`line_times`), so a replay can show what was typed when.
26
+ *
18
27
  * SPEECH. This file never sees audio. Words reach it from local Whisper (lib/notes-whisper.js, D91) or from the browser's
19
28
  * on-device recognition (processLocally, no cloud fallback, D85). A transcript line carries its source: `mic` and
20
29
  * `meeting` from Whisper, `speech` from the browser. The note names each source ("You", "Meeting"); the PM can rename it.
@@ -43,6 +52,10 @@ const MAX_BODY = 400 * 1024;
43
52
  const MAX_PHRASE = 4000;
44
53
  const MAX_SEGMENTS = 20000;
45
54
  const MAX_AGENT_SUMMARY = 64 * 1024;
55
+ const VERSION_FOLD_MS = 2 * 60 * 1000;
56
+ const MAX_VERSIONS = 200;
57
+ const MAX_TAGS = 12;
58
+ const MAX_LINE_TIMES = 2000;
46
59
  const notesDir = (dir) => path.join(dir, 'notes');
47
60
  const handoffDir = (dir) => path.join(notesDir(dir), 'handoff');
48
61
  const fileOf = (dir, id) => path.join(notesDir(dir), id + '.json');
@@ -97,15 +110,51 @@ function read(dir, id) {
97
110
  function save(dir, { id, title, body, now = Date.now() } = {}) {
98
111
  const note = load(dir, id);
99
112
  if (!note) return err(404, 'no such note');
100
- if (title !== undefined) note.title = cleanTitle(title);
113
+ const nextTitle = title !== undefined ? cleanTitle(title) : note.title;
114
+ let nextBody = note.body;
101
115
  if (body !== undefined) {
102
- const b = String(body == null ? '' : body);
103
- if (Buffer.byteLength(b) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
104
- note.body = b;
116
+ nextBody = String(body == null ? '' : body);
117
+ if (Buffer.byteLength(nextBody) > MAX_BODY) return err(413, 'the note is too long to save: keep it under 400 KB');
118
+ }
119
+ if (nextTitle !== note.title || nextBody !== (note.body || '')) {
120
+ keepVersion(note, now);
121
+ if (nextBody !== (note.body || '')) stampLines(note, nextBody, now);
105
122
  }
123
+ note.title = nextTitle; note.body = nextBody;
106
124
  note.updated_at = iso(now);
107
125
  store(dir, note);
108
- return { ok: true, updated_at: note.updated_at };
126
+ return { ok: true, updated_at: note.updated_at, versions: (note.versions || []).length };
127
+ }
128
+ /** Keep the note's text before this save, unless the last kept version is younger than VERSION_FOLD_MS. Notes with no
129
+ * text yet are not a version. The first version is never dropped, so the original stays. */
130
+ function keepVersion(note, now) {
131
+ if (!String(note.body || '').trim()) return;
132
+ const vs = note.versions || (note.versions = []);
133
+ const last = vs[vs.length - 1];
134
+ if (last && now - Date.parse(last.kept_at) < VERSION_FOLD_MS) return;
135
+ vs.push({ at: note.body_at || note.updated_at, kept_at: iso(now), title: note.title || '', body: note.body || '' });
136
+ if (vs.length > MAX_VERSIONS) vs.splice(1, 1);
137
+ note.body_at = iso(now);
138
+ }
139
+ /** Each line of the notes that is new in this save gets the meeting time it appeared at. */
140
+ function stampLines(note, body, now) {
141
+ if (!note.recorded_from) return;
142
+ const t = now - Date.parse(note.recorded_from);
143
+ if (!(t >= 0)) return;
144
+ const prev = note.line_times || {};
145
+ const next = {};
146
+ for (const raw of String(body).split(/\r?\n/)) {
147
+ const l = raw.trim();
148
+ if (!l || next[l] !== undefined) continue;
149
+ next[l] = prev[l] !== undefined ? prev[l] : Math.round(t);
150
+ if (Object.keys(next).length >= MAX_LINE_TIMES) break;
151
+ }
152
+ note.line_times = next;
153
+ }
154
+ /** Every kept version, oldest first, then the current text. The first is the original. */
155
+ function versionsOf(note) {
156
+ const vs = (note.versions || []).map((v, i) => ({ at: v.at, title: v.title, body: v.body, original: i === 0 }));
157
+ return vs;
109
158
  }
110
159
  function remove(dir, id) {
111
160
  if (!load(dir, id)) return err(404, 'no such note');
@@ -176,11 +225,47 @@ function setLabel(dir, { id, index, cat, drop = false, now = Date.now() } = {})
176
225
  if (!note) return err(404, 'no such note');
177
226
  const seg = (note.transcript || [])[index];
178
227
  if (!seg) return err(404, 'no such line');
179
- if (!CATS.includes(cat)) return err(400, 'the label must be one of: ' + CATS.join(', '));
228
+ if (!PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
180
229
  seg.cat = cat; seg.cat_by = 'pm'; if (drop) seg.dropped = true; else delete seg.dropped; note.updated_at = iso(now);
181
230
  store(dir, note);
182
231
  return { ok: true, segment: seg };
183
232
  }
233
+ const cleanTag = (t) => String(t == null ? '' : t).replace(/^[#\s]+/, '').replace(/\s+/g, '-').replace(/[^\p{L}\p{N}_-]/gu, '').slice(0, 40);
234
+ const cleanPerson = (p) => String(p == null ? '' : p).replace(/^[@\s]+/, '').replace(/\s+/g, ' ').trim().slice(0, 60);
235
+ const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = x.toLowerCase(); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
236
+ /** The PM's corrections to one line. Each field is optional; what is not given is kept. */
237
+ function editLine(dir, { id, index, cat, summary, tags, people, text, now = Date.now() } = {}) {
238
+ const note = load(dir, id);
239
+ if (!note) return err(404, 'no such note');
240
+ const seg = (note.transcript || [])[index];
241
+ if (!seg) return err(404, 'no such line');
242
+ if (cat !== undefined && !PM_CATS.includes(cat)) return err(400, 'the label must be one of: ' + PM_CATS.join(', '));
243
+ if (summary !== undefined && summary !== null && typeof summary !== 'boolean') return err(400, 'summary must be true, false or null');
244
+ if (tags !== undefined && !Array.isArray(tags)) return err(400, 'tags must be a list');
245
+ if (people !== undefined && !Array.isArray(people)) return err(400, 'people must be a list');
246
+ let resummarize = false;
247
+ if (text !== undefined) {
248
+ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
249
+ if (!t) return err(400, 'a line cannot be empty: remove it from its section instead');
250
+ if (t.length > MAX_PHRASE) return err(413, 'that line is too long');
251
+ if (t !== seg.text) {
252
+ if (seg.orig_text === undefined) seg.orig_text = seg.text;
253
+ if (t === seg.orig_text) { delete seg.orig_text; delete seg.edited_at; } else seg.edited_at = iso(now);
254
+ seg.text = t; delete seg.check; resummarize = true;
255
+ }
256
+ }
257
+ if (cat !== undefined) { seg.cat = cat; seg.cat_by = 'pm'; delete seg.dropped; resummarize = true; }
258
+ if (summary !== undefined) { if (summary === null) delete seg.in_summary; else seg.in_summary = summary; resummarize = true; }
259
+ if (tags !== undefined) { const v = uniq(tags.map(cleanTag)).slice(0, MAX_TAGS); if (v.length) seg.tags = v; else delete seg.tags; }
260
+ if (people !== undefined) { const v = uniq(people.map(cleanPerson)).slice(0, MAX_TAGS); if (v.length) seg.people = v; else delete seg.people; }
261
+ if (resummarize && note.summary && note.summary.source === 'local') {
262
+ const sm = summarize(note);
263
+ note.summary = { source: 'local', at: iso(now), markdown: sm.markdown, sections: sm.sections };
264
+ }
265
+ note.updated_at = iso(now);
266
+ store(dir, note);
267
+ return { ok: true, segment: seg, summary: note.summary || null };
268
+ }
184
269
  function renameSpeaker(dir, { id, source, name, now = Date.now() } = {}) {
185
270
  const note = load(dir, id);
186
271
  if (!note) return err(404, 'no such note');
@@ -325,6 +410,8 @@ function isQuestion(s) {
325
410
  }
326
411
  /* ── the local label (Run 2) ───────────────────────────────────────────────────────────────────── */
327
412
  const CATS = ['decision', 'action', 'question', 'topic', 'statement', 'small talk'];
413
+ // the labels the PM may set: the six a rule or Jev may give, and fact, which only the PM gives
414
+ const PM_CATS = ['decision', 'action', 'question', 'fact', 'topic', 'statement', 'small talk'];
328
415
  const SMALL_TALK = /^(?:(?:ok(?:ay)?|so|and|well|um|uh)[, ]+)*(?:hi|hello|hey|morning|good (?:morning|afternoon|evening)|thanks?(?: you)?|thank you|bye|see you|cheers|can you (?:hear|see) me|you(?:'re| are) (?:on )?mute|one sec(?:ond)?|sorry|ok(?:ay)?|yeah|yes|no|right|cool|great|perfect|sounds good|let me share my screen|is everyone here|let's (?:start|begin|get started))\b/i;
329
416
  const TOPIC = /\b(?:let's (?:talk about|move on|discuss|look at|go through|turn to)|(?:the )?main (?:thing|topic|item|point)|agenda|next (?:topic|item)|moving on|today we(?:'re| are| will)|the (?:goal|point|idea|context|background|reason) (?:is|here)|what (?:this|that) means is|to give (?:you )?some context)\b/i;
330
417
  /** The local label of one line. Decisions, actions and questions use the summary's own rules, so both agree. */
@@ -340,7 +427,7 @@ function categoryOf(text, names) {
340
427
  return 'statement';
341
428
  }
342
429
  /** A statement worth listing under "Facts mentioned": it has a number, or the repository check found its passage. */
343
- const isFact = (x) => !x.dropped && x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc));
430
+ const isFact = (x) => !x.dropped && (x.cat === 'fact' || (x.cat === 'statement' && (/\d|\b(?:percent|half|double|twice)\b/i.test(x.text) || !!(x.check && x.check.doc))));
344
431
  const fmtTime = (ms) => {
345
432
  if (ms == null || !Number.isFinite(ms)) return null;
346
433
  const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60;
@@ -362,7 +449,8 @@ function bodySentences(body) {
362
449
  function summarize(note) {
363
450
  const tr = ((note && note.transcript) || []).filter((x) => x && x.text);
364
451
  const typed = bodySentences(note && note.body);
365
- const items = tr.map((x, i) => ({ i, text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped }));
452
+ const items = tr.map((x, i) => ({ i, index: (note.transcript || []).indexOf(x), text: x.text, t: Number.isFinite(x.t) ? x.t : null, src: 'transcript', cat: x.cat || null, check: x.check || null, dropped: !!x.dropped,
453
+ in_summary: x.in_summary === true ? true : x.in_summary === false ? false : null }));
366
454
  for (const s of typed) items.push({ i: items.length, text: s, t: null, src: 'notes' });
367
455
  const terms = items.map((x) => [...new Set(termsOf(x.text))]);
368
456
  const names = namesIn(items.map((x) => x.text));
@@ -396,8 +484,11 @@ function summarize(note) {
396
484
  const k = Math.max(3, Math.min(7, Math.round(pool.length / 12)));
397
485
  const chosen = [];
398
486
  const jac = (a, b) => { const A = new Set(a); const B = new Set(b); let i = 0; for (const w of A) if (B.has(w)) i++; return i / ((A.size + B.size - i) || 1); };
487
+ // the PM's choice wins: a line put in the summary is in it, a line taken out never is
488
+ for (const x of pool) if (x.in_summary === true) chosen.push(x);
399
489
  for (const x of pool.slice().sort((a, b) => score[b.i] - score[a.i] || a.i - b.i)) {
400
490
  if (chosen.length >= k || score[x.i] <= 0) break;
491
+ if (x.in_summary === false || chosen.includes(x)) continue;
401
492
  if (chosen.some((c) => jac(terms[c.i], terms[x.i]) > 0.5)) continue;
402
493
  chosen.push(x);
403
494
  }
@@ -410,7 +501,7 @@ function summarize(note) {
410
501
  const questions = items.filter((x) => kindOf(x, 'question')).filter(once);
411
502
  const facts = items.filter((x) => x.src === 'transcript' && isFact(x)).filter(once);
412
503
  const sections = {
413
- overview: chosen.map((x) => ({ text: x.text, at: cite(x) })),
504
+ overview: chosen.map((x) => (x.src === 'transcript' ? { text: x.text, at: cite(x), index: x.index } : { text: x.text, at: cite(x) })),
414
505
  decisions: decisions.map((x) => ({ text: x.text, at: cite(x) })),
415
506
  actions: actions.map((x) => ({ text: x.text, at: cite(x), owner: x.owner, due: x.due })),
416
507
  questions: questions.map((x) => ({ text: x.text, at: cite(x) })),
@@ -459,9 +550,11 @@ function toMarkdown(note, { redact = false, summary = true } = {}) {
459
550
  if (String(note.body || '').trim()) out.push('## Notes', '', ...String(note.body).split(/\r?\n/).map(line), '');
460
551
  const sum = note.agent && note.agent.markdown ? note.agent.markdown : note.summary && note.summary.markdown;
461
552
  if (summary && sum) out.push('## Summary', '', ...sum.split(/\r?\n/).map(line), '');
462
- if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text)), '');
553
+ if ((note.transcript || []).length) out.push('## Transcript', '', ...note.transcript.map((x) => line('[' + fmtTime(x.t) + '] ' + (x.source && x.source !== 'speech' ? speakerOf(note, x.source) + ': ' : '') + x.text + tagWords(x))), '');
463
554
  return { markdown: out.join('\n'), withheld };
464
555
  }
556
+ /** " #tag @Person" for a line, or "". */
557
+ const tagWords = (x) => [...(x.tags || []).map((t) => '#' + t), ...(x.people || []).map((p) => '@' + p)].map((w) => ' ' + w).join('');
465
558
  function promptFor(handoff, out) {
466
559
  return 'Summarize a meeting note for me. Read the file ' + handoff + ' (a PMOS meeting note: my typed notes and a transcript recognised on my machine). '
467
560
  + 'Write the summary in Markdown to the file ' + out + ' with these sections: ### Overview (three to six bullets on what the meeting was about and concluded), '
@@ -507,5 +600,5 @@ function importAgent(dir, note) {
507
600
  return true;
508
601
  }
509
602
 
510
- module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, renameSpeaker, speakerOf, categoryOf, isFact, CATS, setPrivate, dictation, cleanSentences, spokenPunctuation,
603
+ module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
511
604
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 and 2; lib/route-modules.js).
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
5
  * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
@@ -11,9 +11,12 @@
11
11
  * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
12
12
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
13
13
  * POST /api/notes/speaker { id, source, name } → { ok, speakers }
14
+ * POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
15
+ * GET /api/notes/recording?id=… → audio/wav, with byte ranges the note's kept audio (Run 3, D92)
16
+ * POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
14
17
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
15
18
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
16
- * { language?, model? } → { ok, whisper } the spoken language and the model, for this machine
19
+ * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
17
20
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
18
21
  * POST /api/notes/private { id, private } → { ok, private }
19
22
  * POST /api/notes/summarize { id } → { ok, summary } the local summary
@@ -34,6 +37,7 @@ const guard = require('./pm-guard.js');
34
37
  const notes = require('./notes.js');
35
38
  const whisper = require('./notes-whisper.js');
36
39
  const notesRepo = require('./notes-repo.js');
40
+ const notesAudio = require('./notes-audio.js');
37
41
  const jevNotes = require('./jev-notes.js');
38
42
  const tsSettings = require('./typesafe-settings.js');
39
43
 
@@ -43,7 +47,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
43
47
  const prefixes = ['/api/notes'];
44
48
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
45
49
  '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
46
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
50
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
47
51
 
48
52
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
49
53
  const chains = new Map(); // note id → the promise of its enrichment so far
@@ -109,7 +113,7 @@ function engineInfo(ctx, w) {
109
113
  const d = whisper.detect(ctx.runtimeDir);
110
114
  const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
111
115
  language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
112
- model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
116
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
113
117
  if (!w) return { whisper: out, jev: null };
114
118
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
115
119
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -128,10 +132,18 @@ async function handle(req, res, url, ctx) {
128
132
  if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
129
133
  const nid = url.searchParams.get('id');
130
134
  const r = notes.read(dir, nid);
131
- if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
135
+ if (r.ok) { r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); }
132
136
  send(ctx, res, r);
133
137
  return true;
134
138
  }
139
+ if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
140
+ const nid = url.searchParams.get('id');
141
+ const r = notes.read(dir, nid);
142
+ if (!r.ok) { send(ctx, res, r); return true; }
143
+ if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no audio' }, 404); return true; }
144
+ notesAudio.serve(req, res, dir, nid);
145
+ return true;
146
+ }
135
147
  if (p === '/api/notes/engine') {
136
148
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
137
149
  const wsId = url.searchParams.get('ws');
@@ -180,17 +192,29 @@ async function handle(req, res, url, ctx) {
180
192
  if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
181
193
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
182
194
  const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
195
+ // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
196
+ if (notesAudio.getKeep(dir)) { try { notesAudio.write(dir, { id, source, pcm, t }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
183
197
  return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
184
198
  }
185
199
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
186
200
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
201
+ if (p === '/api/notes/line') {
202
+ const pick = (k) => (body[k] === undefined ? undefined : body[k]);
203
+ return done(notes.editLine(dir, { id, index: Number(body.index), cat: pick('cat'), summary: pick('summary'), tags: pick('tags'), people: pick('people'), text: pick('text') }));
204
+ }
205
+ if (p === '/api/notes/recording-delete') {
206
+ const r = notes.read(dir, id);
207
+ if (!r.ok) return done(r);
208
+ return done({ ok: true, deleted: notesAudio.remove(dir, id) });
209
+ }
187
210
  if (p === '/api/notes/switch') {
188
211
  const o = wsOf();
189
212
  // the spoken language belongs to this machine, not to a repository
190
- if (body.language !== undefined || body.model !== undefined) {
213
+ if (body.language !== undefined || body.model !== undefined || body.keep_audio !== undefined) {
191
214
  const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
192
215
  const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
193
- if (!r.ok || !m.ok) { ctx.json(res, { ok: false, error: r.error || m.error }, 400); return true; }
216
+ const k = r.ok && m.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
217
+ if (!r.ok || !m.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || k.error }, 400); return true; }
194
218
  if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
195
219
  }
196
220
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
@@ -211,11 +235,11 @@ async function handle(req, res, url, ctx) {
211
235
  }
212
236
  if (p === '/api/notes/private') {
213
237
  const r = notes.setPrivate(dir, { id, private: body.private });
214
- if (r.ok && r.private && transcriber) transcriber.drop(id);
238
+ if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
215
239
  return done(r);
216
240
  }
217
241
  if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
218
- if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); return done(notes.remove(dir, id)); }
242
+ if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
219
243
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
220
244
  let w = null;
221
245
  if (body.ws != null) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.64.0",
3
+ "version": "0.65.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.64.0",
3
+ "version": "0.65.1",
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",