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