create-pmos 0.116.0 → 0.118.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 +1 -1
- 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/workspace/guide/notes.md +43 -3
- package/workspace/guide/today.md +22 -13
- package/workspace/lib/brief.js +2 -2
- package/workspace/lib/day-marks.js +29 -9
- package/workspace/lib/goals.js +68 -10
- package/workspace/lib/notes.js +139 -17
- package/workspace/lib/routes-notes.js +68 -11
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +289 -91
- package/workspace/public/pm/dest-notes.js +136 -29
- package/workspace/public/pm/note-dock.js +204 -164
- package/workspace/public/pm/notes-context.js +60 -0
- package/workspace/public/pm/notes-links.js +32 -6
- package/workspace/public/pm/primitives.js +1 -1
- package/workspace/public/pm/today-agenda.js +27 -53
- package/workspace/public/pm/today-copy.js +34 -15
- package/workspace/public/pm/today-day.js +98 -76
- package/workspace/public/pm/today-goals.js +159 -15
- package/workspace/public/pm/today-jobs.js +84 -0
- package/workspace/public/pm/today-mentions.js +50 -0
- package/workspace/public/pm/topbar.js +71 -32
- package/workspace/public/pm/view-notes.js +142 -73
- package/workspace/server.js +10 -8
- package/workspace/test-files.json +4 -0
- package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
- package/workspace/test-lists/today-goals-jobs.json +5 -0
- package/workspace/test-lists/today-mentions-cards.json +5 -0
package/workspace/lib/notes.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/* notes.js — the PM's notes: typed notes, dictation, meeting transcripts and their summaries (meeting-notes, Runs 1 to 9).
|
|
3
3
|
*
|
|
4
4
|
* list(dir) · read(dir, id) · create(dir, { title }) · save(dir, { id, title, body }) · remove(dir, id)
|
|
5
|
-
* record(dir, { id, ws }) · appendTranscript(dir, { id, text, source, t, ms }) · setPrivate(dir, { id, private })
|
|
5
|
+
* record(dir, { id, ws }) · pause(dir, { id }) · stop(dir, { id }) · expire(dir, { id, resumeMs }) · appendTranscript(dir, { id, text, source, t, ms }) · setPrivate(dir, { id, private })
|
|
6
6
|
* categoryOf(text, names) → 'decision'|'action'|'question'|'topic'|'statement'|'small talk' the local label (Run 2)
|
|
7
7
|
* meetsBar(cat, text) → boolean whether a line is good enough to be a decision, an action or a question (any labeller)
|
|
8
8
|
* checkable(seg) → boolean whether a line is worth a repository check
|
|
@@ -21,6 +21,17 @@
|
|
|
21
21
|
* the minute batches and the agent's bullets (Run 8; see "minute batches" below)
|
|
22
22
|
* `dir` is the per-user runtime directory (server.js STATE). Notes live in `<dir>/notes/`, never in a repository.
|
|
23
23
|
*
|
|
24
|
+
* PAUSE IS NOT STOP (Run 13, R51, R56). Pause keeps the meeting open for RESUME_MS (one hour). A Listen inside that hour
|
|
25
|
+
* resumes it. After the hour the meeting ends by itself (`expire`, ended_by 'timeout'). Stop ends it for good (`stop`,
|
|
26
|
+
* ended_by 'stop'). A meeting that ended cannot be recorded again: `record` answers 409. Each stretch of recording is
|
|
27
|
+
* `sessions: [{ from, to }]` (ISO times; `to` is null while open); `sessionsOf` derives one for a note from before Run 13.
|
|
28
|
+
* `stateOf(note, now, resumeMs)` is 'recording' | 'paused' | 'ended' | 'idle'. A row and a note carry state, paused_at,
|
|
29
|
+
* resume_until (paused_at + resumeMs while paused), ended_at, ended_by and sessions.
|
|
30
|
+
*
|
|
31
|
+
* MEETING CONTEXT (Run 13, R59). `meeting.context` is a snapshot of the calendar meeting's context, kept when the note is made
|
|
32
|
+
* or linked: with, attendees, role, external, series, purpose, prep, agenda, links, refs, where, organizer, taken_at.
|
|
33
|
+
* `cleanContext` clips and checks every field; nothing the client sends is trusted, and anything else is dropped.
|
|
34
|
+
*
|
|
24
35
|
* WHERE A NOTE LIVES. One JSON file per note, `<dir>/notes/<id>.json`, written to a temporary file and renamed, so a crash
|
|
25
36
|
* never leaves half a note. A transcript holds other people's words, which is why it stays in the per-user directory, as
|
|
26
37
|
* the Live notes transcript does (D85). Deleting a note deletes its file and its agent handoff files.
|
|
@@ -65,6 +76,7 @@ const crypto = require('crypto');
|
|
|
65
76
|
const { scan } = require('./secret-scan');
|
|
66
77
|
const { cleanPhrase } = require('./live-notes');
|
|
67
78
|
|
|
79
|
+
const RESUME_MS = 60 * 60 * 1000;
|
|
68
80
|
const ID_RE = /^n-[0-9a-z]{4,16}-[0-9a-f]{4}$/;
|
|
69
81
|
const MAX_TITLE = 160;
|
|
70
82
|
const MAX_BODY = 400 * 1024;
|
|
@@ -104,15 +116,46 @@ const newId = (now) => 'n-' + Math.floor(now).toString(36) + '-' + crypto.random
|
|
|
104
116
|
const cleanTitle = (t) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, MAX_TITLE);
|
|
105
117
|
const wordsOf = (s) => (String(s || '').match(/[\p{L}\p{N}']+/gu) || []).length;
|
|
106
118
|
|
|
119
|
+
/* ── pause, stop and the state of a meeting (Run 13) ─────────────────────────────────────────────── */
|
|
120
|
+
/** The resume window in ms: PMOS_NOTES_RESUME_MS (50 or more), else one hour. Routes pass their own through `resumeMs`. */
|
|
121
|
+
function RESUME_MS_NOW() { const v = Number(process.env.PMOS_NOTES_RESUME_MS); return Number.isFinite(v) && v >= 50 ? v : RESUME_MS; }
|
|
122
|
+
const resumeOf = (o) => (Number.isFinite(Number(o)) && Number(o) >= 50 ? Number(o) : RESUME_MS_NOW());
|
|
123
|
+
/** The recording stretches of a note: [{ from, to }]. A note from before Run 13 has one, derived from its first Listen and its pause. */
|
|
124
|
+
function sessionsOf(note) {
|
|
125
|
+
if (note && Array.isArray(note.sessions)) return note.sessions.map((x) => ({ from: x.from, to: x.to || null }));
|
|
126
|
+
if (note && note.recorded_from) return [{ from: note.recorded_from, to: note.paused_at || null }];
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
/** 'recording' | 'paused' | 'ended' | 'idle'. Pure. `ended` also covers a pause older than the window that nothing has marked yet. */
|
|
130
|
+
function stateOf(note, now = Date.now(), resumeMs = RESUME_MS) {
|
|
131
|
+
if (!note) return 'idle';
|
|
132
|
+
if (note.ended_at) return 'ended';
|
|
133
|
+
if (!note.recorded_from) return 'idle';
|
|
134
|
+
if (note.paused_at) {
|
|
135
|
+
const p = Date.parse(note.paused_at);
|
|
136
|
+
return Number.isFinite(p) && now - p > resumeMs ? 'ended' : 'paused';
|
|
137
|
+
}
|
|
138
|
+
return note.private ? 'idle' : 'recording';
|
|
139
|
+
}
|
|
140
|
+
function stateFields(n, now, resumeMs) {
|
|
141
|
+
const state = stateOf(n, now, resumeMs);
|
|
142
|
+
const p = Date.parse(n.paused_at);
|
|
143
|
+
let endedAt = n.ended_at || null;
|
|
144
|
+
let endedBy = n.ended_by || null;
|
|
145
|
+
if (state === 'ended' && !endedAt && Number.isFinite(p)) { endedAt = iso(Math.min(now, p + resumeMs)); endedBy = 'timeout'; }
|
|
146
|
+
return { state, paused_at: n.paused_at || null, resume_until: state === 'paused' && Number.isFinite(p) ? iso(p + resumeMs) : null,
|
|
147
|
+
ended_at: endedAt, ended_by: endedBy, sessions: sessionsOf(n) };
|
|
148
|
+
}
|
|
149
|
+
|
|
107
150
|
/** The row a list shows. Never the body or the transcript. */
|
|
108
|
-
function rowOf(n) {
|
|
151
|
+
function rowOf(n, now = Date.now(), resumeMs = RESUME_MS) {
|
|
109
152
|
return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
|
|
110
153
|
words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
|
|
111
154
|
meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
|
|
112
155
|
// the meeting time so far (a paused note's clock) and how many statements differ from a repository file (the Notes menu)
|
|
113
156
|
meeting_ms: n.recorded_from ? meetingMs(n) : 0, conflicts: n.private ? 0 : conflictsOf(n).length,
|
|
114
157
|
// a private note shows its title on the calendar and the initiative, never its summary
|
|
115
|
-
summary_lines: n.private ? [] : summaryLines(n) };
|
|
158
|
+
summary_lines: n.private ? [] : summaryLines(n), ...stateFields(n, now, resumeMs) };
|
|
116
159
|
}
|
|
117
160
|
/** The statements of a note that a repository file says otherwise: transcript lines, the agent's bullets and the PM's
|
|
118
161
|
* highlights whose check verdict is 'differs'. → [{ text, doc, line, t, from }] */
|
|
@@ -147,6 +190,30 @@ const EVENT_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
|
|
|
147
190
|
const INI_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
148
191
|
const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
|
|
149
192
|
const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
193
|
+
|
|
194
|
+
const httpUrl = (u) => {
|
|
195
|
+
if (typeof u !== 'string' || u.length > 500) return null;
|
|
196
|
+
try { const x = new URL(u); return x.protocol === 'http:' || x.protocol === 'https:' ? u : null; } catch { return null; }
|
|
197
|
+
};
|
|
198
|
+
const ctxText = (v, n) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, n) || null : null);
|
|
199
|
+
const ctxList = (v, n, max) => (Array.isArray(v) ? v.map((x) => ctxText(x, n)).filter(Boolean).slice(0, max) : []);
|
|
200
|
+
/** A snapshot of the meeting's context. Every field is clipped and checked; anything else is dropped. null when `c` is not an object. */
|
|
201
|
+
function cleanContext(c, now) {
|
|
202
|
+
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
|
|
203
|
+
const a = Number(c.attendees);
|
|
204
|
+
const links = (Array.isArray(c.links) ? c.links : []).map((l) => {
|
|
205
|
+
const url = l && httpUrl(l.url); const label = l && ctxText(l.label, 60);
|
|
206
|
+
return url && label ? { label, url } : null;
|
|
207
|
+
}).filter(Boolean).slice(0, 5);
|
|
208
|
+
const refs = (Array.isArray(c.refs) ? c.refs : []).map((r) => {
|
|
209
|
+
const label = r && ctxText(r.label, 120);
|
|
210
|
+
return label ? { label, source: ctxText(r.source, 30), link: httpUrl(r.link) } : null;
|
|
211
|
+
}).filter(Boolean).slice(0, 5);
|
|
212
|
+
return { with: ctxText(c.with, 120), attendees: c.attendees != null && c.attendees !== '' && Number.isInteger(a) && a >= 0 && a <= 1000 ? a : null,
|
|
213
|
+
role: ['present', 'decide', 'attend'].includes(c.role) ? c.role : null, external: c.external === true, series: ctxText(c.series, 200),
|
|
214
|
+
purpose: ctxText(c.purpose, 240), prep: ctxText(c.prep, 240), agenda: ctxList(c.agenda, 140, 6), links, refs,
|
|
215
|
+
where: ctxText(c.where, 120), organizer: ctxText(c.organizer, 120), taken_at: iso(now) };
|
|
216
|
+
}
|
|
150
217
|
/** A meeting link: a calendar event (its id, where it came from, its start, end and title) or a meeting that is not on the
|
|
151
218
|
* calendar. null unlinks. Returns { ok, meeting } or an error. */
|
|
152
219
|
function cleanMeeting(m, now) {
|
|
@@ -160,7 +227,8 @@ function cleanMeeting(m, now) {
|
|
|
160
227
|
if (m.end != null && minuteOf(m.end) === null) return err(400, 'the end of the event is not a time');
|
|
161
228
|
if (m.event_id != null && !EVENT_ID_RE.test(String(m.event_id))) return err(400, 'that is not a calendar event id');
|
|
162
229
|
return { ok: true, meeting: { kind: 'event', event_id: m.event_id != null ? String(m.event_id) : null, src: m.src === 'memory' ? 'memory' : 'brief',
|
|
163
|
-
start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now)
|
|
230
|
+
start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now),
|
|
231
|
+
...(cleanContext(m.context, now) ? { context: cleanContext(m.context, now) } : {}) } };
|
|
164
232
|
}
|
|
165
233
|
/** An initiative link: { id, ws, repo, title, by }. `repo` is the repository's name in the page, to open the initiative. `by` is pm (chosen), event (from the calendar event) or local (suggested). */
|
|
166
234
|
function cleanInitiative(x, by) {
|
|
@@ -193,6 +261,9 @@ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}
|
|
|
193
261
|
if (meeting !== undefined) {
|
|
194
262
|
const m = cleanMeeting(meeting, now);
|
|
195
263
|
if (!m.ok) return m;
|
|
264
|
+
// a link without context keeps the stored context of the same event
|
|
265
|
+
const prev = note.meeting;
|
|
266
|
+
if (m.meeting && m.meeting.kind === 'event' && !m.meeting.context && prev && prev.context && sameEvent(prev, m.meeting)) m.meeting.context = prev.context;
|
|
196
267
|
note.meeting = m.meeting;
|
|
197
268
|
if (m.meeting && m.meeting.kind === 'event' && !String(note.title || '').trim()) note.title = m.meeting.title;
|
|
198
269
|
}
|
|
@@ -206,11 +277,11 @@ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}
|
|
|
206
277
|
store(dir, note);
|
|
207
278
|
return { ok: true, note };
|
|
208
279
|
}
|
|
209
|
-
function list(dir) {
|
|
280
|
+
function list(dir, { now = Date.now(), resumeMs } = {}) {
|
|
210
281
|
let names = [];
|
|
211
282
|
try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return { ok: true, notes: [] }; }
|
|
212
283
|
const notes = [];
|
|
213
|
-
for (const f of names) { const n = load(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n)); }
|
|
284
|
+
for (const f of names) { const n = load(dir, f.slice(0, -5)); if (n && n.id) notes.push(rowOf(n, now, resumeOf(resumeMs))); }
|
|
214
285
|
notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
|
|
215
286
|
return { ok: true, notes };
|
|
216
287
|
}
|
|
@@ -227,11 +298,11 @@ function create(dir, { title = '', meeting, initiative, now = Date.now() } = {})
|
|
|
227
298
|
return { ok: true, note };
|
|
228
299
|
}
|
|
229
300
|
/** Read one note. An agent summary file written since the request is imported here, so the note is the one source. */
|
|
230
|
-
function read(dir, id) {
|
|
301
|
+
function read(dir, id, { now = Date.now(), resumeMs } = {}) {
|
|
231
302
|
const note = load(dir, id);
|
|
232
303
|
if (!note) return err(404, 'no such note');
|
|
233
304
|
importAgent(dir, note);
|
|
234
|
-
return { ok: true, note };
|
|
305
|
+
return { ok: true, note: { ...note, ...stateFields(note, now, resumeOf(resumeMs)) } };
|
|
235
306
|
}
|
|
236
307
|
function save(dir, { id, title, body, now = Date.now() } = {}) {
|
|
237
308
|
const note = load(dir, id);
|
|
@@ -349,28 +420,79 @@ function meetingMs(note, now = Date.now()) {
|
|
|
349
420
|
}
|
|
350
421
|
/** When the meeting clock started, as the page counts it: the first Listen moved on by the time it was stopped. */
|
|
351
422
|
const clockFrom = (note, now = Date.now()) => iso(now - meetingMs(note, now));
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
423
|
+
const REFUSE = Object.freeze({
|
|
424
|
+
stop: 'this meeting was stopped, so it cannot be resumed',
|
|
425
|
+
timeout: 'this meeting was paused for over an hour, so it ended and cannot be resumed' });
|
|
426
|
+
/** Close the open recording stretch, if any. */
|
|
427
|
+
function closeSession(note, at) {
|
|
428
|
+
const ss = sessionsOf(note);
|
|
429
|
+
const open = ss.find((x) => !x.to);
|
|
430
|
+
if (open) open.to = at;
|
|
431
|
+
note.sessions = ss;
|
|
432
|
+
return !!open;
|
|
433
|
+
}
|
|
434
|
+
/** Recording starts: the first start fixes the time every transcript offset is counted from. A start after a pause adds
|
|
435
|
+
* the paused time to `paused_ms`, so the meeting time carries on from where it stopped. A meeting that ended (Stop, or a
|
|
436
|
+
* pause longer than the resume window) is refused with 409. */
|
|
437
|
+
function record(dir, { id, ws = undefined, now = Date.now(), resumeMs } = {}) {
|
|
438
|
+
let note = load(dir, id);
|
|
356
439
|
if (!note) return err(404, 'no such note');
|
|
357
440
|
if (note.private) return err(409, 'this note is marked private, so it is not recorded. Mark it not private first.');
|
|
441
|
+
if (stateOf(note, now, resumeOf(resumeMs)) === 'ended') {
|
|
442
|
+
if (!note.ended_at) expire(dir, { id, now, resumeMs });
|
|
443
|
+
note = load(dir, id);
|
|
444
|
+
return err(409, REFUSE[note.ended_by] || REFUSE.stop);
|
|
445
|
+
}
|
|
358
446
|
let changed = false;
|
|
359
447
|
if (!note.recorded_from) { note.recorded_from = iso(now); changed = true; }
|
|
360
448
|
const stopped = Date.parse(note.paused_at);
|
|
361
449
|
if (Number.isFinite(stopped)) { note.paused_ms = (Number(note.paused_ms) || 0) + Math.max(0, now - stopped); note.paused_at = null; changed = true; }
|
|
450
|
+
const ss = sessionsOf(note);
|
|
451
|
+
if (!ss.some((x) => !x.to)) { ss.push({ from: iso(now), to: null }); changed = true; }
|
|
452
|
+
note.sessions = ss;
|
|
362
453
|
if (ws !== undefined && note.ws !== (ws || null)) { note.ws = ws || null; changed = true; }
|
|
363
454
|
if (changed) { note.updated_at = iso(now); store(dir, note); }
|
|
364
455
|
return { ok: true, recorded_from: note.recorded_from, clock_from: clockFrom(note, now), meeting_ms: meetingMs(note, now) };
|
|
365
456
|
}
|
|
366
|
-
|
|
367
|
-
|
|
457
|
+
const endedShape = (note, now, resumeMs) => ({ meeting_ms: meetingMs(note, now), ...stateFields(note, now, resumeMs) });
|
|
458
|
+
/** Listening stopped but the meeting stays open: the meeting clock stops until the next Listen, for the resume window. */
|
|
459
|
+
function pause(dir, { id, now = Date.now(), resumeMs } = {}) {
|
|
368
460
|
const note = load(dir, id);
|
|
369
461
|
if (!note) return err(404, 'no such note');
|
|
370
462
|
const closed = closeOpenLines(note);
|
|
371
463
|
if (note.recorded_from && !note.paused_at) note.paused_at = iso(now);
|
|
372
|
-
|
|
373
|
-
|
|
464
|
+
const s = note.paused_at ? closeSession(note, note.paused_at) : false;
|
|
465
|
+
if (closed.length || note.paused_at || s) store(dir, note);
|
|
466
|
+
return { ok: true, ...endedShape(note, now, resumeOf(resumeMs)), closed: closed.length };
|
|
467
|
+
}
|
|
468
|
+
/** The meeting ends for good: it is paused if it was listening, then marked ended by 'stop'. Calling it again changes nothing. */
|
|
469
|
+
function stop(dir, { id, now = Date.now(), resumeMs } = {}) {
|
|
470
|
+
const note = load(dir, id);
|
|
471
|
+
if (!note) return err(404, 'no such note');
|
|
472
|
+
const closed = closeOpenLines(note);
|
|
473
|
+
if (note.recorded_from && !note.paused_at) note.paused_at = iso(now);
|
|
474
|
+
if (note.paused_at) closeSession(note, note.paused_at);
|
|
475
|
+
if (!note.ended_at) { note.ended_at = iso(now); note.ended_by = 'stop'; }
|
|
476
|
+
if (!note.sessions && note.recorded_from) note.sessions = sessionsOf(note);
|
|
477
|
+
note.updated_at = iso(now);
|
|
478
|
+
store(dir, note);
|
|
479
|
+
return { ok: true, ...endedShape(note, now, resumeOf(resumeMs)), closed: closed.length };
|
|
480
|
+
}
|
|
481
|
+
/** A pause older than the resume window ends the meeting by 'timeout', at the moment the window closed.
|
|
482
|
+
* → { ok, expired } plus the same fields as pause. */
|
|
483
|
+
function expire(dir, { id, now = Date.now(), resumeMs } = {}) {
|
|
484
|
+
const note = load(dir, id);
|
|
485
|
+
if (!note) return err(404, 'no such note');
|
|
486
|
+
const win = resumeOf(resumeMs);
|
|
487
|
+
const p = Date.parse(note.paused_at);
|
|
488
|
+
let expired = false;
|
|
489
|
+
if (!note.ended_at && Number.isFinite(p) && now - p > win) {
|
|
490
|
+
note.ended_at = iso(Math.min(now, p + win)); note.ended_by = 'timeout'; note.updated_at = iso(now);
|
|
491
|
+
if (!note.sessions) note.sessions = sessionsOf(note);
|
|
492
|
+
store(dir, note);
|
|
493
|
+
expired = true;
|
|
494
|
+
}
|
|
495
|
+
return { ok: true, expired, ...endedShape(note, now, win) };
|
|
374
496
|
}
|
|
375
497
|
/** Listening stopped, so no cut will finish a line still open: each one is closed and gets its local label. A label the PM
|
|
376
498
|
* chose is kept. → the closed lines' indexes */
|
|
@@ -1545,7 +1667,7 @@ function editFinal(dir, { id, sec, i, text, now = Date.now() } = {}) {
|
|
|
1545
1667
|
}
|
|
1546
1668
|
|
|
1547
1669
|
module.exports = { addHighlight, updateHighlight, removeHighlight, editFinal, conflictsOf, setFinal, setFinalState, renameFinalSpeaker, finalSpeaker, transcriptLines, cleanNotes, notesMarkdown, groundQuote, parseTime, NOTES_SHAPE,
|
|
1548
|
-
wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
1670
|
+
wisprHandoff, wisprPrompt, setWispr, importWispr, notesOutOf, wisprOutOf, list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, pause, stop, expire, stateOf, sessionsOf, cleanContext, RESUME_MS, meetingMs, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
|
|
1549
1671
|
summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf,
|
|
1550
1672
|
meetsBar, checkable, vocabOf, termsIn, namesIn, SECTION_CAP, JOIN_GAP_MS,
|
|
1551
1673
|
takeBatch, setMinutesAgent, minuteHandoff, bulletsPrompt, bulletsFollowUp, importBullets, closeOpenBullets, minuteHanded, lateMinutes, markFallback, updateBullet, editBullet, minutesDirOf, minuteMdOf, minuteJsonOf, MAX_BULLET };
|
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
* POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
|
|
18
18
|
* POST /api/notes/record { id, ws? } → { ok, recorded_from, clock_from, meeting_ms } `ws` is the repository the note's lines are checked against;
|
|
19
19
|
* clock_from is the first Listen moved on by the time listening was stopped
|
|
20
|
-
* POST /api/notes/pause { id } → { ok, meeting_ms
|
|
20
|
+
* POST /api/notes/pause { id } → { ok, meeting_ms, state, paused_at, resume_until, ended_at, ended_by, sessions }
|
|
21
|
+
* a resumable pause (Run 13, R51): the clock stops, the meeting stays open for one hour
|
|
22
|
+
* (PMOS_NOTES_RESUME_MS or ctx.notesResumeMs, 50 or more), and a timer ends it after that.
|
|
23
|
+
* It writes the last minute batch. It does not ask for Wispr Flow's notes.
|
|
24
|
+
* POST /api/notes/stop { id } → { ok, meeting_ms, state, … } the meeting ends for good: the last minute batch, and the
|
|
25
|
+
* Wispr Flow fetch is scheduled. A stopped meeting cannot be recorded again.
|
|
26
|
+
* When a pause runs out (the timer, or the first list or read after a restart) the meeting ends with ended_by 'timeout':
|
|
27
|
+
* the Wispr Flow fetch is scheduled and the final pass is queued once. POST /api/notes/record answers 409 for a meeting that
|
|
28
|
+
* ended. A row and a note carry state ('idle'|'recording'|'paused'|'ended'), paused_at, resume_until, ended_at, ended_by, sessions
|
|
29
|
+
* and meeting.context (the snapshot of the meeting, lib/notes.js cleanContext; R59).
|
|
21
30
|
* POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
|
|
22
31
|
* POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
|
|
23
32
|
* POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
|
|
@@ -83,7 +92,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
83
92
|
|
|
84
93
|
const prefixes = ['/api/notes'];
|
|
85
94
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
86
|
-
'/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
95
|
+
'/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
87
96
|
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/final', '/api/notes/final-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch']);
|
|
88
97
|
|
|
89
98
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
@@ -170,6 +179,43 @@ function arm(ctx, id) {
|
|
|
170
179
|
if (t.timer.unref) t.timer.unref();
|
|
171
180
|
timers.set(id, t);
|
|
172
181
|
}
|
|
182
|
+
/* ── the resume window (Run 13): a paused meeting ends by itself ─────────────────────────────────── */
|
|
183
|
+
const resumeMsOf = (ctx) => { const v = Number(ctx.notesResumeMs || process.env.PMOS_NOTES_RESUME_MS); return Number.isFinite(v) && v >= 50 ? v : notes.RESUME_MS; };
|
|
184
|
+
const expiryTimers = new Map(); // note id → the timer that ends a paused meeting
|
|
185
|
+
function clearExpiry(id) { const t = expiryTimers.get(id); if (t) { clearTimeout(t); expiryTimers.delete(id); } }
|
|
186
|
+
/** The meeting ended by itself: write nothing new, ask for Wispr Flow's notes and queue the final pass (not twice). */
|
|
187
|
+
function endMeeting(ctx, id, by = 'timeout') {
|
|
188
|
+
if (by !== 'timeout') return { ok: true, expired: false };
|
|
189
|
+
const dir = ctx.runtimeDir;
|
|
190
|
+
const r = notes.expire(dir, { id, resumeMs: resumeMsOf(ctx) });
|
|
191
|
+
if (!r.ok || !r.expired) return r;
|
|
192
|
+
clearExpiry(id);
|
|
193
|
+
const cur = notes.read(dir, id, { resumeMs: resumeMsOf(ctx) });
|
|
194
|
+
if (cur.ok && !cur.note.private) {
|
|
195
|
+
disarm(id);
|
|
196
|
+
runMinute(ctx, id, { final: true });
|
|
197
|
+
scheduleWispr(ctx, id);
|
|
198
|
+
const fs0 = cur.note.final_state && cur.note.final_state.state;
|
|
199
|
+
if (!fs0) queueFinal(ctx, id);
|
|
200
|
+
}
|
|
201
|
+
return r;
|
|
202
|
+
}
|
|
203
|
+
function armExpiry(ctx, id, resumeUntil) {
|
|
204
|
+
clearExpiry(id);
|
|
205
|
+
const left = Number.isFinite(Date.parse(resumeUntil)) ? Date.parse(resumeUntil) - Date.now() : resumeMsOf(ctx);
|
|
206
|
+
const t = setTimeout(() => { expiryTimers.delete(id); endMeeting(ctx, id, 'timeout'); }, Math.max(1, left) + 5);
|
|
207
|
+
if (t.unref) t.unref();
|
|
208
|
+
expiryTimers.set(id, t);
|
|
209
|
+
}
|
|
210
|
+
const swept = new Set(); // note ids already ended by a sweep
|
|
211
|
+
/** After a restart no timer is left: a list or a read ends a meeting whose pause ran out. Idempotent and cheap. */
|
|
212
|
+
function sweepExpired(ctx, rows) {
|
|
213
|
+
for (const n of rows) {
|
|
214
|
+
if (n.state !== 'ended' || n.ended_by !== 'timeout' || !n.paused_at || swept.has(n.id)) continue;
|
|
215
|
+
swept.add(n.id);
|
|
216
|
+
endMeeting(ctx, n.id, 'timeout');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
173
219
|
function disarm(id) { const t = timers.get(id); if (t) { clearTimeout(t.timer); timers.delete(id); } }
|
|
174
220
|
/** One minute's work for a note, after its earlier work: import what the agent wrote, then the next batch. */
|
|
175
221
|
function runMinute(ctx, id, { final = false } = {}) { return chain(id, () => minuteNow(ctx, id, { final })); }
|
|
@@ -538,15 +584,16 @@ async function handle(req, res, url, ctx) {
|
|
|
538
584
|
if (!dir) { ctx.json(res, { ok: false, error: 'the notes store is not available' }, 500); return true; }
|
|
539
585
|
if (p === '/api/notes' || p === '/api/notes/one') {
|
|
540
586
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
541
|
-
if (p === '/api/notes') {
|
|
587
|
+
if (p === '/api/notes') { const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) }); if (l.ok) sweepExpired(ctx, l.notes); send(ctx, res, l); return true; }
|
|
542
588
|
const nid = url.searchParams.get('id');
|
|
543
589
|
// the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
|
|
544
590
|
const imp = notes.importBullets(dir, { id: nid });
|
|
545
591
|
if (imp.added.length) enrich(ctx, nid, imp.added, 'bullets');
|
|
546
592
|
notes.importWispr(dir, { id: nid });
|
|
547
|
-
let r = notes.read(dir, nid);
|
|
593
|
+
let r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
|
|
594
|
+
if (r.ok) { sweepExpired(ctx, [r.note]); r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) }); }
|
|
548
595
|
// the page is open: the agent that wrote the notes before Wispr Flow's arrived writes them again, merged
|
|
549
|
-
if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid);
|
|
596
|
+
if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
|
|
550
597
|
if (r.ok) {
|
|
551
598
|
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); if (r.recording) r.recording = { ...r.recording, cleaned: notesAudio.hasClean(dir, nid) };
|
|
552
599
|
if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
|
|
@@ -619,8 +666,12 @@ async function handle(req, res, url, ctx) {
|
|
|
619
666
|
if (p === '/api/notes/record') {
|
|
620
667
|
const o = wsOf();
|
|
621
668
|
if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
622
|
-
const
|
|
669
|
+
const rm = resumeMsOf(ctx);
|
|
670
|
+
// a pause that ran out ends the meeting first (final notes and all), then the record is refused
|
|
671
|
+
endMeeting(ctx, id, 'timeout');
|
|
672
|
+
const r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id, resumeMs: rm });
|
|
623
673
|
if (r.ok) {
|
|
674
|
+
clearExpiry(id); swept.delete(id);
|
|
624
675
|
// a new Listen tries the agent again when it could not start last time
|
|
625
676
|
const cur = notes.read(dir, id);
|
|
626
677
|
if (cur.ok && cur.note.minutes && cur.note.minutes.agent === 'failed') notes.setMinutesAgent(dir, { id, agent: null, reason: '' });
|
|
@@ -633,9 +684,15 @@ async function handle(req, res, url, ctx) {
|
|
|
633
684
|
return done(r);
|
|
634
685
|
}
|
|
635
686
|
if (p === '/api/notes/pause') {
|
|
636
|
-
const r = notes.pause(dir, { id });
|
|
637
|
-
// listening stopped: the minute timer stops, and what was said since the last minute is one more batch
|
|
638
|
-
|
|
687
|
+
const r = notes.pause(dir, { id, resumeMs: resumeMsOf(ctx) });
|
|
688
|
+
// listening stopped: the minute timer stops, and what was said since the last minute is one more batch.
|
|
689
|
+
// The meeting stays open: Wispr Flow and the final pass wait for Stop or for the hour to run out.
|
|
690
|
+
if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); if (r.state === 'paused') armExpiry(ctx, id, r.resume_until); }
|
|
691
|
+
return done(r);
|
|
692
|
+
}
|
|
693
|
+
if (p === '/api/notes/stop') {
|
|
694
|
+
const r = notes.stop(dir, { id, resumeMs: resumeMsOf(ctx) });
|
|
695
|
+
if (r.ok) { clearExpiry(id); swept.add(id); disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
|
|
639
696
|
return done(r);
|
|
640
697
|
}
|
|
641
698
|
if (p === '/api/notes/transcript') {
|
|
@@ -751,7 +808,7 @@ async function handle(req, res, url, ctx) {
|
|
|
751
808
|
if (p === '/api/notes/private') {
|
|
752
809
|
vocabCache.delete(id);
|
|
753
810
|
const r = notes.setPrivate(dir, { id, private: body.private });
|
|
754
|
-
if (r.ok && r.private) { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
811
|
+
if (r.ok && r.private) { clearExpiry(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
755
812
|
return done(r);
|
|
756
813
|
}
|
|
757
814
|
if (p === '/api/notes/summarize') {
|
|
@@ -780,7 +837,7 @@ async function handle(req, res, url, ctx) {
|
|
|
780
837
|
}
|
|
781
838
|
return done(r);
|
|
782
839
|
}
|
|
783
|
-
if (p === '/api/notes/delete') { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
|
|
840
|
+
if (p === '/api/notes/delete') { clearExpiry(id); swept.delete(id); disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
|
|
784
841
|
if (p === '/api/notes/highlight') {
|
|
785
842
|
// the PM's highlight: the transcript of the window, then Jev's label and the repository check of its text
|
|
786
843
|
const r = notes.addHighlight(dir, { id, from: body.from, to: body.to });
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.118.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED