create-pmos 0.75.0 → 0.76.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.
@@ -2,14 +2,22 @@
2
2
  /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 4; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
- * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
5
+ * GET /api/notes/one?id=… → { ok, note, bullets_state, audio, recording } imports an agent summary and the agent's
6
+ * minute bullets written since the last read (Run 8). note.bullets:
7
+ * [{ id, t, minute, text, owner, cat, cat_by, by: 'agent', open?, check?, dropped? }];
8
+ * an open bullet has cat null. bullets_state: { agent: 'on'|'off'|'failed',
9
+ * reason, session, next_at_ms, minute_ms, minutes }
10
+ * POST /api/notes/bullet { id, bullet, cat?, drop?, text? } → { ok, bullet, summary } the PM's correction to one bullet (Run 8)
6
11
  * POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
7
12
  * gets that note back (reused: true), so one meeting keeps one note (Run 4)
8
13
  * POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
9
14
  * GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
10
- * POST /api/notes/save { id, title?, body? } → { ok, updated_at }
15
+ * POST /api/notes/save { id, title?, body? } → { ok, updated_at, versions, added } each new line of the notes is also
16
+ * a transcript line, source 'typed' (`added`, Run 8)
11
17
  * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
12
- * POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
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
+ * 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
13
21
  * POST /api/notes/transcript { id, text, source? } → { ok, added: [{ at, t, text, source, cat, index }] }
14
22
  * POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
15
23
  * POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
@@ -19,11 +27,16 @@
19
27
  * POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
20
28
  * GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
21
29
  * POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
22
- * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
30
+ * { language?, model?, accuracy?, keep_audio? } → { ok, whisper } the language, the model, the decoding
31
+ * ('fast' | 'accurate') and keeping audio, for this machine
23
32
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
24
33
  * POST /api/notes/private { id, private } → { ok, private }
25
- * POST /api/notes/summarize { id, ws? } → { ok, summary, initiative } the local summary; with ws and no initiative yet,
26
- * the local suggestion is linked (by: 'local')
34
+ * POST /api/notes/summarize { id, ws?, prefer? } → { ok, summary, initiative, source, agent? } the local summary; with ws and
35
+ * no initiative yet, the local suggestion is linked (by: 'local'). With
36
+ * prefer: 'agent' the PM's agent session is also started, as summarize-agent
37
+ * does: source is 'agent' when it started (its file is imported by
38
+ * GET /api/notes/one), else 'local'; `agent` is summarize-agent's answer
39
+ * without `ok`, or { started: false, reason } when no agent was asked
27
40
  * POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
28
41
  * POST /api/notes/delete { id } → { ok }
29
42
  *
@@ -33,9 +46,14 @@
33
46
  * agent summary starts the PM's own agent session in the repository (ctx.spawnSession), as the skill draft does; Whisper runs
34
47
  * on this machine (lib/notes-whisper.js); a label or a check goes to Jev only through lib/jev-notes.js, behind the D91 gates.
35
48
  *
36
- * AFTER A LINE IS ADDED (Run 2). Each new line already has its local label. Then, one note at a time and in order: Jev's label
37
- * when the label gates hold; for a decision, action or statement, the closest repository passage (local, lib/notes-repo.js),
38
- * and Jev's comparison when the check gates hold too. The page reads the results with GET /api/notes/one.
49
+ * AFTER A LINE IS ADDED (Run 2, batched by Run 8). Each new line already has its local label (none while it is open: cut
50
+ * mid-sentence). Nothing goes to Jev per line: once a minute of meeting time the new lines are one batch (see "minute batches"
51
+ * below). With the PM's agent, the batch becomes bullets, and Jev labels and checks each closed bullet; without one, Jev labels
52
+ * the batch's lines and checks the checkable ones. One note at a time and in order: Jev's label when the label gates hold;
53
+ * for a decision, action, question or fact, the closest repository passage (local, lib/notes-repo.js), and Jev's comparison
54
+ * when the check gates hold too. The page reads the results with GET /api/notes/one.
55
+ * The minute is PMOS_NOTES_MINUTE_MS (or ctx.notesMinuteMs), default 60000; the bullets agent is PMOS_NOTES_BULLETS_AGENT
56
+ * (or ctx.notesBulletsAgent), default 'claude', and 'off' uses none.
39
57
  */
40
58
  const guard = require('./pm-guard.js');
41
59
  const notes = require('./notes.js');
@@ -46,23 +64,31 @@ const notesLinks = require('./notes-links.js');
46
64
  const jevNotes = require('./jev-notes.js');
47
65
  const tsSettings = require('./typesafe-settings.js');
48
66
 
67
+ const fs = require('fs');
68
+ const path = require('path');
69
+
49
70
  const MAX_PCM_BYTES = 16000 * 2 * 15; // 15 s of 16 kHz 16-bit audio in one request
50
- const CHECKED = new Set(['decision', 'action', 'statement']);
71
+ const VOCAB_TTL_MS = 60 * 1000;
51
72
 
52
73
  const prefixes = ['/api/notes'];
53
74
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
54
- '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
55
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link']);
75
+ '/api/notes/private', '/api/notes/pause', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
76
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link', '/api/notes/bullet']);
56
77
 
57
78
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
58
- const chains = new Map(); // note id → the promise of its enrichment so far
59
- function enrich(ctx, id, added) {
60
- if (!added || !added.length) return Promise.resolve();
61
- const next = (chains.get(id) || Promise.resolve()).then(() => enrichNow(ctx, id, added)).catch(() => {});
79
+ const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
80
+ /** Run `fn` after the note's earlier work, so one note's labels and checks happen one at a time and in order. */
81
+ function chain(id, fn) {
82
+ const next = (chains.get(id) || Promise.resolve()).then(fn).catch(() => {});
62
83
  chains.set(id, next);
63
84
  next.then(() => { if (chains.get(id) === next) chains.delete(id); });
64
85
  return next;
65
86
  }
87
+ /** Label and check `added` (transcript lines, or bullets with kind 'bullets') after the note's earlier work. */
88
+ function enrich(ctx, id, added, kind = 'lines') {
89
+ if (!added || !added.length) return Promise.resolve();
90
+ return chain(id, () => enrichNow(ctx, id, added, kind));
91
+ }
66
92
  function gatesFor(ctx, root) {
67
93
  const dir = ctx.runtimeDir;
68
94
  if (!root) return { root: null, exclude: [], ask: null, check: false, key: null, askOpts: {} };
@@ -73,29 +99,187 @@ function gatesFor(ctx, root) {
73
99
  return { root, exclude: repo.exclude, ask: snd.ask, check: snd.check, key: snd.ask ? key : null, askOpts: snd.askOpts };
74
100
  }
75
101
  const stillOpen = (dir, id) => { const r = notes.read(dir, id); return r.ok && !r.note.private ? r.note : null; };
76
- async function enrichNow(ctx, id, added) {
102
+ /** Jev's label (when the label gates hold), then for a checkable item the closest repository passage and Jev's comparison
103
+ * (when the check gates hold too). `kind` 'lines': `items` are transcript lines ({ index, text, cat, cat_by }); 'bullets':
104
+ * they are the agent's bullets ({ id, text, cat, cat_by }). */
105
+ async function enrichNow(ctx, id, items, kind = 'lines') {
77
106
  const dir = ctx.runtimeDir;
78
107
  const note = stillOpen(dir, id);
79
108
  if (!note) return;
80
109
  const w = note.ws ? ctx.getWs(note.ws) : null;
81
110
  const g = gatesFor(ctx, w ? w.root : null);
82
- for (const seg of added) {
83
- let cat = seg.cat;
111
+ const put = (x, patch) => (kind === 'bullets'
112
+ ? notes.updateBullet(dir, { id, bid: x.id, text: x.text, patch })
113
+ : notes.updateSegment(dir, { id, index: x.index, text: x.text, patch }));
114
+ for (const x of items) {
115
+ let cat = x.cat;
116
+ let catBy = x.cat_by;
84
117
  if (g.ask && stillOpen(dir, id)) {
85
- const l = await jevNotes.label({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, root: g.root });
86
- if (l.cat) { const u = notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { cat: l.cat, cat_by: 'jev' } }); if (u.ok) cat = u.segment.cat; }
87
- else if (l.reason === 'failed' || l.reason === 'paused') notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { jev: l.reason } });
118
+ const l = await jevNotes.label({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: x.text, root: g.root });
119
+ // a label below the bar (a fragment called a decision, a backchannel called a question) keeps the local label
120
+ if (l.cat && notes.meetsBar(l.cat, x.text)) { const u = put(x, { cat: l.cat, cat_by: 'jev' }); if (u.ok) { const v = u.segment || u.bullet; cat = v.cat; catBy = v.cat_by; } }
121
+ else if (l.reason === 'failed' || l.reason === 'paused') put(x, { jev: l.reason });
88
122
  }
89
- if (!g.root || !CHECKED.has(cat)) continue;
90
- const pass = notesRepo.closest(g.root, seg.text, { exclude: g.exclude });
123
+ // only an item that survives as a decision, action, question or fact, with 5 words or more, is checked
124
+ if (!g.root || !notes.checkable({ text: x.text, cat, cat_by: catBy })) continue;
125
+ const pass = notesRepo.closest(g.root, x.text, { exclude: g.exclude });
91
126
  if (!pass) continue;
92
127
  let check = { doc: pass.path, line: pass.line, verdict: 'related', by: 'local' };
93
128
  if (g.ask && g.check && stillOpen(dir, id)) {
94
- const c = await jevNotes.compare({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: seg.text, passage: pass, root: g.root });
129
+ const c = await jevNotes.compare({ ask: g.ask, key: g.key, askOpts: g.askOpts, text: x.text, passage: pass, root: g.root });
95
130
  if (c.verdict) check = { doc: pass.path, line: pass.line, verdict: c.verdict, words: c.words || null, by: 'jev' };
96
131
  }
97
- notes.updateSegment(dir, { id, index: seg.index, text: seg.text, patch: { check } });
132
+ put(x, { check });
133
+ }
134
+ }
135
+
136
+ /* ── minute batches and the agent's bullets (Run 8) ─────────────────────────────────────────────── */
137
+ /* While a note listens, every MINUTE_MS of meeting time (and once more when listening stops, and on a summary after Stop)
138
+ * the lines added since the last batch become one batch. With an agent: the batch is a minute file, and the note's one
139
+ * bullets session is told to write its bullets (the first batch starts the session with the whole job; a later batch is
140
+ * typed into the same session; a session that is gone is started again). The bullets are imported on each read of the note
141
+ * and on each tick, then Jev labels each one and the checkable ones are checked against the repository. Without an agent
142
+ * (switched off, no sessions, or it could not start): Jev labels the batch's lines and checks the checkable ones, as each
143
+ * line was before, but once a minute. A private note is never batched or handed off. A note that is not receiving audio
144
+ * after a restart does not tick: the timer starts on Listen, and again on the next audio or transcript line. */
145
+ const MINUTE_MS = 60 * 1000;
146
+ const minuteMsOf = (ctx) => { const v = Number(ctx.notesMinuteMs || process.env.PMOS_NOTES_MINUTE_MS); return Number.isFinite(v) && v >= 100 ? v : MINUTE_MS; };
147
+ // the bullets agent's command: 'claude' unless ctx.notesBulletsAgent or PMOS_NOTES_BULLETS_AGENT names another; 'off' uses none
148
+ const bulletsAgentOf = (ctx) => { const v = ctx.notesBulletsAgent !== undefined ? ctx.notesBulletsAgent : process.env.PMOS_NOTES_BULLETS_AGENT; return v ? String(v) : 'claude'; };
149
+ const timers = new Map(); // note id → { timer, next_at_ms }
150
+ const listening = (n) => !!(n && !n.private && n.recorded_from && !n.paused_at);
151
+ /** Start the note's minute timer, when it listens and has none. */
152
+ function arm(ctx, id) {
153
+ if (timers.has(id)) return;
154
+ const r = notes.read(ctx.runtimeDir, id);
155
+ if (!r.ok || !listening(r.note)) return;
156
+ const ms = minuteMsOf(ctx);
157
+ const t = { timer: null, next_at_ms: Math.round(notes.meetingMs(r.note)) + ms };
158
+ t.timer = setTimeout(() => { if (timers.get(id) === t) timers.delete(id); runMinute(ctx, id).then(() => arm(ctx, id)); }, ms);
159
+ if (t.timer.unref) t.timer.unref();
160
+ timers.set(id, t);
161
+ }
162
+ function disarm(id) { const t = timers.get(id); if (t) { clearTimeout(t.timer); timers.delete(id); } }
163
+ /** One minute's work for a note, after its earlier work: import what the agent wrote, then the next batch. */
164
+ function runMinute(ctx, id, { final = false } = {}) { return chain(id, () => minuteNow(ctx, id, { final })); }
165
+ async function importNow(ctx, id) {
166
+ const r = notes.importBullets(ctx.runtimeDir, { id });
167
+ if (r.added.length) await enrichNow(ctx, id, r.added, 'bullets');
168
+ }
169
+ /** The late minutes (handed to the agent, no bullets by their deadline) go through the line fallback. Then the next
170
+ * deadline gets a timer, so a minute handed off at Stop is caught even though the minute timer has stopped. */
171
+ const lateTimers = new Map(); // note id → timer
172
+ async function lateNow(ctx, id) {
173
+ const dir = ctx.runtimeDir;
174
+ if (!stillOpen(dir, id)) return;
175
+ await importNow(ctx, id);
176
+ const r = notes.lateMinutes(dir, { id, minuteMs: minuteMsOf(ctx) });
177
+ for (const x of r.late) {
178
+ notes.markFallback(dir, { id, minute: x.minute });
179
+ await enrichNow(ctx, id, x.lines.filter((l) => l.cat && notes.meetsBar(l.cat, l.text)), 'lines');
180
+ }
181
+ watchLate(ctx, id, r.next_at);
182
+ }
183
+ function watchLate(ctx, id, at) {
184
+ const old = lateTimers.get(id); if (old) { clearTimeout(old); lateTimers.delete(id); }
185
+ if (at == null) return;
186
+ const t = setTimeout(() => { lateTimers.delete(id); chain(id, () => lateNow(ctx, id)); }, Math.max(20, at - Date.now() + 20));
187
+ if (t.unref) t.unref();
188
+ lateTimers.set(id, t);
189
+ }
190
+ function unwatchLate(id) { const t = lateTimers.get(id); if (t) { clearTimeout(t); lateTimers.delete(id); } }
191
+ async function minuteNow(ctx, id, { final }) {
192
+ const dir = ctx.runtimeDir;
193
+ if (!stillOpen(dir, id)) return;
194
+ await lateNow(ctx, id);
195
+ const b = notes.takeBatch(dir, { id, final });
196
+ if (!b.ok || !b.minute) return;
197
+ const note = stillOpen(dir, id);
198
+ if (!note) return;
199
+ if (handToAgent(ctx, id, note, b)) {
200
+ notes.minuteHanded(dir, { id, minute: b.minute, lines: b.lines, final: b.final });
201
+ const r = notes.lateMinutes(dir, { id, minuteMs: minuteMsOf(ctx) });
202
+ watchLate(ctx, id, r.next_at);
203
+ return;
204
+ }
205
+ // no agent: Jev labels the lines that meet the bar for their local label, and the checkable ones are checked. A batch
206
+ // never holds an open line (takeBatch leaves it for later; pause closes it), so a line cut mid-sentence is never sent.
207
+ await enrichNow(ctx, id, b.lines.filter((x) => !x.open && x.cat && notes.meetsBar(x.cat, x.text)), 'lines');
208
+ // the meeting ended and no agent will close the points still open: they are closed here, then labelled and checked
209
+ if (b.final) { const c = notes.closeOpenBullets(dir, { id }); if (c.closed.length) await enrichNow(ctx, id, c.closed, 'bullets'); }
210
+ }
211
+ /** The batch to the note's bullets session. → true when an agent has it; false when the fallback must run. */
212
+ function handToAgent(ctx, id, note, b) {
213
+ const dir = ctx.runtimeDir;
214
+ const m = note.minutes || {};
215
+ const cmd = bulletsAgentOf(ctx);
216
+ if (cmd === 'off') { notes.setMinutesAgent(dir, { id, agent: 'off', reason: 'the bullets agent is switched off on this machine' }); return false; }
217
+ if (typeof ctx.spawnSession !== 'function') { notes.setMinutesAgent(dir, { id, agent: 'off', reason: 'this workspace cannot start sessions' }); return false; }
218
+ if (m.agent === 'failed') return false;
219
+ const h = notes.minuteHandoff(dir, { id, minute: b.minute, lines: b.lines, context: b.context, final: b.final, terms: repoTermsFor(ctx, note) });
220
+ if (!h.ok) return false;
221
+ if (m.agent === 'on' && m.session && typeof ctx.sendToSession === 'function' && ctx.sendToSession(m.session, notes.bulletsFollowUp(h))) return true;
222
+ const w = note.ws ? ctx.getWs(note.ws) : null;
223
+ const s = ctx.spawnSession({ name: 'notes: bullets', agent: cmd, ws: w ? w.id : undefined, origin: 'notes-bullets:' + id, initialInput: notes.bulletsPrompt(h) + '\r' });
224
+ const started = !!s && s.state !== 'exited';
225
+ if (!started) {
226
+ try { fs.unlinkSync(h.md); } catch { /* gone */ }
227
+ notes.setMinutesAgent(dir, { id, agent: 'failed', reason: (s && s.note) || 'could not start a ' + cmd + ' session', session: null });
228
+ return false;
229
+ }
230
+ notes.setMinutesAgent(dir, { id, agent: 'on', reason: '', session: s.id });
231
+ return true;
232
+ }
233
+ /** What the page shows about the bullets: who makes them, why not, the session, and when the next minute is taken. */
234
+ function bulletsState(ctx, note) {
235
+ const m = note.minutes || {};
236
+ const t = timers.get(note.id);
237
+ const cannot = note.private ? 'this note is marked private' : bulletsAgentOf(ctx) === 'off' ? 'the bullets agent is switched off on this machine'
238
+ : typeof ctx.spawnSession !== 'function' ? 'this workspace cannot start sessions' : '';
239
+ const agent = cannot ? 'off' : (m.agent || 'on');
240
+ const imported = new Set((m.imported || []).map(Number));
241
+ const waiting = Object.keys(m.handed || {}).map(Number).filter((n) => !imported.has(n) && !(m.fallback || []).includes(n)).length;
242
+ return { agent, reason: cannot || m.reason || '', session: agent === 'on' ? m.session || null : null, next_at_ms: t ? t.next_at_ms : null, minute_ms: minuteMsOf(ctx), minutes: Number(m.n) || 0,
243
+ late: (m.fallback || []).length, waiting };
244
+ }
245
+
246
+ /* ── the names and terms of a note and its repository, cached ───────────────────────────────────── */
247
+ // A repository glossary, when it has one: bold terms, "- Term:" items and small headings. Read at most 64 KB.
248
+ const GLOSSARY_FILES = ['GLOSSARY.md', 'glossary.md', 'planning/glossary.md', 'docs/glossary.md', 'okf/glossary.md'];
249
+ const glossaryCache = new Map(); // root → { at, terms }
250
+ function glossaryOf(root) {
251
+ const c = glossaryCache.get(root);
252
+ if (c && Date.now() - c.at < VOCAB_TTL_MS) return c.terms;
253
+ const terms = [];
254
+ for (const rel of GLOSSARY_FILES) {
255
+ let text = '';
256
+ try { const fd = fs.openSync(path.join(root, rel), 'r'); try { const b = Buffer.alloc(64 * 1024); text = b.subarray(0, fs.readSync(fd, b, 0, b.length, 0)).toString('utf8'); } finally { fs.closeSync(fd); } } catch { continue; }
257
+ for (const m of text.matchAll(/^\s*(?:[-*]\s+)?\*\*([^*\n]{2,40}?)\*\*/gm)) terms.push(m[1]);
258
+ for (const m of text.matchAll(/^\s*[-*]\s+([A-Za-z][\w .&/-]{1,38}?)\s*[:—–]\s/gm)) terms.push(m[1]);
259
+ for (const m of text.matchAll(/^#{2,4}\s+([^\n#]{2,40})$/gm)) terms.push(m[1]);
260
+ break;
98
261
  }
262
+ const out = terms.map((t) => t.replace(/\s*[—–(].*$/, '').trim()).filter(Boolean).slice(0, 40);
263
+ glossaryCache.set(root, { at: Date.now(), terms: out });
264
+ return out;
265
+ }
266
+ /** The repository's terms for a note: its initiative's title, the glossary, and the product terms in the open initiatives' titles. */
267
+ function repoTermsFor(ctx, note) {
268
+ const w = note && note.ws && typeof ctx.getWs === 'function' ? ctx.getWs(note.ws) : null;
269
+ const linked = note && note.initiative && note.initiative.title ? [note.initiative.title] : [];
270
+ if (!w || !w.root) return linked;
271
+ const open = notesLinks.initiativesOf(w.root);
272
+ return [...linked, ...glossaryOf(w.root), ...notes.termsIn(open.map((i) => i.title))];
273
+ }
274
+ const vocabCache = new Map(); // note id → { at, text }
275
+ function vocabFor(ctx, id) {
276
+ const c = vocabCache.get(id);
277
+ if (c && Date.now() - c.at < VOCAB_TTL_MS) return c.text;
278
+ const r = notes.read(ctx.runtimeDir, id);
279
+ const text = r.ok && !r.note.private ? notes.vocabOf(r.note, { terms: repoTermsFor(ctx, r.note) }).text : '';
280
+ vocabCache.set(id, { at: Date.now(), text });
281
+ if (vocabCache.size > 200) vocabCache.delete(vocabCache.keys().next().value);
282
+ return text;
99
283
  }
100
284
 
101
285
  /* ── local Whisper: one transcriber for the process ───────────────────────────────────────────── */
@@ -106,9 +290,10 @@ function transcriberFor(ctx) {
106
290
  transcriberDir = ctx.runtimeDir;
107
291
  transcriber = whisper.createTranscriber({
108
292
  runtimeDir: ctx.runtimeDir,
293
+ vocab: ({ id }) => vocabFor(ctx, id),
109
294
  onText: async (c) => {
110
- const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t });
111
- if (r.ok) { audioErrors.delete(c.id); await enrich(ctx, c.id, r.added).catch(() => {}); }
295
+ const r = notes.appendTranscript(ctx.runtimeDir, { id: c.id, text: c.text, source: c.source, t: c.t, ms: c.ms });
296
+ if (r.ok) { audioErrors.delete(c.id); arm(ctx, c.id); }
112
297
  },
113
298
  onError: (c) => { audioErrors.set(c.id, { at: new Date().toISOString(), error: c.error }); },
114
299
  });
@@ -118,7 +303,8 @@ function engineInfo(ctx, w) {
118
303
  const d = whisper.detect(ctx.runtimeDir);
119
304
  const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
120
305
  language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
121
- model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
306
+ model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })),
307
+ accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
122
308
  if (!w) return { whisper: out, jev: null };
123
309
  const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
124
310
  const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
@@ -136,8 +322,15 @@ async function handle(req, res, url, ctx) {
136
322
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
137
323
  if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
138
324
  const nid = url.searchParams.get('id');
325
+ // the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
326
+ const imp = notes.importBullets(dir, { id: nid });
327
+ if (imp.added.length) enrich(ctx, nid, imp.added, 'bullets');
139
328
  const r = notes.read(dir, nid);
140
- if (r.ok) { r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); }
329
+ if (r.ok) {
330
+ r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid);
331
+ if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
332
+ r.bullets_state = bulletsState(ctx, r.note);
333
+ }
141
334
  send(ctx, res, r);
142
335
  return true;
143
336
  }
@@ -192,7 +385,11 @@ async function handle(req, res, url, ctx) {
192
385
  // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
193
386
  if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
194
387
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
195
- if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
388
+ if (p === '/api/notes/save') {
389
+ const r = notes.save(dir, { id, title: body.title, body: body.body });
390
+ if (r.ok && r.added && r.added.length) arm(ctx, id);
391
+ return done(r);
392
+ }
196
393
  const wsOf = () => {
197
394
  if (body.ws == null) return { ok: true, w: null };
198
395
  const w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
@@ -201,11 +398,24 @@ async function handle(req, res, url, ctx) {
201
398
  if (p === '/api/notes/record') {
202
399
  const o = wsOf();
203
400
  if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
204
- return done(notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id }));
401
+ const r = notes.record(dir, { id, ws: body.ws == null ? undefined : o.w.id });
402
+ if (r.ok) {
403
+ // a new Listen tries the agent again when it could not start last time
404
+ const cur = notes.read(dir, id);
405
+ if (cur.ok && cur.note.minutes && cur.note.minutes.agent === 'failed') notes.setMinutesAgent(dir, { id, agent: null, reason: '' });
406
+ arm(ctx, id);
407
+ }
408
+ return done(r);
409
+ }
410
+ if (p === '/api/notes/pause') {
411
+ const r = notes.pause(dir, { id });
412
+ // listening stopped: the minute timer stops, and what was said since the last minute is one more batch
413
+ if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); }
414
+ return done(r);
205
415
  }
206
416
  if (p === '/api/notes/transcript') {
207
417
  const r = notes.appendTranscript(dir, { id, text: body.text, source: 'speech' });
208
- if (r.ok) enrich(ctx, id, r.added);
418
+ if (r.ok) arm(ctx, id);
209
419
  return done(r);
210
420
  }
211
421
  if (p === '/api/notes/audio') {
@@ -221,13 +431,15 @@ async function handle(req, res, url, ctx) {
221
431
  if (pcm.length % 2) return done({ ok: false, status: 400, error: 'the audio must be 16-bit samples' });
222
432
  if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
223
433
  const ms = (pcm.length / 2 / whisper.RATE) * 1000;
224
- const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
434
+ const t = Math.max(0, notes.meetingMs(note.note) - ms);
225
435
  // the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
436
+ arm(ctx, id);
226
437
  if (notesAudio.getKeep(dir)) { try { notesAudio.write(dir, { id, source, pcm, t }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
227
438
  return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
228
439
  }
229
440
  if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
230
441
  if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
442
+ if (p === '/api/notes/bullet') return done(notes.editBullet(dir, { id, bid: body.bullet, cat: body.cat, drop: body.drop, text: body.text }));
231
443
  if (p === '/api/notes/line') {
232
444
  const pick = (k) => (body[k] === undefined ? undefined : body[k]);
233
445
  return done(notes.editLine(dir, { id, index: Number(body.index), cat: pick('cat'), summary: pick('summary'), tags: pick('tags'), people: pick('people'), text: pick('text') }));
@@ -240,11 +452,12 @@ async function handle(req, res, url, ctx) {
240
452
  if (p === '/api/notes/switch') {
241
453
  const o = wsOf();
242
454
  // the spoken language belongs to this machine, not to a repository
243
- if (body.language !== undefined || body.model !== undefined || body.keep_audio !== undefined) {
455
+ if (body.language !== undefined || body.model !== undefined || body.accuracy !== undefined || body.keep_audio !== undefined) {
244
456
  const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
245
457
  const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
246
- const k = r.ok && m.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
247
- if (!r.ok || !m.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || k.error }, 400); return true; }
458
+ const a = r.ok && m.ok && body.accuracy !== undefined ? whisper.setAccuracy(dir, String(body.accuracy)) : { ok: true };
459
+ const k = r.ok && m.ok && a.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
460
+ if (!r.ok || !m.ok || !a.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || a.error || k.error }, 400); return true; }
248
461
  if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
249
462
  }
250
463
  if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
@@ -264,11 +477,15 @@ async function handle(req, res, url, ctx) {
264
477
  return true;
265
478
  }
266
479
  if (p === '/api/notes/private') {
480
+ vocabCache.delete(id);
267
481
  const r = notes.setPrivate(dir, { id, private: body.private });
268
- if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
482
+ if (r.ok && r.private) { disarm(id); unwatchLate(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
269
483
  return done(r);
270
484
  }
271
485
  if (p === '/api/notes/summarize') {
486
+ // after Stop, what was said since the last minute is one more batch (nothing when the pause already took it)
487
+ // a note that was never recorded (typed lines only) gets its batch here too
488
+ { const cur0 = notes.read(dir, id); if (cur0.ok && !cur0.note.private && !listening(cur0.note)) runMinute(ctx, id, { final: true }); }
272
489
  const r = notes.localSummary(dir, { id });
273
490
  if (!r.ok) return done(r);
274
491
  const ws = wsOf();
@@ -278,28 +495,41 @@ async function handle(req, res, url, ctx) {
278
495
  if (s.pick) { const l = notes.link(dir, { id, initiative: { id: s.pick.id, ws: ws.w.id, title: s.pick.title, shared: s.pick.shared }, by: 'local' }); if (l.ok) r.initiative = l.note.initiative; }
279
496
  }
280
497
  if (r.initiative === undefined) r.initiative = cur.ok ? cur.note.initiative || null : null;
498
+ r.source = 'local';
499
+ if (body.prefer === 'agent') {
500
+ // the agent's summary is the default when an agent can be asked; the local one is already stored as the fallback
501
+ const ag = cur.ok && cur.note.private ? { ok: false, error: 'this note is marked private, so it is not handed to any agent or service' }
502
+ : startAgent(ctx, dir, id, ws.ok ? ws.w : null);
503
+ r.agent = ag.ok ? (({ ok, ...x }) => x)(ag) : { started: false, reason: ag.error || 'the agent could not be asked' };
504
+ if (r.agent.started) r.source = 'agent';
505
+ }
281
506
  return done(r);
282
507
  }
283
- if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
508
+ if (p === '/api/notes/delete') { disarm(id); unwatchLate(id); if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
284
509
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
285
510
  let w = null;
286
511
  if (body.ws != null) {
287
512
  w = typeof body.ws === 'string' ? ctx.getWs(body.ws) : null;
288
513
  if (!w || w.id !== body.ws) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
289
514
  }
290
- const h = notes.agentHandoff(dir, { id });
291
- if (!h.ok) return done(h);
515
+ return done(startAgent(ctx, dir, id, w));
516
+ }
517
+
518
+ /** The handoff file (refused for a private note), then the PM's own agent session in the repository `w`, or none.
519
+ * → { ok, started, session, reason, handoff, out, withheld } or an error. */
520
+ function startAgent(ctx, dir, id, w) {
521
+ const cur = notes.read(dir, id);
522
+ const h = notes.agentHandoff(dir, { id, terms: cur.ok ? repoTermsFor(ctx, w ? { ...cur.note, ws: w.id } : cur.note) : [] });
523
+ if (!h.ok) return h;
292
524
  if (typeof ctx.spawnSession !== 'function') {
293
525
  notes.agentStarted(dir, { id, started: false, reason: 'this workspace cannot start sessions' });
294
- ctx.json(res, { ok: true, started: false, reason: 'this workspace cannot start sessions', handoff: h.handoff, out: h.out, withheld: h.withheld });
295
- return true;
526
+ return { ok: true, started: false, session: null, reason: 'this workspace cannot start sessions', handoff: h.handoff, out: h.out, withheld: h.withheld };
296
527
  }
297
528
  const s = ctx.spawnSession({ name: 'notes: summary', agent: 'claude', ws: w ? w.id : undefined, origin: 'notes-summary:' + id, initialInput: h.prompt + '\r' });
298
529
  const started = !!s && s.state !== 'exited';
299
530
  const reason = started ? '' : ((s && s.note) || 'could not start a claude session');
300
531
  notes.agentStarted(dir, { id, session: s ? s.id : null, started, reason });
301
- ctx.json(res, { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld });
302
- return true;
532
+ return { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld };
303
533
  }
304
534
 
305
- module.exports = { prefixes, handle, enrich, __idle: () => Promise.all([transcriber ? transcriber.idle() : null, ...chains.values()]) };
535
+ module.exports = { prefixes, handle, enrich, runMinute, bulletsState, __idle: () => Promise.all([transcriber ? transcriber.idle() : null, ...chains.values()]) };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.75.0",
3
+ "version": "0.76.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.75.0",
3
+ "version": "0.76.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",