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.
@@ -3,7 +3,8 @@
3
3
  * About 70% of the width on a desktop and the whole screen on a phone. Top to bottom:
4
4
  * line 1 the title, the horizon, the health reason (only when not on track), and a meta line: lane · anchor · id
5
5
  * line 2 the stage stepper, then "Waiting on you: approval [Open gate ▸]" or "Waiting on agent: build [Hand to agent ▸]"
6
- * tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files, History,
6
+ * tabs Breakdown (the Run 1 pane, embedded: Tree · Board, and Timeline once Run 4 registers it), Files (List · Graph:
7
+ * the graph is doc-graph.js, initiative docs-library-graph), History,
7
8
  * Jira tickets (sheet-jira.js, initiative jira-wbs: the tickets drafted from the PRD and their status in Jira),
8
9
  * People (people-link.js, initiative people-directory: who owns its items or is named in interactions about it)
9
10
  * ⋯ horizon, check-by, lifecycle and the anchor (both PM-only: the server's guard answers), Copy CLI, Export breakdown
@@ -22,6 +23,7 @@ import { mountBreakdown, unmountBreakdown } from './panel-breakdown.js';
22
23
  import { mountJira, unmountJira } from './sheet-jira.js';
23
24
  import { initiativePeople } from './people-link.js';
24
25
  import { openDoc } from './panel-initiatives.js';
26
+ import { getLibrary, initiativeGraph } from './doc-graph.js';
25
27
  import { openNewSession } from './panel-newsess.js';
26
28
  import { wsOf, readDoc, saveDoc } from './data.js';
27
29
  import { state, setState } from './state.js';
@@ -67,6 +69,8 @@ export function openSheet(repo, slug) {
67
69
  ensureSprite();
68
70
  let data = null;
69
71
  let tab = 'breakdown';
72
+ let fview = 'list'; // the Files tab: 'list' or 'graph' (docs-library-graph R-N1)
73
+ let lib = null; // the library, read on the first press on Graph
70
74
  let closed = false;
71
75
  const tabId = 'initiative:' + slug;
72
76
  const crumb = el('div', { class: 'in-sheet__crumb' });
@@ -277,9 +281,27 @@ export function openSheet(repo, slug) {
277
281
  onOpen: () => { s.close(); openDoc(slug, f.path, f.label, null, f.source === 'git' ? { readonly: true } : undefined); },
278
282
  attrs: { 'data-in-file': f.kind, 'data-in-file-source': f.source || 'record' }, openAttrs: { 'data-in-file-open': f.kind },
279
283
  }));
280
- panel.replaceChildren(files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
284
+ const listView = () => (files.length ? el('div', { class: 'in-files' }, groups.map(([src, list]) => el('section', { class: 'in-files__group', 'data-in-files': src },
281
285
  el('h3', { class: 'in-files__title' }, COPY.sheet.fileSource[src] || src),
282
286
  el('div', { class: 'in-sheet__rows', role: 'list' }, list.map(fileRow))))) : empty({ text: COPY.sheet.noFiles }));
287
+ // List · Graph (docs-library-graph R-N1): the graph is this initiative's markdown files (the ones its record names
288
+ // and the ones the library's index attributes to it; a file git changed for it is evidence, not one of its files)
289
+ // and the outside files they link to. On a phone initiativeGraph draws nothing and the list shows (R-N5).
290
+ const paths = files.filter((f) => f.kind !== 'note' && (f.source || 'record') !== 'git' && /\.md$/i.test(f.path || '')).map((f) => f.path);
291
+ const seg = tabs({ variant: 'segmented', ariaLabel: COPY.sheet.filesAs, value: fview,
292
+ items: [{ id: 'list', label: COPY.sheet.filesList }, { id: 'graph', label: COPY.sheet.filesGraph }], attrs: { 'data-in-files-view': '' },
293
+ onChange: (id) => { if (id === fview) return; fview = id; drawPanel(); const t = panel.querySelector('[data-in-files-view] [data-tab="' + id + '"]'); if (t) t.focus({ preventScroll: true }); } });
294
+ const bar = el('div', { class: 'in-files__bar' }, seg, el('span', { class: 'in-files__hint' }, fview === 'graph' ? COPY.sheet.graphHint : COPY.sheet.listHint));
295
+ if (fview === 'list' || !paths.length) { panel.replaceChildren(bar, listView()); return; }
296
+ const open = (p, title) => { s.close(); openDoc(slug, p, title); };
297
+ const graphNow = () => { const g = lib ? initiativeGraph(lib, paths, { onOpen: open }) : null; return g || el('div', null, el('p', { class: 'in-note' }, COPY.sheet.graphPhone), listView()); };
298
+ if (lib) { panel.replaceChildren(bar, graphNow()); return; }
299
+ panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphReading));
300
+ getLibrary(ws).then((l) => {
301
+ if (tab !== 'files' || fview !== 'graph') return;
302
+ if (!l) { panel.replaceChildren(bar, el('p', { class: 'in-note' }, COPY.sheet.graphError), listView()); return; }
303
+ lib = l; drawPanel();
304
+ });
283
305
  return;
284
306
  }
285
307
  const hist = data.history || [];
@@ -134,6 +134,14 @@ export const COPY = {
134
134
  openGate: 'Open gate',
135
135
  handToAgent: 'Hand to agent',
136
136
  noFiles: 'No files yet.',
137
+ filesAs: 'Show files as',
138
+ filesList: 'List',
139
+ filesGraph: 'Graph',
140
+ listHint: 'Every file this initiative owns.',
141
+ graphHint: 'This initiative’s files, and the files outside it that they link to.',
142
+ graphReading: 'Reading the links…',
143
+ graphError: 'The links could not be read. The list is below.',
144
+ graphPhone: 'On a small screen the files show as a list.',
137
145
  fileSource: { record: 'Named by the record', 'knowledge-graph': 'Linked documents', git: 'Changed for it in git' },
138
146
  commits: (n) => (n === 1 ? '1 commit' : `${n} commits`),
139
147
  shared: 'shared',
@@ -1,4 +1,4 @@
1
- /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 and 2).
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
2
2
  *
