create-pmos 0.63.2 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +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/notes.md +35 -6
- package/workspace/guide/people.md +19 -10
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/people-cli.js +7 -8
- package/workspace/lib/people-store.js +110 -1
- package/workspace/lib/people.js +74 -50
- 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 +82 -0
- package/workspace/public/pm/dest-people.js +13 -27
- package/workspace/public/pm/view-notes.js +228 -19
- package/workspace/server.js +8 -5
- package/workspace/skills/gather-requirements.skill +2 -2
- package/workspace/skills/jira-sync.skill +1 -1
- package/workspace/skills/live-notes.skill +2 -2
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
* only the body is rebuilt when its own data changed.
|
|
7
7
|
*
|
|
8
8
|
* What it shows, for the repository the tab names (or the open one):
|
|
9
|
-
* - Waiting for you:
|
|
10
|
-
*
|
|
9
|
+
* - Waiting for you: possible duplicates (Merge · Keep separate) and how many tones wait for a confirm. A new person
|
|
10
|
+
* is added, not listed here (the PM, 2026-10-06): Remove from People on their page keeps them out;
|
|
11
11
|
* - the list: search over names, handles and initiatives, All · Team · External, sort by latest, most or name;
|
|
12
12
|
* - the person's page: who, interactions with where each came from, tone (Confirm · Set another · Change), shared
|
|
13
13
|
* work (initiatives, items they own with Blocked shown, coming meetings), notes (Add · Remove), and identity and
|
|
14
14
|
* sources (handles, how many interactions each source gave, the file the person lives in).
|
|
15
|
-
* Every press is a page press to POST /api/people/decide. Nothing here reads a source:
|
|
16
|
-
* saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
|
|
15
|
+
* Every press is a page press to POST /api/people/decide. Nothing here reads a source: GET /api/people records the names
|
|
16
|
+
* the workspace's own records hold, and agent sessions record what they saw through `pmos people record`. A link from another place opens a person through the tab's slug (people-link.js).
|
|
17
17
|
* In fixture mode it reads nothing.
|
|
18
18
|
*/
|
|
19
19
|
|
|
@@ -26,7 +26,7 @@ import { registerCommand, openDestination } from './registry.js';
|
|
|
26
26
|
import { forgetPeople } from './people-link.js';
|
|
27
27
|
|
|
28
28
|
const TONES = ['warm', 'neutral', 'cautious', 'tense'];
|
|
29
|
-
const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira' };
|
|
29
|
+
const SOURCE = { gather: 'Gathered notes', calendar: 'Calendar', live: 'Live notes', jira: 'Jira', plan: 'Plan items', notes: 'Meeting notes' };
|
|
30
30
|
const HANDLE = { mail: 'Mail', slack: 'Chat', jira: 'Jira', confluence: 'Confluence', speaker: 'Speaker', name: 'Name' };
|
|
31
31
|
|
|
32
32
|
export const COPY = Object.freeze({
|
|
@@ -37,16 +37,13 @@ export const COPY = Object.freeze({
|
|
|
37
37
|
readError: 'People could not be read.',
|
|
38
38
|
find: 'Find a person',
|
|
39
39
|
waiting: 'Waiting for you',
|
|
40
|
-
newPerson: (n, src) => `${n} sighting${n === 1 ? '' : 's'} · ${src}`,
|
|
41
|
-
add: 'Add to People',
|
|
42
|
-
ignore: 'Ignore',
|
|
43
40
|
same: (a, b) => `${a} may be the same person as ${b}`,
|
|
44
41
|
merge: 'Merge',
|
|
45
42
|
keep: 'Keep separate',
|
|
46
43
|
tonesWait: (n) => `${n} tone${n === 1 ? '' : 's'} to confirm. Open the person to confirm it.`,
|
|
47
44
|
all: 'All', team: 'Team', external: 'External',
|
|
48
45
|
latest: 'Latest', most: 'Most', name: 'Name',
|
|
49
|
-
none: 'Nobody yet.
|
|
46
|
+
none: 'Nobody yet. People adds everyone it finds: the owners of plan items, the speakers in Live notes and your meeting notes, the calendar when calendar names are on, and the people agent sessions see when they gather requirements or sync Jira.',
|
|
50
47
|
noMatch: (q) => `Nobody matches “${q}”.`,
|
|
51
48
|
pick: 'Choose a person to see their page.',
|
|
52
49
|
interactions: 'Interactions',
|
|
@@ -70,8 +67,9 @@ export const COPY = Object.freeze({
|
|
|
70
67
|
remove: 'Remove',
|
|
71
68
|
identity: 'Identity and sources',
|
|
72
69
|
lives: (f) => `Lives in ${f}`,
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
removeFromPeople: 'Remove from People',
|
|
71
|
+
removed: (n) => `${n} is removed. Later sightings stay out.`,
|
|
72
|
+
foundIn: (s) => `Found in ${s}`,
|
|
75
73
|
merged: 'Merged.',
|
|
76
74
|
kept: 'Kept separate.',
|
|
77
75
|
noteAdded: 'Note added.',
|
|
@@ -174,21 +172,6 @@ function review(inst) {
|
|
|
174
172
|
const r = inst.view.review;
|
|
175
173
|
if (!r.count) return null;
|
|
176
174
|
const rows = [];
|
|
177
|
-
for (const f of r.new) {
|
|
178
|
-
const key = f.handle.kind + ':' + f.handle.value;
|
|
179
|
-
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-new': key },
|
|
180
|
-
row({
|
|
181
|
-
title: f.name,
|
|
182
|
-
meta: [[f.role, f.org].filter(Boolean).join(' · ') || handleText(f.handle), COPY.newPerson(f.count, f.sources.map((s) => SOURCE[s] || s).join(', '))],
|
|
183
|
-
trail: [
|
|
184
|
-
button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-people-act': 'add', 'data-people-key': key },
|
|
185
|
-
onClick: async () => { const x = await decide(inst, { act: 'add', handle: f.handle, name: f.name }, COPY.added(f.name)); if (x && x.person) { inst.open = x.person; draw(inst); } } }),
|
|
186
|
-
button({ label: COPY.ignore, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'ignore', 'data-people-key': key },
|
|
187
|
-
onClick: () => decide(inst, { act: 'ignore', handle: f.handle }, COPY.ignored(f.name)) }),
|
|
188
|
-
],
|
|
189
|
-
attrs: { title: handleText(f.handle) },
|
|
190
|
-
})));
|
|
191
|
-
}
|
|
192
175
|
for (const m of r.merges) {
|
|
193
176
|
const key = m.handle.kind + ':' + m.handle.value;
|
|
194
177
|
rows.push(el('div', { class: 'rp-people-row', role: 'listitem', 'data-people-merge': key },
|
|
@@ -340,6 +323,7 @@ function identitySec(inst, p) {
|
|
|
340
323
|
el('ul', { class: 'rp-people-list-plain' },
|
|
341
324
|
p.handles.map((h) => el('li', { class: 'rp-people-handle', 'data-people-handle': h.kind + ':' + h.value }, handleText(h))),
|
|
342
325
|
Object.entries(by).map(([s, n]) => el('li', { class: 'rp-people-hint' }, `${SOURCE[s] || s}: ${n} interaction${n === 1 ? '' : 's'}`))),
|
|
326
|
+
(p.sources || []).length ? el('p', { class: 'rp-people-hint', 'data-people-sources': p.sources.join(',') }, COPY.foundIn(p.sources.map((s) => SOURCE[s] || s).join(', '))) : null,
|
|
343
327
|
el('p', { class: 'rp-people-hint rp-people-file' }, COPY.lives(file)),
|
|
344
328
|
], { 'data-people-sec': 'identity' });
|
|
345
329
|
}
|
|
@@ -350,7 +334,9 @@ function personPane(inst) {
|
|
|
350
334
|
return el('article', { class: 'rp-people-page', 'data-people-page': p.id },
|
|
351
335
|
el('header', { class: 'rp-people-who' },
|
|
352
336
|
el('h3', { class: 'rp-people-who__name' }, p.name),
|
|
353
|
-
el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · '))
|
|
337
|
+
el('p', { class: 'rp-people-hint' }, [who(p), p.external && (p.role || p.org) ? COPY.external : null].filter(Boolean).join(' · ')),
|
|
338
|
+
el('div', { class: 'rp-people-acts' }, button({ label: COPY.removeFromPeople, variant: 'ghost', size: 'sm', attrs: { 'data-people-act': 'remove' },
|
|
339
|
+
onClick: async () => { const r = await decide(inst, { act: 'remove', person: p.id }, COPY.removed(p.name)); if (r) { inst.open = null; draw(inst); } } }))),
|
|
354
340
|
interactionsSec(p), toneSec(inst, p), sharedSec(p), notesSec(inst, p), identitySec(inst, p));
|
|
355
341
|
}
|
|
356
342
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1
|
|
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
|
-
|
|
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': '' },
|
|
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
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
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
|
}
|
package/workspace/server.js
CHANGED
|
@@ -6623,17 +6623,20 @@ async function handleRequest(req, res) {
|
|
|
6623
6623
|
return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
|
|
6624
6624
|
}
|
|
6625
6625
|
// ── People: the people the PM works with in a repository (people-directory, PRD R9 to R14, proposed D90) ──────────
|
|
6626
|
-
// GET
|
|
6627
|
-
//
|
|
6628
|
-
//
|
|
6626
|
+
// GET first records the names this machine's own records hold (people-store scan: plan item owners, Live notes
|
|
6627
|
+
// speakers, the PM's meeting notes for this repository, and the remembered calendar while calendar names are on), then
|
|
6628
|
+
// folds people.jsonl with the plan items and the coming meetings. POST is one PM page press: add, remove, ignore,
|
|
6629
|
+
// merge, keep separate, edit, tone, a note, or the calendar setting. Agent sessions record what they read from mail,
|
|
6630
|
+
// chat and Jira through `pmos people record`; no route here reads a connector.
|
|
6629
6631
|
if (p === '/api/people' && req.method === 'GET') {
|
|
6630
6632
|
const w = getWs(url.searchParams.get('ws'));
|
|
6631
6633
|
const root = w ? w.root : SELF_ROOT;
|
|
6632
6634
|
const now = Date.now();
|
|
6633
6635
|
const mem = readCalMemory();
|
|
6634
6636
|
const brief = briefOfDay(briefLib.localDate(now));
|
|
6635
|
-
const
|
|
6636
|
-
|
|
6637
|
+
const calendar = [...((mem && mem.events) || []), ...((brief && brief.calendar) || [])];
|
|
6638
|
+
peopleStore.scan(root, { now, calendar, notesDir: STATE, ws: w ? w.id : null });
|
|
6639
|
+
const meetings = calendar.filter((e) => e && e.start && Date.parse(e.end || e.start) >= now);
|
|
6637
6640
|
const v = peopleStore.view(root, { now, meetings });
|
|
6638
6641
|
if (!v.ok) return json(res, v, 500);
|
|
6639
6642
|
return json(res, { ...v, ws: w ? w.id : null });
|
|
@@ -4,7 +4,7 @@ label: "Gather requirements"
|
|
|
4
4
|
description: "Read what changed in the PM's mail, chat, Jira, Confluence, calendar and meeting notes since the last note, and file one dated note of requirements — each with its source, how sure it is, and which initiative it touches."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
|
-
version: 1.
|
|
7
|
+
version: 1.3.0
|
|
8
8
|
owner: wawan
|
|
9
9
|
risk: low
|
|
10
10
|
scope: read:connectors, read:state, write:state/requirements, write:state/people
|
|
@@ -55,7 +55,7 @@ Procedure (each step ends with a check that shows it is done):
|
|
|
55
55
|
Done when: the file starts with this frontmatter and holds the four sections in order.
|
|
56
56
|
7. Under "Sources reached and not reached", list every source kind you tried, with `reached` or `not reached — <why>`, and the number of private items skipped.
|
|
57
57
|
Done when: every source kind you tried has a line, and the private count is stated.
|
|
58
|
-
8. Record the people in People. For each author of an item you filed (other than the PM
|
|
58
|
+
8. Record the people in People. For each author of an item you filed, and each other person an item names (an owner, a stakeholder, a decision maker), other than the PM, write one sighting (for a person an item only names, the interaction kind is "Mention"): {"source": "gather", "handle": {"kind": "mail" | "slack" | "jira" | "confluence", "value": "<their address or name in that source>"}, "name": "<display name>", "role"?: "<their role, when the source states it>", "org"?: "<their organisation, when it is not the PM's>", "external"?: true (when they are outside the PM's company), "interaction": {"date": "YYYY-MM-DD", "kind": "Mail" | "Chat" | "Jira" | "Doc" | "Meeting", "text": "<what they asked or said, in your words, at most 200 characters>", "pointer": "<the item's pointer>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the item's initiative id, or omit when unmatched>"}}. Tone describes the exchange, not the person. When two handles look like one person (the same name in mail and in chat), add a merge {"a": <handle>, "b": <handle>, "why": "<one line>"}. Put them in one file {"sightings": [...], "merges": [...]} in the scratch folder and run `pmos people record --file <that file>` once. Contact details beyond the handle are never recorded.
|
|
59
59
|
Done when: the command printed how many sightings it recorded, or you said no item had an author.
|
|
60
60
|
9. Finish by saying in one line how many items you filed and where, and how many people you recorded. Do not commit; the PM's Sync carries the file.
|
|
61
61
|
Done when: the count and the path are said once, and no commit was made.
|
|
@@ -31,7 +31,7 @@ Procedure:
|
|
|
31
31
|
- `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
|
|
32
32
|
The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
|
|
33
33
|
4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
|
|
34
|
-
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue: {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
34
|
+
5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
|
|
35
35
|
6. Finish with the three sections of the output contract, one short line each:
|
|
36
36
|
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
|
|
37
37
|
- Not reached: each initiative whose search failed, with the reason.
|
|
@@ -5,7 +5,7 @@ description: "Take the talking points of a conversation, place each against the
|
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
tier: balanced
|
|
8
|
-
version: 1.
|
|
8
|
+
version: 1.2.0
|
|
9
9
|
owner: wawan
|
|
10
10
|
risk: low
|
|
11
11
|
scope: read:connectors, read:state, write:state/live-notes
|
|
@@ -48,7 +48,7 @@ Procedure (each step ends with a check that shows it is done):
|
|
|
48
48
|
Done when: you have shown the list and recorded nothing the PM did not say.
|
|
49
49
|
6. When the PM says the conversation is over, run `node workspace/bin/live-notes.js close --repo . --session <id>`. It appends a section to today's requirements note and deletes the raw transcript.
|
|
50
50
|
Done when: the command printed the note path and "transcript: deleted", or the retention date the PM set.
|
|
51
|
-
7. Record the speakers in People. For each named speaker other than the PM, write one sighting: {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
|
|
51
|
+
7. Record the speakers in People. For each named speaker, and each person the meeting names as the owner of an action, other than the PM, write one sighting (for a named owner, the handle kind is "name" and the interaction kind is "Mention"): {"source": "live", "handle": {"kind": "speaker", "value": "<the speaker's name as the transcript gives it>"}, "name": "<display name>", "interaction": {"date": "YYYY-MM-DD", "kind": "Meeting" (or "1:1" when only the PM and this speaker talk), "text": "<what this speaker raised, in your words, at most 200 characters>", "pointer": "<wispr:meeting id, or the segments file> · <date>", "tone": "warm|neutral|cautious|tense" (only when the exchange shows one), "initiative": "<the initiative id their points were placed on, or omit>"}}. Tone describes the exchange, not the person. Never copy what the speaker said. Put the sightings in one file {"sightings": [...]} in the scratch folder and run `pmos people record --file <that file>` once. A speaker with no name ("Speaker 2") is not recorded.
|
|
52
52
|
Done when: the command printed how many sightings it recorded, or you said no speaker was named.
|
|
53
53
|
8. Finish by saying in two lines how many points were placed, how many are still unanswered, which path ran, how many people you recorded, and which sources were not reached. Do not commit; the PM's Sync carries the files.
|
|
54
54
|
Done when: the two lines are said once and no commit was made.
|