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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
@@ -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
- /** Recording starts: the first start fixes the time every transcript offset is counted from. A start after a stop adds
353
- * the stopped time to `paused_ms`, so the meeting time carries on from where it stopped. */
354
- function record(dir, { id, ws = undefined, now = Date.now() } = {}) {
355
- const note = load(dir, id);
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
- /** Listening stopped: the meeting clock stops too, until the next Listen. */
367
- function pause(dir, { id, now = Date.now() } = {}) {
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
- if (closed.length || note.paused_at) store(dir, note);
373
- return { ok: true, meeting_ms: meetingMs(note, now), closed: closed.length };
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 } listening stopped: the meeting clock stops until the next Listen
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') { send(ctx, res, notes.list(dir)); return true; }
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 r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id });
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
- if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
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.116.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.116.0",
9
+ "version": "0.118.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.116.0",
3
+ "version": "0.118.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",