3
3
  * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
4
  * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
@@ -17,10 +17,17 @@
17
17
  * LABELS AND CHECKS. The server labels each line locally; behind the D91 gates Jev labels it and compares a captured line
18
18
  * with the closest repository passage. The page shows the result in words and reads it by polling the note.
19
19
  *
20
+ * CORRECT, TRACK AND REPLAY (Run 3, D92). The ellipsis on a transcript line opens its editor: the words (the words that
21
+ * were heard stay kept and the line shows "edited"), the label, whether it is in the summary, tags and people. Tags and
22
+ * people show on the line, on its item and under Tagged. Each save of the notes keeps the text before it, and History
23
+ * shows every version as a line diff, the original first. When the note kept its audio, Replay plays it; while it plays,
24
+ * the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
25
+ * jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
26
+ *
20
27
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
21
- * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=…
28
+ * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
22
29
  * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
23
- * · summarize · summarize-agent · delete
30
+ * · summarize · summarize-agent · delete · line · recording-delete
24
31
  */
25
32
  import { el } from './dom.js';
26
33
  import { askConfirm, askText } from './dialogs.js';
@@ -49,6 +56,9 @@ let engine = null; // { ws, whisper, jev, at }
49
56
  let showList = false;
50
57
  let pollTimer = null; let agentTimer = null;
51
58
  let busy = '';
59
+ let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
60
+ let people = null; // { ws, names } — the repository's people, offered in the line editor
61
+ const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
52
62
 
53
63
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
54
64
  const dayOf = (iso) => { try { return new Date(iso).toLocaleDateString([], { weekday: 'short', day: 'numeric', month: 'short' }); } catch { return ''; } };
@@ -107,6 +117,42 @@ export function caught(n) {
107
117
  return out;
108
118
  }
109
119
  const checkWords = (c) => (!c ? '' : c.verdict === 'differs' ? 'differs from ' + c.doc : c.verdict === 'agrees' ? 'consistent with ' + c.doc : 'see ' + c.doc);
120
+ /** " #tag @Person" for a line, or ''. Pure. */
121
+ export const tagWords = (x) => [...((x && x.tags) || []).map((t) => ' #' + t), ...((x && x.people) || []).map((p) => ' @' + p)].join('');
122
+ /** The lines with tags or people that are not already a caught item, in the order they were said. Pure. */
123
+ export function tagged(n) {
124
+ const c = caught(n);
125
+ const inItems = new Set(Object.values(c).flat().map((x) => x.index));
126
+ return (n.transcript || []).map((x, index) => ({ ...x, index }))
127
+ .filter((x) => x && x.text && !x.dropped && !inItems.has(x.index) && ((x.tags && x.tags.length) || (x.people && x.people.length)));
128
+ }
129
+ /** The index of the last item whose time is at or before `ms`, or -1. `times` is in meeting order. Pure. */
130
+ export function nowIndex(times, ms) {
131
+ let best = -1;
132
+ for (let i = 0; i < times.length; i++) if (typeof times[i] === 'number' && times[i] <= ms && (best < 0 || times[i] >= times[best])) best = i;
133
+ return best;
134
+ }
135
+ /** The note line written at meeting time `ms`: of the lines that have a time at or before it, the latest. Pure. */
136
+ export function noteLineAt(body, lineTimes, ms) {
137
+ const lines = String(body || '').split(/\r?\n/);
138
+ return nowIndex(lines.map((l) => (l.trim() && lineTimes ? lineTimes[l.trim()] : undefined)), ms);
139
+ }
140
+ /** A line diff from `a` to `b`: [{ op: 'same'|'del'|'add', text }]. Longest common subsequence of lines. Pure. */
141
+ export function lineDiff(a, b) {
142
+ const x = String(a || '').split(/\r?\n/); const y = String(b || '').split(/\r?\n/);
143
+ if (x.length * y.length > 250000) return [...x.map((t) => ({ op: 'del', text: t })), ...y.map((t) => ({ op: 'add', text: t }))];
144
+ const L = Array.from({ length: x.length + 1 }, () => new Array(y.length + 1).fill(0));
145
+ for (let i = x.length - 1; i >= 0; i--) for (let j = y.length - 1; j >= 0; j--) L[i][j] = x[i] === y[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1]);
146
+ const out = []; let i = 0; let j = 0;
147
+ while (i < x.length && j < y.length) {
148
+ if (x[i] === y[j]) { out.push({ op: 'same', text: x[i] }); i++; j++; }
149
+ else if (L[i + 1][j] >= L[i][j + 1]) out.push({ op: 'del', text: x[i++] });
150
+ else out.push({ op: 'add', text: y[j++] });
151
+ }
152
+ while (i < x.length) out.push({ op: 'del', text: x[i++] });
153
+ while (j < y.length) out.push({ op: 'add', text: y[j++] });
154
+ return out;
155
+ }
110
156
  /** The whole note as Markdown, for "Copy as Markdown". Pure, for the suite. */
111
157
  export function noteMarkdown(n) {
112
158
  const out = ['# ' + (n.title || 'Untitled note'), ''];
@@ -118,9 +164,11 @@ export function noteMarkdown(n) {
118
164
  for (const [k, title] of [...SECTIONS, ['fact', 'Facts mentioned']]) {
119
165
  if (!c[k].length) continue;
120
166
  out.push('## ' + title, '', ...c[k].map((x) => '- ' + (k === 'action' && x.source && x.source !== 'speech' ? speakerOf(n, x.source) + ': ' : '') + x.text
121
- + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
167
+ + tagWords(x) + (x.check && x.check.verdict !== 'related' ? ' (' + checkWords(x.check) + ')' : '') + ' [' + fmtTime(x.t) + ']'), '');
122
168
  }
123
- if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text), '');
169
+ const tg = tagged(n);
170
+ if (tg.length) out.push('## Tagged', '', ...tg.map((x) => '- ' + x.text + tagWords(x) + ' [' + fmtTime(x.t) + ']'), '');
171
+ if ((n.transcript || []).length) out.push('## Transcript', '', ...n.transcript.map((x) => '[' + fmtTime(x.t) + '] ' + speakerOf(n, x.source) + ': ' + x.text + tagWords(x)), '');
124
172
  return out.join('\n');
125
173
  }
126
174
  /** Float samples at `inRate` as 16 kHz 16-bit samples. Each output sample averages the input samples it covers. Pure. */
@@ -151,7 +199,7 @@ async function loadList(ctx) {
151
199
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
152
200
  ctx.setState({});
153
201
  }
154
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title]);
202
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
155
203
  async function loadNote(ctx, id) {
156
204
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
157
205
  if (r && r.ok) {
@@ -159,6 +207,7 @@ async function loadNote(ctx, id) {
159
207
  // what the PM is typing wins over what the server last saved
160
208
  if (editor && editor.id === id && dirty) { r.note.title = editor.title.value; r.note.body = editor.body.value; }
161
209
  r.note.__audio = r.audio || null;
210
+ r.note.__recording = r.recording || null;
162
211
  cache.set(id, r.note);
163
212
  if (!had || sig(had) !== sig(r.note)) ctx.setState({});
164
213
  } else if (r && r.status === 404) { cache.delete(id); if (currentId === id) currentId = null; loadList(ctx); }
@@ -210,6 +259,8 @@ async function flushSave(ctx) {
210
259
  if (r && r.ok) {
211
260
  savedAt = r.updated_at; saveError = null;
212
261
  const n = cache.get(id); if (n) { n.title = title; n.body = body; n.updated_at = r.updated_at; touchRow(n); }
262
+ // a new version was kept: read it, so History shows it
263
+ if (n && typeof r.versions === 'number' && r.versions !== (n.versions || []).length) loadNote(ctx, id);
213
264
  } else { dirty = true; saveError = (r && r.error) || 'not saved'; }
214
265
  if (dirty && !saveError) scheduleSave(ctx); // typed while the save was in flight
215
266
  paintSaveState();
@@ -237,14 +288,14 @@ async function selectNote(ctx, id) {
237
288
  showList = false;
238
289
  if (id === currentId) { ctx.setState({}); return; }
239
290
  await stopAll(ctx); await flushSave(ctx);
240
- currentId = id; editor = null; savedAt = null; saveError = null; banner = null;
291
+ currentId = id; editor = null; savedAt = null; saveError = null; banner = null; dropPlayer();
241
292
  ctx.setState({});
242
293
  loadNote(ctx, id);
243
294
  }
244
295
  async function deleteNote(ctx, n) {
245
296
  const sure = await askConfirm({ title: 'Delete this note?', body: '"' + (n.title || 'Untitled note') + '" and its transcript and summaries are deleted from this machine. This cannot be undone.', confirmLabel: 'Delete note', danger: true });
246
297
  if (!sure) return;
247
- await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false;
298
+ await stopAll(ctx, { discard: true }); clearTimeout(saveTimer); dirty = false; dropPlayer();
248
299
  const r = await pmPost('/api/notes/delete', { id: n.id });
249
300
  if (r && r.ok) { cache.delete(n.id); currentId = null; editor = null; if (list) list.notes = (list.notes || []).filter((x) => x.id !== n.id); if (list && list.notes.length) currentId = list.notes[0].id; banner = { tone: 'ok', text: 'The note was deleted.' }; }
250
301
  else banner = { tone: 'bad', text: (r && r.error) || 'the note was not deleted' };
@@ -254,7 +305,7 @@ async function setPrivate(ctx, n, priv) {
254
305
  // a private press drops what is still buffered: nothing more is sent, transcribed or summarized
255
306
  if (priv) await stopAll(ctx, { discard: true });
256
307
  const r = await pmPost('/api/notes/private', { id: n.id, private: priv });
257
- if (r && r.ok) { n.private = r.private; touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this note goes to Jev or to any agent.' } : null; }
308
+ if (r && r.ok) { n.private = r.private; if (priv) { n.__recording = null; dropPlayer(); } touchRow(n); banner = priv ? { tone: 'ok', text: 'Marked private. Listening stopped, its audio was deleted, and nothing from this note goes to Jev or to any agent.' } : null; }
258
309
  else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
259
310
  ctx.setState({});
260
311
  }
@@ -310,6 +361,133 @@ async function renameSpeaker(ctx, n, source) {
310
361
  ctx.setState({});
311
362
  }
312
363
 
364
+ /* ── corrections, history and replay (Run 3, D92) ─────────────────────────────────────────────────── */
365
+ async function peopleNames(ctx) {
366
+ const ws = wsOf(ctx);
367
+ if (people && people.ws === ws) return people.names;
368
+ const r = await pmGet('/api/people' + (ws ? '?ws=' + encodeURIComponent(ws) : ''));
369
+ const names = r && r.ok && Array.isArray(r.people) ? [...new Set(r.people.map((x) => x && x.name).filter(Boolean))].slice(0, 24) : [];
370
+ people = { ws, names };
371
+ return names;
372
+ }
373
+ const splitTags = (v) => String(v || '').split(/[\s,]+/).map((t) => t.replace(/^#+/, '')).filter(Boolean);
374
+ const splitPeople = (v) => String(v || '').split(',').map((t) => t.replace(/^\s*@/, '').trim()).filter(Boolean);
375
+ /** The editor for one transcript line: its words, label, place in the summary, tags and people. */
376
+ async function editLine(ctx, n, index) {
377
+ const x = (n.transcript || [])[index];
378
+ if (!x) return;
379
+ const text = field({ label: 'Words', kind: 'textarea', value: x.text, attrs: { 'data-nt-ed-text': '' } });
380
+ const cat = field({ label: 'Label', kind: 'select', value: LABELS.some(([v]) => v === x.cat) ? x.cat : 'statement', options: LABELS.map(([value, label]) => ({ value, label })), attrs: { 'data-nt-ed-cat': '' } });
381
+ const sumv = x.in_summary === true ? 'in' : x.in_summary === false ? 'out' : 'auto';
382
+ const sum = field({ label: 'Summary', kind: 'select', value: sumv, options: [{ value: 'auto', label: 'As picked' }, { value: 'in', label: 'Always in the summary' }, { value: 'out', label: 'Never in the summary' }], attrs: { 'data-nt-ed-summary': '' } });
383
+ const tags = field({ label: 'Tags', value: (x.tags || []).map((t) => '#' + t).join(' '), placeholder: '#pricing #trial', attrs: { 'data-nt-ed-tags': '' } });
384
+ const ppl = field({ label: 'People', value: (x.people || []).join(', '), placeholder: 'Names, separated by commas', attrs: { 'data-nt-ed-people': '' } });
385
+ const sugg = el('div', { class: 'nt-row', 'data-nt-ed-suggest': '' });
386
+ const addPerson = (name) => {
387
+ const have = splitPeople(ppl.control.value);
388
+ if (!have.some((h) => h.toLowerCase() === name.toLowerCase())) ppl.control.value = [...have, name].join(', ');
389
+ };
390
+ const heard = x.orig_text !== undefined
391
+ ? el('div', { class: 'nt-heard', 'data-nt-ed-orig': '' }, el('p', { class: 'nt-hint' }, 'Heard as: ' + x.orig_text),
392
+ button({ label: 'Use what was heard', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => { text.control.value = x.orig_text; }, attrs: { 'data-nt-ed-restore': '' } }))
393
+ : null;
394
+ let h = null;
395
+ const save = async () => {
396
+ const patch = { id: n.id, index, tags: splitTags(tags.control.value), people: splitPeople(ppl.control.value) };
397
+ const t = text.control.value.replace(/\s+/g, ' ').trim();
398
+ if (t !== x.text) patch.text = t;
399
+ if (cat.control.value !== x.cat) patch.cat = cat.control.value;
400
+ if (sum.control.value !== sumv) patch.summary = sum.control.value === 'in' ? true : sum.control.value === 'out' ? false : null;
401
+ const r = await pmPost('/api/notes/line', patch);
402
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; banner = null; h.close(); }
403
+ else banner = { tone: 'bad', text: (r && r.error) || 'the line was not changed' };
404
+ ctx.setState({});
405
+ };
406
+ h = sheet({ title: 'Correct this line', sub: speakerOf(n, x.source) + ', ' + fmtTime(x.t), body: [text, heard, cat, sum, tags, ppl, sugg], attrs: { 'data-nt-line-sheet': '' },
407
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', onClick: save, attrs: { 'data-nt-ed-save': '' } })] });
408
+ const names = await peopleNames(ctx);
409
+ if (h.open && names.length) sugg.replaceChildren(...names.map((name) => button({ label: name, icon: 'plus', variant: 'ghost', size: 'sm', onClick: () => addPerson(name), attrs: { 'data-nt-ed-person': name } })));
410
+ }
411
+ async function unsummarize(ctx, n, index) {
412
+ const r = await pmPost('/api/notes/line', { id: n.id, index, summary: false });
413
+ if (r && r.ok) { n.transcript[index] = r.segment; if (r.summary) n.summary = r.summary; } else banner = { tone: 'bad', text: (r && r.error) || 'not changed' };
414
+ ctx.setState({});
415
+ }
416
+ /** Every kept version of the notes, newest first, each as the lines it changed from the one before. */
417
+ function openHistory(ctx, n) {
418
+ const vs = n.versions || [];
419
+ const now = { at: n.updated_at, title: editor && editor.id === n.id ? editor.title.value : n.title, body: editor && editor.id === n.id ? editor.body.value : n.body, current: true };
420
+ const all = [...vs, now];
421
+ const when = (iso) => dayOf(iso) + ', ' + nowClock(iso);
422
+ let h = null;
423
+ const restore = (v) => {
424
+ h.close();
425
+ if (!editor || editor.id !== n.id) return;
426
+ editor.title.value = v.title || ''; editor.body.value = v.body || '';
427
+ banner = { tone: 'ok', text: 'The version from ' + when(v.at) + ' is back in your notes. The text before it is kept in History.' };
428
+ scheduleSave(ctx); ctx.setState({});
429
+ };
430
+ const items = all.map((v, i) => {
431
+ const diff = i === 0 ? String(v.body || '').split(/\r?\n/).map((t) => ({ op: 'same', text: t })) : lineDiff(all[i - 1].body, v.body);
432
+ const head = v.current ? 'Now' : i === 0 ? 'Original, ' + when(v.at) : 'Version ' + (i + 1) + ', ' + when(v.at);
433
+ return el('div', { class: 'nt-ver', 'data-nt-version': v.current ? 'now' : String(i) },
434
+ el('div', { class: 'nt-set__head' }, head, i > 0 && all[i - 1].title !== v.title ? el('span', { class: 'nt-hint' }, ' · title: ' + (v.title || 'Untitled')) : null),
435
+ el('div', { class: 'nt-diff' }, diff.map((d) => el('div', { class: 'nt-diff__ln nt-diff__ln--' + d.op, 'data-nt-diff': d.op },
436
+ el('span', { class: 'nt-sr' }, d.op === 'add' ? 'added: ' : d.op === 'del' ? 'removed: ' : ''), d.text || ' '))),
437
+ v.current ? null : button({ label: 'Use this version', variant: 'ghost', size: 'sm', icon: 'undo-2', onClick: () => restore(v), attrs: { 'data-nt-restore': String(i) } }));
438
+ }).reverse();
439
+ h = sheet({ title: 'History of your notes', sub: vs.length + ' earlier version' + (vs.length === 1 ? '' : 's') + '. The original is kept.', body: items, attrs: { 'data-nt-history-sheet': '' } });
440
+ }
441
+ function dropPlayer() { if (player) { try { player.el.pause(); } catch { /* gone */ } player = null; } }
442
+ /** The replay of the note's kept audio. One <audio> per note, kept across redraws so playback goes on. */
443
+ function playerFor(ctx, n) {
444
+ const rec = n.__recording;
445
+ if (!rec || n.private) { if (player && player.id === n.id) dropPlayer(); return null; }
446
+ if (player && (player.id !== n.id || (player.bytes !== rec.bytes && !player.playing))) dropPlayer();
447
+ if (player) return player;
448
+ const a = el('audio', { controls: true, preload: 'metadata', class: 'nt-audio', 'data-nt-audio': '', src: '/api/notes/recording?id=' + encodeURIComponent(n.id) + '&b=' + rec.bytes });
449
+ const p = { id: n.id, bytes: rec.bytes, el: a, playing: false };
450
+ a.addEventListener('play', () => { p.playing = true; ctx.setState({}); });
451
+ a.addEventListener('pause', () => { p.playing = false; ctx.setState({}); });
452
+ a.addEventListener('ended', () => { p.playing = false; ctx.setState({}); });
453
+ a.addEventListener('timeupdate', paintNow);
454
+ a.addEventListener('seeked', paintNow);
455
+ player = p;
456
+ return p;
457
+ }
458
+ function seek(ctx, n, ms) {
459
+ const p = playerFor(ctx, n);
460
+ if (!p) return;
461
+ try { p.el.currentTime = Math.max(0, ms / 1000); p.el.play().catch(() => {}); } catch { /* not loaded yet */ }
462
+ paintNow();
463
+ }
464
+ /** Highlight the transcript line, its item and the note line at the replay's time. Classes only: no redraw. */
465
+ function paintNow() {
466
+ if (typeof document === 'undefined') return;
467
+ for (const x of document.querySelectorAll('.nt-now')) x.classList.remove('nt-now');
468
+ const n = currentId ? cache.get(currentId) : null;
469
+ if (!player || !n || player.id !== n.id) return;
470
+ const ms = player.el.currentTime * 1000;
471
+ if (!player.playing && !(ms > 0)) return;
472
+ const i = nowIndex((n.transcript || []).map((x) => x.t), ms);
473
+ if (i >= 0) {
474
+ for (const x of document.querySelectorAll('[data-nt-seg="' + i + '"], [data-nt-item="' + i + '"], [data-nt-ov="' + i + '"]')) x.classList.add('nt-now');
475
+ const ln = document.querySelector('[data-nt-seg="' + i + '"]');
476
+ if (ln && player.playing && ln.scrollIntoView) ln.scrollIntoView({ block: 'nearest' });
477
+ }
478
+ const k = noteLineAt(editor && editor.id === n.id ? editor.body.value : n.body, n.line_times, ms);
479
+ if (k >= 0) { const nl = document.querySelector('[data-nt-noteline="' + k + '"]'); if (nl) nl.classList.add('nt-now'); }
480
+ }
481
+ async function deleteAudio(ctx, n) {
482
+ const sure = await askConfirm({ title: 'Delete this note\'s audio?', body: 'The recording of "' + (n.title || 'Untitled note') + '" is deleted from this machine. The notes and the transcript stay. This cannot be undone.', confirmLabel: 'Delete audio', danger: true });
483
+ if (!sure) return;
484
+ if (player && player.id === n.id) dropPlayer();
485
+ const r = await pmPost('/api/notes/recording-delete', { id: n.id });
486
+ if (r && r.ok) { n.__recording = null; banner = { tone: 'ok', text: 'The audio was deleted. The notes and the transcript are kept.' }; }
487
+ else banner = { tone: 'bad', text: (r && r.error) || 'the audio was not deleted' };
488
+ ctx.setState({});
489
+ }
490
+
313
491
  /* ── listening with local Whisper ─────────────────────────────────────────────────────────────── */
314
492
  async function openSource(kind) {
315
493
  const md = navigator.mediaDevices;
@@ -491,7 +669,9 @@ async function chooseSources(ctx, n) {
491
669
  const meet = field({ label: 'The meeting', kind: 'check', value: local, attrs: { 'data-nt-src-meeting': '', disabled: !local } });
492
670
  const body = [mic, meet,
493
671
  el('p', { class: 'nt-hint' }, local ? 'Pick the meeting\'s tab or window next, and tick "Share tab audio".' : 'The meeting can be heard once local transcription is set up in Settings. Until then the microphone is recognised by the browser on this device.'),
494
- el('p', { class: 'nt-consent', 'data-nt-consent': '' }, 'Speech is transcribed on this ' + (local ? 'machine' : 'device') + '. Let people know you\'re taking notes.')];
672
+ el('p', { class: 'nt-consent', 'data-nt-consent': '' }, local
673
+ ? 'Speech is transcribed on this machine' + (eng.whisper.keep_audio === false ? ', and no audio is kept' : ', and the audio is kept on this machine so you can replay the meeting') + '. Let people know you\'re taking notes.'
674
+ : 'Speech is recognised by the browser on this device, and no audio is kept. Let people know you\'re taking notes.')];
495
675
  let h = null;
496
676
  const go = () => {
497
677
  const want = [mic.control.checked && 'mic', local && meet.control.checked && 'meeting'].filter(Boolean);
@@ -504,7 +684,7 @@ async function chooseSources(ctx, n) {
504
684
  }
505
685
  async function setSwitch(ctx, patch) {
506
686
  const ws = wsOf(ctx);
507
- if (!ws && patch.language === undefined && patch.model === undefined) return;
687
+ if (!ws && patch.language === undefined && patch.model === undefined && patch.keep_audio === undefined) return;
508
688
  const r = await pmPost('/api/notes/switch', ws ? { ws, ...patch } : patch);
509
689
  if (r && r.ok) engine = { ws, whisper: r.whisper, jev: r.jev, at: Date.now() };
510
690
  ctx.setState({});
@@ -531,7 +711,12 @@ function openSettings(ctx, n) {
531
711
  onCommit: (v) => setSwitch(ctx, { model: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-model': '' } }) : null,
532
712
  w && !w.ready ? button({ label: 'Set up in a terminal', onClick: () => { h.close(); whisperSetup(ctx); }, attrs: { 'data-nt-whisper-setup': '' } }) : null,
533
713
  w && w.ready && w.languages ? field({ label: 'Language spoken in the meeting', kind: 'select', value: w.language, options: w.languages,
534
- onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null));
714
+ onCommit: (v) => setSwitch(ctx, { language: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-language': '' } }) : null,
715
+ w ? field({ label: 'Keep the audio for replay', kind: 'check', value: w.keep_audio !== false,
716
+ onCommit: (v) => setSwitch(ctx, { keep_audio: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-keep-audio': '' } }) : null,
717
+ w ? el('p', { class: 'nt-hint', 'data-nt-keep-hint': '' }, w.keep_audio !== false
718
+ ? 'The meeting audio is kept on this machine, outside every repository, until you delete it. It is never sent anywhere. Browser recognition keeps no audio.'
719
+ : 'No audio is kept: each clip is deleted once it is transcribed, and a meeting cannot be replayed.') : null));
535
720
  if (!wsOf(ctx)) parts.push(el('p', { class: 'nt-hint' }, 'Choose a repository to check notes against it.'));
536
721
  else {
537
722
  const jsw = field({ label: 'Label lines with Jev', kind: 'check', value: !!(j && j.jev), onCommit: (v) => setSwitch(ctx, { jev: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-jev': '' } });
@@ -546,6 +731,7 @@ function openSettings(ctx, n) {
546
731
  el('div', { class: 'nt-row' },
547
732
  button({ label: n.private ? 'Mark not private' : 'Mark private', onClick: () => { h.close(); setPrivate(ctx, n, !n.private); }, attrs: { [n.private ? 'data-nt-unprivate' : 'data-nt-mark-private']: '' } }),
548
733
  button({ label: busy === 'agent' ? 'Starting …' : 'Summary by agent', disabled: n.private || busy === 'agent', onClick: () => { h.close(); summarizeAgent(ctx, n); }, attrs: { 'data-nt-summarize-agent': '' } }),
734
+ n.__recording ? button({ label: 'Delete this note\'s audio', variant: 'ghost', onClick: () => { h.close(); deleteAudio(ctx, n); }, attrs: { 'data-nt-delete-audio': '' } }) : null,
549
735
  button({ label: 'Delete', variant: 'ghost', onClick: () => { h.close(); deleteNote(ctx, n); }, attrs: { 'data-nt-delete': '' } }))));
550
736
  return parts;
551
737
  };
@@ -584,7 +770,9 @@ function listening(n) {
584
770
  function header(ctx, n, ed) {
585
771
  const live = listening(n);
586
772
  const when = el('span', { class: 'nt-when' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at));
587
- const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()));
773
+ const nv = (n.versions || []).length;
774
+ const left = el('div', { class: 'nt-headl' }, ed.titleWrap, when, el('span', { class: 'nt-saved', 'data-nt-saved': '' }, saveWords()),
775
+ nv ? button({ label: 'Edited', icon: 'history', variant: 'ghost', size: 'sm', onClick: () => openHistory(ctx, n), attrs: { 'data-nt-history': String(nv), title: nv + ' earlier version' + (nv === 1 ? '' : 's') + ' kept' } }) : null);
588
776
  const acts = [];
589
777
  if (live) {
590
778
  acts.push(el('span', { class: 'nt-live', 'data-nt-listening': live.kind, role: 'status' },
@@ -622,6 +810,11 @@ function checkMark(x) {
622
810
  if (c.verdict === 'agrees') return el('span', { class: 'nt-ok', 'data-nt-check': 'agrees', title: 'Jev rates it ' + (c.words || 'likely') + ' that ' + where + ' already says this', role: 'img', 'aria-label': 'consistent with ' + baseName(c.doc) }, icon('check'));
623
811
  return el('span', { class: 'nt-rel', 'data-nt-check': 'related', title: 'The closest passage in this repository: ' + where }, 'see ' + baseName(c.doc));
624
812
  }
813
+ function chips(x) {
814
+ return [...(x.tags || []).map((t) => el('span', { class: 'nt-tag', 'data-nt-tag': t }, '#' + t)),
815
+ ...(x.people || []).map((p) => el('span', { class: 'nt-tag nt-tag--person', 'data-nt-person': p }, '@' + p))];
816
+ }
817
+ const editedMark = (x) => (x.orig_text !== undefined ? el('span', { class: 'nt-edited', 'data-nt-edited': '', title: 'Heard as: ' + x.orig_text }, 'edited') : null);
625
818
  function sectionOf(ctx, n, key, title, items) {
626
819
  if (!items.length) return null;
627
820
  return el('section', { class: 'nt-sec', 'data-nt-sec': key },
@@ -629,9 +822,9 @@ function sectionOf(ctx, n, key, title, items) {
629
822
  el('ul', { class: 'nt-items' }, items.map((x) => el('li', { class: 'nt-item', 'data-nt-item': String(x.index) },
630
823
  el('span', { class: 'nt-item__text' },
631
824
  key === 'action' && x.source && x.source !== 'speech' ? el('span', { class: 'nt-who' }, speakerOf(n, x.source) + ': ') : null,
632
- x.text, ' ', checkMark(x)),
825
+ x.text, ' ', checkMark(x), ' ', editedMark(x), chips(x)),
633
826
  el('span', { class: 'nt-at' }, fmtTime(x.t)),
634
- key === 'fact' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
827
+ key === 'fact' || key === 'tagged' ? null : iconButton({ icon: 'x', ariaLabel: 'Remove from ' + title, onClick: () => unpick(ctx, n, x), size: 'sm', attrs: { 'data-nt-unpick': String(x.index) } })))));
635
828
  }
636
829
  function summarySection(ctx, n) {
637
830
  const a = n.agent;
@@ -646,13 +839,19 @@ function summarySection(ctx, n) {
646
839
  else if (a && a.started === false && a.reason) out.push(el('p', { class: 'nt-hint', 'data-nt-agent-failed': '' }, 'No agent session started: ' + a.reason + '.'));
647
840
  if (!ov || !ov.length) return out.length ? el('section', { class: 'nt-sec' }, out) : null;
648
841
  return el('section', { class: 'nt-sec', 'data-nt-summary': 'local' }, el('h3', { class: 'nt-sec__h' }, 'Summary'),
649
- el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain' }, el('span', { class: 'nt-item__text' }, x.text)))),
842
+ el('ul', { class: 'nt-items' }, ov.map((x) => el('li', { class: 'nt-item nt-item--plain', 'data-nt-ov': x.index === undefined ? null : String(x.index) }, el('span', { class: 'nt-item__text' }, x.text),
843
+ x.index === undefined ? null : iconButton({ icon: 'x', ariaLabel: 'Take out of the summary', onClick: () => unsummarize(ctx, n, x.index), size: 'sm', attrs: { 'data-nt-unsum': String(x.index) } })))),
650
844
  el('p', { class: 'nt-hint' }, 'Picked from what was said, on this machine.'), ...out);
651
845
  }
652
846
  function noteColumn(ctx, n, ed) {
653
847
  const live = listening(n);
654
848
  const c = caught(n);
655
- const parts = [summarySection(ctx, n), ed.bodyWrap];
849
+ const replaying = player && player.id === n.id && player.playing;
850
+ const notes = replaying
851
+ ? el('div', { class: 'nt-notelines', 'data-nt-notelines': '' }, String(ed.body.value || '').split(/\r?\n/).map((l, k) => el('div', { class: 'nt-noteline', 'data-nt-noteline': String(k) }, l || ' ')),
852
+ el('p', { class: 'nt-hint' }, 'Your notes, as the meeting plays. Pause to edit them.'))
853
+ : ed.bodyWrap;
854
+ const parts = [summarySection(ctx, n), notes];
656
855
  const sp = speech && speech.id === n.id ? speech : null;
657
856
  const bar = [];
658
857
  if (sp && sp.mode === 'dictate' && sp.state === 'on') bar.push(el('span', { class: 'nt-hint', 'data-nt-listening': 'dictate', role: 'status' }, 'Dictating into your notes.'), button({ label: 'Stop dictating', variant: 'ghost', onClick: () => { stopSpeech(); ctx.setState({}); }, attrs: { 'data-nt-stop-dictate': '' } }));
@@ -663,6 +862,7 @@ function noteColumn(ctx, n, ed) {
663
862
  if (n.__audio && n.__audio.error && live) parts.push(el('p', { class: 'nt-hint nt-hint--bad', 'data-nt-audio-error': '' }, n.__audio.error));
664
863
  const secs = SECTIONS.map(([k, t]) => sectionOf(ctx, n, k, t, c[k])).filter(Boolean);
665
864
  if (!live && (n.transcript || []).length) { const f = sectionOf(ctx, n, 'fact', 'Facts mentioned', c.fact); if (f) secs.push(f); }
865
+ const tg = sectionOf(ctx, n, 'tagged', 'Tagged', tagged(n)); if (tg) secs.push(tg);
666
866
  if (secs.length) parts.push(...secs);
667
867
  else parts.push(el('p', { class: 'nt-hint', 'data-nt-caught-hint': '' }, live ? 'Listening …' : (n.transcript || []).length ? 'Nothing was caught as a decision, action or question.'
668
868
  : 'Decisions, actions and questions appear here as they are said, each checked against this repository.'));
@@ -677,8 +877,13 @@ function transcriptColumn(ctx, n) {
677
877
  const spk = x.source === 'meeting'
678
878
  ? button({ label: who, variant: 'ghost', size: 'sm', onClick: () => renameSpeaker(ctx, n, 'meeting'), attrs: { 'data-nt-spk': 'meeting', title: 'Name this speaker' } })
679
879
  : el('span', { class: 'nt-spk', 'data-nt-spk': x.source || 'speech' }, who);
680
- return el('div', { class: 'nt-ln' + (['decision', 'action', 'question'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
681
- el('div', { class: 'nt-ln__meta' }, spk, el('span', { class: 'nt-at' }, fmtTime(x.t)), lab), el('div', null, x.text));
880
+ const at = n.__recording && !live && typeof x.t === 'number'
881
+ ? button({ label: fmtTime(x.t), variant: 'ghost', size: 'sm', onClick: () => seek(ctx, n, x.t), attrs: { 'data-nt-seek': String(i), title: 'Play from here' } })
882
+ : el('span', { class: 'nt-at' }, fmtTime(x.t));
883
+ return el('div', { class: 'nt-ln' + (['decision', 'action', 'question', 'fact'].includes(x.cat) ? ' nt-ln--picked' : ''), 'data-nt-seg': String(i) },
884
+ el('div', { class: 'nt-ln__meta' }, spk, at, lab, editedMark(x),
885
+ iconButton({ icon: 'ellipsis', ariaLabel: 'Correct this line', onClick: () => editLine(ctx, n, i), size: 'sm', attrs: { 'data-nt-edit': String(i) } })),
886
+ el('div', null, x.text), x.tags || x.people ? el('div', { class: 'nt-tags' }, chips(x)) : null);
682
887
  });
683
888
  const sp = speech && speech.id === n.id && speech.mode === 'record' && speech.state === 'on' ? speech : null;
684
889
  if (sp) lines.push(el('div', { class: 'nt-ln nt-ln--interim', 'data-nt-interim': '', 'aria-live': 'polite' }, sp.interim || ''));
@@ -716,7 +921,11 @@ export function renderBoard(inner, ctx) {
716
921
  wrap.appendChild(header(ctx, n, ed));
717
922
  if (showList) wrap.appendChild(listPane(ctx));
718
923
  if (n.private) wrap.appendChild(el('p', { class: 'nt-hint', 'data-nt-private': '' }, 'This note is private: it is not recorded, and nothing from it goes to Jev or to any agent.'));
924
+ const pl = !listening(n) ? playerFor(ctx, n) : null;
925
+ if (pl) wrap.appendChild(el('div', { class: 'nt-replay', 'data-nt-part': 'replay' }, el('span', { class: 'nt-sec__h' }, 'Replay'), pl.el,
926
+ el('span', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the transcript to play from there.')));
719
927
  wrap.appendChild(el('div', { class: 'nt-body2' }, noteColumn(ctx, n, ed), transcriptColumn(ctx, n)));
928
+ if (pl) queueMicrotask(paintNow);
720
929
  if (n.agent && n.agent.started && !n.agent.markdown && !agentTimer) pollAgent(ctx, n.id);
721
930
  requestAnimationFrame(fitBody);
722
931
  }
@@ -131,6 +131,7 @@ const focus = require('./lib/focus'); // buildFocus(input) →
131
131
  const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
132
132
  const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
133
133
  const docSyncStore = require('./lib/doc-sync-store');
134
+ const docLibrary = require('./lib/doc-library'); // every repository file by category, and the links the files write (docs-library-graph)
134
135
  const peopleStore = require('./lib/people-store'); // people-directory (D90) — people.jsonl, folded on read
135
136
  // The session each repository's Check now started (workspace id → session id), so a second press while it runs refuses.
136
137
  const docCheckSessions = new Map();
@@ -6549,6 +6550,7 @@ async function handleRequest(req, res) {
6549
6550
  const docs = v.docs.map((d) => ({
6550
6551
  ...d,
6551
6552
  initiative: owner.get(d.path) || (d.initiative_id ? { id: d.initiative_id, title: d.initiative_id } : null),
6553
+ category: docLibrary.categoryOf(d.path, d.type),
6552
6554
  links: d.links.map((l) => {
6553
6555
  if (l.syncing) return l;
6554
6556
  const ps = docSyncSessions.get(`${wid}|${d.path}|${l.kind}`);
@@ -6557,6 +6559,25 @@ async function handleRequest(req, res) {
6557
6559
  }));
6558
6560
  return json(res, { ...v, docs, ws: w ? w.id : null, session: live, connectors: fourPanel.connectors(os.homedir()) });
6559
6561
  }
6562
+ // All files (docs-library-graph): every tracked markdown file by category, its initiative, and the links the files
6563
+ // write (lib/doc-library.js over pmos kg). Kept in memory per repository state; nothing is written into the repository.
6564
+ if (p === '/api/docs/library' && req.method === 'GET') {
6565
+ const w = getWs(url.searchParams.get('ws'));
6566
+ const root = w ? w.root : SELF_ROOT;
6567
+ const owners = () => {
6568
+ const own = new Map();
6569
+ if (wsShape(root) !== 'pmos') return own;
6570
+ const real = (id) => fs.existsSync(path.join(root, 'planning', 'initiatives', id + '.md'));
6571
+ for (const g of initiativeIndex(root).initiatives) {
6572
+ if (!real(g.id)) continue;
6573
+ for (const a of g.artifacts) { const rel = String(a.path).split(path.sep).join('/'); if (!a.shared && !own.has(rel)) own.set(rel, { id: g.id, title: g.title || g.id }); }
6574
+ }
6575
+ return own;
6576
+ };
6577
+ const lib = docLibrary.library(root, { stateDir: STATE, owners });
6578
+ if (!lib.ok) return json(res, lib, 400);
6579
+ return json(res, { ...lib, ws: w ? w.id : null });
6580
+ }
6560
6581
  if (p === '/api/docs/files' && req.method === 'GET') {
6561
6582
  const w = getWs(url.searchParams.get('ws'));
6562
6583
  const root = w ? w.root : SELF_ROOT;
@@ -0,0 +1,5 @@
1
+ {
2
+ "_comment": "docs-library-graph: every repository file by category in Docs, the links the files write, and an initiative's files as a network. The browser half runs in pm-docs-test.js (docs-three-way-sync's fragment).",
3
+ "modules": ["doc-library.test.js"],
4
+ "seconds": { "doc-library.test.js": 2 }
5
+ }