create-pmos 0.76.0 → 0.78.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/layout.md +10 -11
- package/workspace/guide/notes.md +32 -3
- package/workspace/lib/mobile-nav.js +9 -2
- package/workspace/lib/notes-audio.js +41 -11
- package/workspace/lib/notes-diarize.py +61 -0
- package/workspace/lib/notes-final.js +293 -0
- package/workspace/lib/notes.js +268 -29
- package/workspace/lib/routes-notes.js +175 -8
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +14 -20
- package/workspace/public/pm/components.css +27 -8
- package/workspace/public/pm/mobile.css +77 -93
- package/workspace/public/pm/mobile.js +6 -2
- package/workspace/public/pm/phone-deck.js +67 -143
- package/workspace/public/pm/view-notes.js +159 -7
- package/workspace/public/vendor/lucide-sprite.js +9 -2
- package/workspace/test-lists/meeting-notes.json +5 -3
- package/workspace/test-timings.json +1 -0
- package/workspace/third-party/README.md +1 -1
|
@@ -60,6 +60,7 @@ const notes = require('./notes.js');
|
|
|
60
60
|
const whisper = require('./notes-whisper.js');
|
|
61
61
|
const notesRepo = require('./notes-repo.js');
|
|
62
62
|
const notesAudio = require('./notes-audio.js');
|
|
63
|
+
const notesFinal = require('./notes-final.js');
|
|
63
64
|
const notesLinks = require('./notes-links.js');
|
|
64
65
|
const jevNotes = require('./jev-notes.js');
|
|
65
66
|
const tsSettings = require('./typesafe-settings.js');
|
|
@@ -73,7 +74,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
73
74
|
const prefixes = ['/api/notes'];
|
|
74
75
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
75
76
|
'/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']);
|
|
77
|
+
'/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/link', '/api/notes/bullet']);
|
|
77
78
|
|
|
78
79
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
79
80
|
const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
|
|
@@ -304,7 +305,8 @@ function engineInfo(ctx, w) {
|
|
|
304
305
|
const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
|
|
305
306
|
language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
|
|
306
307
|
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)
|
|
308
|
+
accuracy: whisper.getAccuracy(ctx.runtimeDir), accuracies: whisper.ACCURACIES.map((a) => ({ value: a.id, label: a.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir),
|
|
309
|
+
final: finalInfo(ctx), wispr: wisprOn(ctx) };
|
|
308
310
|
if (!w) return { whisper: out, jev: null };
|
|
309
311
|
const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
|
|
310
312
|
const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
|
|
@@ -312,6 +314,134 @@ function engineInfo(ctx, w) {
|
|
|
312
314
|
return { whisper: out, jev: { ...info, paused: jevNotes.pauseStatus(w.root) } };
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
/* ── the final pass and Wispr Flow's notes (meeting-notes Run 9) ───────────────────────────────────── */
|
|
318
|
+
/* AFTER STOP. The summary the page asks for after Stop (prefer: 'agent') queues the final pass (lib/notes-final.js) when an
|
|
319
|
+
* engine is set up and the note kept audio: the whole meeting is transcribed again, the speakers are told apart, and then
|
|
320
|
+
* the PM's agent session writes the notes from that transcript (startAgent). Without an engine or audio, the agent writes
|
|
321
|
+
* them from the live transcript at once, as before. Listen again drops a queued pass; the next Stop queues a new one.
|
|
322
|
+
* The audio of each source is written for the pass even when the PM keeps no audio for replay; then it is deleted when the
|
|
323
|
+
* pass ends. PMOS_NOTES_FINAL (or ctx.notesFinal) 'off' skips the pass.
|
|
324
|
+
*
|
|
325
|
+
* WISPR FLOW. When the PM uses Wispr Flow in the same meeting, its notes are fetched after Stop: WISPR_DELAY_MS later the
|
|
326
|
+
* PM's agent session is asked to find the meeting with the Wispr Flow connector and copy its summary (notes.wisprHandoff);
|
|
327
|
+
* while it is not ready, it is asked again every WISPR_RETRY_MS, up to WISPR_DEADLINE_MS after Stop. The workspace itself
|
|
328
|
+
* makes no request: the agent session does, with the connector the PM signed in to. On by default ("Fetch Wispr Flow
|
|
329
|
+
* notes" in the settings); PMOS_NOTES_WISPR (or ctx.notesWispr) 'off' turns it off on this machine. Never for a private note. */
|
|
330
|
+
const finalOff = (ctx) => String(ctx.notesFinal !== undefined ? ctx.notesFinal : process.env.PMOS_NOTES_FINAL || '') === 'off';
|
|
331
|
+
let finalRunner = null; let finalDir = null;
|
|
332
|
+
function finalFor(ctx) {
|
|
333
|
+
if (finalRunner && finalDir === ctx.runtimeDir) return finalRunner;
|
|
334
|
+
finalDir = ctx.runtimeDir;
|
|
335
|
+
finalRunner = notesFinal.createFinal({
|
|
336
|
+
runtimeDir: ctx.runtimeDir,
|
|
337
|
+
...(ctx.notesFinalRun ? { run: ctx.notesFinalRun } : {}),
|
|
338
|
+
...(ctx.notesFinalDetect ? { detect: ctx.notesFinalDetect } : {}),
|
|
339
|
+
onState: (id, st) => { if (st.state !== 'done') notes.setFinalState(ctx.runtimeDir, { id, state: st }); },
|
|
340
|
+
onDone: (id, final) => {
|
|
341
|
+
const dir = ctx.runtimeDir;
|
|
342
|
+
const r = notes.setFinal(dir, { id, final });
|
|
343
|
+
if (!notesAudio.getKeep(dir)) notesAudio.removeSources(dir, id);
|
|
344
|
+
if (!r.ok) return;
|
|
345
|
+
const cur = notes.read(dir, id);
|
|
346
|
+
const w = cur.ok && cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
347
|
+
startAgent(ctx, dir, id, w && w.id === cur.note.ws ? w : null);
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return finalRunner;
|
|
351
|
+
}
|
|
352
|
+
function finalInfo(ctx) {
|
|
353
|
+
const d = ctx.notesFinalDetect ? ctx.notesFinalDetect() : notesFinal.detect(ctx.runtimeDir);
|
|
354
|
+
return { ready: !!d.ready && !finalOff(ctx), transcriber: d.transcriber || null, vad: !!d.vad, speakers: !!d.diarize, missing: d.missing || [], setup: d.setup || '' };
|
|
355
|
+
}
|
|
356
|
+
/** Queue the final pass of a note when it can run. → { queued, reason } */
|
|
357
|
+
function queueFinal(ctx, id) {
|
|
358
|
+
const dir = ctx.runtimeDir;
|
|
359
|
+
const cur = notes.read(dir, id);
|
|
360
|
+
if (!cur.ok || cur.note.private) return { queued: false, reason: 'this note is marked private' };
|
|
361
|
+
if (finalOff(ctx)) return { queued: false, reason: 'the final pass is switched off on this machine' };
|
|
362
|
+
const info = finalInfo(ctx);
|
|
363
|
+
if (!info.ready) return { queued: false, reason: 'local transcription is not set up' };
|
|
364
|
+
if (!notesAudio.sources(dir, id).length) return { queued: false, reason: 'no audio was kept for this note' };
|
|
365
|
+
const prompt = whisper.promptFor(vocabFor(ctx, id), '');
|
|
366
|
+
finalFor(ctx).queue(id, { prompt });
|
|
367
|
+
notes.setFinalState(dir, { id, state: { state: 'queued' } });
|
|
368
|
+
return { queued: true, reason: '' };
|
|
369
|
+
}
|
|
370
|
+
function cancelFinal(ctx, id) { if (finalRunner) finalRunner.cancel(id); }
|
|
371
|
+
|
|
372
|
+
const WISPR_DELAY_MS = 5 * 60 * 1000;
|
|
373
|
+
const WISPR_RETRY_MS = 2 * 60 * 1000;
|
|
374
|
+
const WISPR_DEADLINE_MS = 20 * 60 * 1000;
|
|
375
|
+
const numOf = (v, d, min = 50) => { const n = Number(v); return Number.isFinite(n) && n >= min ? n : d; };
|
|
376
|
+
const wisprDelay = (ctx) => numOf(ctx.notesWisprDelayMs !== undefined ? ctx.notesWisprDelayMs : process.env.PMOS_NOTES_WISPR_DELAY_MS, WISPR_DELAY_MS);
|
|
377
|
+
const wisprRetry = (ctx) => numOf(ctx.notesWisprRetryMs !== undefined ? ctx.notesWisprRetryMs : process.env.PMOS_NOTES_WISPR_RETRY_MS, WISPR_RETRY_MS);
|
|
378
|
+
const wisprDeadline = (ctx) => numOf(ctx.notesWisprDeadlineMs !== undefined ? ctx.notesWisprDeadlineMs : process.env.PMOS_NOTES_WISPR_DEADLINE_MS, WISPR_DEADLINE_MS);
|
|
379
|
+
const wisprSettingsFile = (dir) => path.join(dir, 'notes', 'wispr-settings.json');
|
|
380
|
+
function wisprOn(ctx) {
|
|
381
|
+
const env = String(ctx.notesWispr !== undefined ? ctx.notesWispr : process.env.PMOS_NOTES_WISPR || '');
|
|
382
|
+
if (env === 'off') return false;
|
|
383
|
+
try { return JSON.parse(fs.readFileSync(wisprSettingsFile(ctx.runtimeDir), 'utf8')).fetch !== false; } catch { return true; }
|
|
384
|
+
}
|
|
385
|
+
function setWisprOn(dir, on) {
|
|
386
|
+
const f = wisprSettingsFile(dir);
|
|
387
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
388
|
+
fs.writeFileSync(f, JSON.stringify({ fetch: !!on }), { mode: 0o600 });
|
|
389
|
+
}
|
|
390
|
+
const wisprTimers = new Map(); // note id → timer
|
|
391
|
+
function unwatchWispr(id) { const t = wisprTimers.get(id); if (t) { clearTimeout(t); wisprTimers.delete(id); } }
|
|
392
|
+
function wisprLater(ctx, id, ms, fn) {
|
|
393
|
+
unwatchWispr(id);
|
|
394
|
+
const t = setTimeout(() => { wisprTimers.delete(id); fn(); }, ms);
|
|
395
|
+
if (t.unref) t.unref();
|
|
396
|
+
wisprTimers.set(id, t);
|
|
397
|
+
}
|
|
398
|
+
/** Listening stopped: ask for Wispr Flow's notes WISPR_DELAY_MS from now. */
|
|
399
|
+
function scheduleWispr(ctx, id) {
|
|
400
|
+
const dir = ctx.runtimeDir;
|
|
401
|
+
const cur = notes.read(dir, id);
|
|
402
|
+
if (!cur.ok || cur.note.private || !cur.note.recorded_from || !wisprOn(ctx)) return;
|
|
403
|
+
if (cur.note.wispr && cur.note.wispr.state === 'found') return;
|
|
404
|
+
if (typeof ctx.spawnSession !== 'function') { notes.setWispr(dir, { id, patch: { state: 'off', reason: 'this workspace cannot start sessions' } }); return; }
|
|
405
|
+
const stop = Date.now();
|
|
406
|
+
notes.setWispr(dir, { id, patch: { state: 'waiting', due_at: new Date(stop + wisprDelay(ctx)).toISOString(), stopped_at: new Date(stop).toISOString(), tries: 0, reason: '' } });
|
|
407
|
+
wisprLater(ctx, id, wisprDelay(ctx), () => askWispr(ctx, id));
|
|
408
|
+
}
|
|
409
|
+
function askWispr(ctx, id) {
|
|
410
|
+
const dir = ctx.runtimeDir;
|
|
411
|
+
const cur = notes.read(dir, id);
|
|
412
|
+
if (!cur.ok || cur.note.private || !cur.note.wispr || listening(cur.note)) return;
|
|
413
|
+
const w0 = cur.note.wispr;
|
|
414
|
+
const stopped = Date.parse(w0.stopped_at) || Date.now();
|
|
415
|
+
const h = notes.wisprHandoff(dir, { id, until: stopped });
|
|
416
|
+
if (!h.ok) return;
|
|
417
|
+
const tries = (w0.tries || 0) + 1;
|
|
418
|
+
let session = w0.session || null;
|
|
419
|
+
const again = 'Try again: the Wispr Flow notes may be ready now. Read ' + h.handoff + ' again and write ' + h.out + ' as before.';
|
|
420
|
+
const sent = session && typeof ctx.sendToSession === 'function' && ctx.sendToSession(session, again + '\r');
|
|
421
|
+
if (!sent) {
|
|
422
|
+
const ws = cur.note.ws ? ctx.getWs(cur.note.ws) : null;
|
|
423
|
+
const s = ctx.spawnSession({ name: 'notes: Wispr Flow', agent: 'claude', ws: ws && ws.id === cur.note.ws ? ws.id : undefined, origin: 'notes-wispr:' + id, initialInput: h.prompt + '\r' });
|
|
424
|
+
if (!s || s.state === 'exited') { notes.setWispr(dir, { id, patch: { state: 'failed', reason: (s && s.note) || 'could not start a claude session', tries } }); return; }
|
|
425
|
+
session = s.id;
|
|
426
|
+
}
|
|
427
|
+
notes.setWispr(dir, { id, patch: { state: 'asking', tries, session, asked_at: new Date().toISOString() } });
|
|
428
|
+
wisprLater(ctx, id, wisprRetry(ctx), () => checkWispr(ctx, id));
|
|
429
|
+
}
|
|
430
|
+
function checkWispr(ctx, id) {
|
|
431
|
+
const dir = ctx.runtimeDir;
|
|
432
|
+
notes.importWispr(dir, { id });
|
|
433
|
+
const cur = notes.read(dir, id);
|
|
434
|
+
if (!cur.ok || !cur.note.wispr || cur.note.private) return;
|
|
435
|
+
const w = cur.note.wispr;
|
|
436
|
+
if (['found', 'not_found', 'no_connector', 'failed', 'off'].includes(w.state)) return;
|
|
437
|
+
const stopped = Date.parse(w.stopped_at) || 0;
|
|
438
|
+
if (Date.now() - stopped >= wisprDeadline(ctx)) {
|
|
439
|
+
notes.setWispr(dir, { id, patch: { state: 'failed', reason: 'Wispr Flow had no notes for this meeting ' + Math.round(wisprDeadline(ctx) / 60000) + ' minutes after Stop' } });
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
askWispr(ctx, id);
|
|
443
|
+
}
|
|
444
|
+
|
|
315
445
|
const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
|
|
316
446
|
|
|
317
447
|
async function handle(req, res, url, ctx) {
|
|
@@ -325,6 +455,7 @@ async function handle(req, res, url, ctx) {
|
|
|
325
455
|
// the bullets the agent wrote since the last read; Jev labels and checks them after the note's earlier work
|
|
326
456
|
const imp = notes.importBullets(dir, { id: nid });
|
|
327
457
|
if (imp.added.length) enrich(ctx, nid, imp.added, 'bullets');
|
|
458
|
+
notes.importWispr(dir, { id: nid });
|
|
328
459
|
const r = notes.read(dir, nid);
|
|
329
460
|
if (r.ok) {
|
|
330
461
|
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid);
|
|
@@ -403,6 +534,10 @@ async function handle(req, res, url, ctx) {
|
|
|
403
534
|
// a new Listen tries the agent again when it could not start last time
|
|
404
535
|
const cur = notes.read(dir, id);
|
|
405
536
|
if (cur.ok && cur.note.minutes && cur.note.minutes.agent === 'failed') notes.setMinutesAgent(dir, { id, agent: null, reason: '' });
|
|
537
|
+
// listening again: the final pass and the Wispr Flow fetch wait for the next Stop
|
|
538
|
+
cancelFinal(ctx, id); unwatchWispr(id);
|
|
539
|
+
if (cur.ok && cur.note.final_state && cur.note.final_state.state !== 'done') notes.setFinalState(dir, { id, state: null });
|
|
540
|
+
if (cur.ok && cur.note.wispr && cur.note.wispr.state !== 'found') notes.setWispr(dir, { id, patch: null });
|
|
406
541
|
arm(ctx, id);
|
|
407
542
|
}
|
|
408
543
|
return done(r);
|
|
@@ -410,7 +545,7 @@ async function handle(req, res, url, ctx) {
|
|
|
410
545
|
if (p === '/api/notes/pause') {
|
|
411
546
|
const r = notes.pause(dir, { id });
|
|
412
547
|
// 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 }); }
|
|
548
|
+
if (r.ok) { disarm(id); runMinute(ctx, id, { final: true }); scheduleWispr(ctx, id); }
|
|
414
549
|
return done(r);
|
|
415
550
|
}
|
|
416
551
|
if (p === '/api/notes/transcript') {
|
|
@@ -434,11 +569,21 @@ async function handle(req, res, url, ctx) {
|
|
|
434
569
|
const t = Math.max(0, notes.meetingMs(note.note) - ms);
|
|
435
570
|
// the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
|
|
436
571
|
arm(ctx, id);
|
|
437
|
-
|
|
572
|
+
// each source is also kept apart for the final pass after Stop, which deletes it when the PM keeps no audio (Run 9)
|
|
573
|
+
{ try { notesAudio.write(dir, { id, source, pcm, t, mixed: notesAudio.getKeep(dir) }); } catch (e) { audioErrors.set(id, { at: new Date().toISOString(), error: 'the audio was not kept: ' + ((e && e.message) || e) }); } }
|
|
438
574
|
return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
|
|
439
575
|
}
|
|
440
576
|
if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
|
|
441
|
-
if (p === '/api/notes/speaker')
|
|
577
|
+
if (p === '/api/notes/speaker') {
|
|
578
|
+
if (body.key !== undefined) return done(notes.renameFinalSpeaker(dir, { id, key: String(body.key), name: body.name }));
|
|
579
|
+
return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
|
|
580
|
+
}
|
|
581
|
+
if (p === '/api/notes/final') {
|
|
582
|
+
// the PM asks for the final pass again, for example after setting up a better engine
|
|
583
|
+
if (!notes.read(dir, id).ok) return done(notes.read(dir, id));
|
|
584
|
+
const q = queueFinal(ctx, id);
|
|
585
|
+
return done(q.queued ? { ok: true, queued: true } : { ok: false, status: 409, error: q.reason });
|
|
586
|
+
}
|
|
442
587
|
if (p === '/api/notes/bullet') return done(notes.editBullet(dir, { id, bid: body.bullet, cat: body.cat, drop: body.drop, text: body.text }));
|
|
443
588
|
if (p === '/api/notes/line') {
|
|
444
589
|
const pick = (k) => (body[k] === undefined ? undefined : body[k]);
|
|
@@ -452,6 +597,13 @@ async function handle(req, res, url, ctx) {
|
|
|
452
597
|
if (p === '/api/notes/switch') {
|
|
453
598
|
const o = wsOf();
|
|
454
599
|
// the spoken language belongs to this machine, not to a repository
|
|
600
|
+
if (body.wispr !== undefined) {
|
|
601
|
+
if (typeof body.wispr !== 'boolean') { ctx.json(res, { ok: false, error: 'say wispr: true or wispr: false' }, 400); return true; }
|
|
602
|
+
setWisprOn(dir, body.wispr);
|
|
603
|
+
if (body.language === undefined && body.model === undefined && body.accuracy === undefined && body.keep_audio === undefined && body.jev === undefined && body.check === undefined) {
|
|
604
|
+
ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
455
607
|
if (body.language !== undefined || body.model !== undefined || body.accuracy !== undefined || body.keep_audio !== undefined) {
|
|
456
608
|
const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
|
|
457
609
|
const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
|
|
@@ -476,10 +628,21 @@ async function handle(req, res, url, ctx) {
|
|
|
476
628
|
ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
|
|
477
629
|
return true;
|
|
478
630
|
}
|
|
631
|
+
if (p === '/api/notes/final-setup') {
|
|
632
|
+
const o = wsOf();
|
|
633
|
+
if (!o.ok) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
634
|
+
const command = notesFinal.setupCommand(dir);
|
|
635
|
+
if (typeof ctx.spawnSession !== 'function') { ctx.json(res, { ok: true, started: false, command, reason: 'this workspace cannot start sessions' }); return true; }
|
|
636
|
+
// typed, not run, as the Whisper setup is (D91)
|
|
637
|
+
const s = ctx.spawnSession({ name: 'notes: set up the final pass', agent: 'shell', ws: o.w ? o.w.id : undefined, origin: 'notes-final-setup', initialInput: command });
|
|
638
|
+
const started = !!s && s.state !== 'exited';
|
|
639
|
+
ctx.json(res, { ok: true, started, session: s ? s.id : null, command, reason: started ? '' : ((s && s.note) || 'could not start a terminal') });
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
479
642
|
if (p === '/api/notes/private') {
|
|
480
643
|
vocabCache.delete(id);
|
|
481
644
|
const r = notes.setPrivate(dir, { id, private: body.private });
|
|
482
|
-
if (r.ok && r.private) { disarm(id); unwatchLate(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
645
|
+
if (r.ok && r.private) { disarm(id); unwatchLate(id); cancelFinal(ctx, id); unwatchWispr(id); if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
483
646
|
return done(r);
|
|
484
647
|
}
|
|
485
648
|
if (p === '/api/notes/summarize') {
|
|
@@ -498,6 +661,9 @@ async function handle(req, res, url, ctx) {
|
|
|
498
661
|
r.source = 'local';
|
|
499
662
|
if (body.prefer === 'agent') {
|
|
500
663
|
// the agent's summary is the default when an agent can be asked; the local one is already stored as the fallback
|
|
664
|
+
// with kept audio and an engine, the final pass runs first and the agent writes the notes from it (Run 9)
|
|
665
|
+
const q = cur.ok && !cur.note.private ? queueFinal(ctx, id) : { queued: false };
|
|
666
|
+
if (q.queued) { r.final = { state: 'queued' }; r.agent = { started: false, waiting: 'final', reason: '' }; r.source = 'agent'; return done(r); }
|
|
501
667
|
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
668
|
: startAgent(ctx, dir, id, ws.ok ? ws.w : null);
|
|
503
669
|
r.agent = ag.ok ? (({ ok, ...x }) => x)(ag) : { started: false, reason: ag.error || 'the agent could not be asked' };
|
|
@@ -505,7 +671,7 @@ async function handle(req, res, url, ctx) {
|
|
|
505
671
|
}
|
|
506
672
|
return done(r);
|
|
507
673
|
}
|
|
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); }
|
|
674
|
+
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); }
|
|
509
675
|
// summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
|
|
510
676
|
let w = null;
|
|
511
677
|
if (body.ws != null) {
|
|
@@ -532,4 +698,5 @@ function startAgent(ctx, dir, id, w) {
|
|
|
532
698
|
return { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld };
|
|
533
699
|
}
|
|
534
700
|
|
|
535
|
-
module.exports = { prefixes, handle, enrich, runMinute, bulletsState,
|
|
701
|
+
module.exports = { prefixes, handle, enrich, runMinute, bulletsState, queueFinal, askWispr, checkWispr,
|
|
702
|
+
__idle: () => Promise.all([transcriber ? transcriber.idle() : null, finalRunner ? finalRunner.idle() : null, ...chains.values()]) };
|
package/workspace/package.json
CHANGED
|
@@ -114,9 +114,9 @@
|
|
|
114
114
|
<button class="mob-sync" id="mobSync" type="button" hidden>Sync</button>
|
|
115
115
|
<button class="mob-set" id="mobSettings" type="button" hidden aria-current="false" aria-label="Settings"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="settings"><use href="#lucide-settings"></use></svg></button>
|
|
116
116
|
</header>
|
|
117
|
-
<!-- The
|
|
118
|
-
second view, the switch to it (
|
|
119
|
-
|
|
117
|
+
<!-- The list head (pm/phone-deck.js): at a tab's root, how many things the list holds and, where the tab has a
|
|
118
|
+
second view, the switch to it (Agenda: Needs you or My day; Initiatives: Roadmap or All; Board: Board or Reports;
|
|
119
|
+
Docs: Out of sync or All). The list under it scrolls on its own; the head and the tabs stay put. -->
|
|
120
120
|
<div class="mob-deck" id="mobDeck" hidden>
|
|
121
121
|
<span class="mob-deck__where" id="mobDeckWhere" aria-live="polite"></span>
|
|
122
122
|
<span class="mob-deck__views" id="mobDeckViews" role="group" aria-label="View"></span>
|
|
@@ -124,11 +124,12 @@
|
|
|
124
124
|
</div>
|
|
125
125
|
|
|
126
126
|
<!-- The bottom block: a terminal control-key accessory bar (terminal screen only — phones have no
|
|
127
|
-
Esc/Tab/Ctrl/arrows)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
Esc/Tab/Ctrl/arrows) and the five tabs (workspace-phone-scroll-layout): Agenda · Initiatives · Board · Docs · Agent,
|
|
128
|
+
each an icon over a short word. Reports has no tab of its own: it is the Board tab's second view, switched from the
|
|
129
|
+
head above the list, and Board stays lit while it is shown. Agent is the dock with its live terminals. On a terminal
|
|
130
|
+
the tabs are not drawn: the session takes the screen, and Back returns. The nav is a <nav> of buttons carrying
|
|
131
|
+
aria-current, NOT a tablist: there is no tabpanel to point at. Selecting a tab resets it to its root; depth is pushed
|
|
132
|
+
on top of it and #mobBack pops one screen. -->
|
|
132
133
|
<div class="mobmenu" id="mobMenu" hidden>
|
|
133
134
|
<div class="ctrlbar" id="ctrlBar" role="toolbar" aria-label="Terminal control keys">
|
|
134
135
|
<button type="button" data-seq="esc" aria-label="Escape"><span class="lbl">esc</span></button>
|
|
@@ -139,19 +140,12 @@
|
|
|
139
140
|
<button type="button" data-seq="down" aria-label="Down arrow"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="arrow-down"><use href="#lucide-arrow-down"></use></svg></button>
|
|
140
141
|
<button type="button" data-seq="right" aria-label="Right arrow"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="arrow-right"><use href="#lucide-arrow-right"></use></svg></button>
|
|
141
142
|
</div>
|
|
142
|
-
<!-- The deck's pager: the previous card, where this one is in the deck, the next card. A swipe does the same. -->
|
|
143
|
-
<div class="mob-pager" id="mobPager" hidden>
|
|
144
|
-
<button type="button" class="mob-pager__b" id="mobPrev" aria-label="Previous card"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="chevron-left"><use href="#lucide-chevron-left"></use></svg></button>
|
|
145
|
-
<span class="mob-pager__track" id="mobPagerTrack" aria-hidden="true"><span class="mob-pager__fill" id="mobPagerFill"></span></span>
|
|
146
|
-
<button type="button" class="mob-pager__b" id="mobNext" aria-label="Next card"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="chevron-right"><use href="#lucide-chevron-right"></use></svg></button>
|
|
147
|
-
</div>
|
|
148
143
|
<nav class="mobnav" id="mobNav" aria-label="Destinations">
|
|
149
|
-
<button type="button" class="mobnav-b" data-dest="today" aria-current="page"><span class="l">Agenda</span><span class="mobnav-n" id="mobTodayCount" hidden></span></button>
|
|
150
|
-
<button type="button" class="mobnav-b" data-dest="initiatives" aria-current="false"><span class="l">Initiatives</span></button>
|
|
151
|
-
<button type="button" class="mobnav-b" data-dest="board" aria-current="false"><span class="l">Board</span></button>
|
|
152
|
-
<button type="button" class="mobnav-b" data-dest="
|
|
153
|
-
<button type="button" class="mobnav-b" data-dest="
|
|
154
|
-
<button type="button" class="mobnav-b" data-dest="agent" aria-current="false"><span class="l">Agent</span></button>
|
|
144
|
+
<button type="button" class="mobnav-b" data-dest="today" aria-current="page"><svg class="pmc-icon mobnav-i" width="22" height="22" aria-hidden="true" focusable="false" data-icon="house"><use href="#lucide-house"></use></svg><span class="l">Agenda</span><span class="mobnav-n" id="mobTodayCount" hidden></span></button>
|
|
145
|
+
<button type="button" class="mobnav-b" data-dest="initiatives" aria-current="false"><svg class="pmc-icon mobnav-i" width="22" height="22" aria-hidden="true" focusable="false" data-icon="target"><use href="#lucide-target"></use></svg><span class="l">Initiatives</span></button>
|
|
146
|
+
<button type="button" class="mobnav-b" data-dest="board" aria-current="false"><svg class="pmc-icon mobnav-i" width="22" height="22" aria-hidden="true" focusable="false" data-icon="square-kanban"><use href="#lucide-square-kanban"></use></svg><span class="l">Board</span></button>
|
|
147
|
+
<button type="button" class="mobnav-b" data-dest="docs" aria-current="false"><svg class="pmc-icon mobnav-i" width="22" height="22" aria-hidden="true" focusable="false" data-icon="file-text"><use href="#lucide-file-text"></use></svg><span class="l">Docs</span></button>
|
|
148
|
+
<button type="button" class="mobnav-b" data-dest="agent" aria-current="false"><svg class="pmc-icon mobnav-i" width="22" height="22" aria-hidden="true" focusable="false" data-icon="square-terminal"><use href="#lucide-square-terminal"></use></svg><span class="l">Agent</span></button>
|
|
155
149
|
</nav>
|
|
156
150
|
</div>
|
|
157
151
|
|
|
@@ -504,14 +504,18 @@
|
|
|
504
504
|
.pmc-card--muted .pmc-card__title {
|
|
505
505
|
color: var(--muted-foreground);
|
|
506
506
|
}
|
|
507
|
-
/* card
|
|
508
|
-
*
|
|
509
|
-
|
|
510
|
-
.pmc-card
|
|
511
|
-
.pmc-card
|
|
512
|
-
.pmc-card
|
|
513
|
-
.pmc-card
|
|
514
|
-
.pmc-card
|
|
507
|
+
/* card on a phone (≤760px), in the Initiatives and Board places only: a row in one vertical list (workspace-phone-scroll-layout). One open target as tall as what
|
|
508
|
+
* it says, with a 64px floor so short and long cards line up; the title and its line under it at reading size. */
|
|
509
|
+
@media (max-width: 760px) {
|
|
510
|
+
.pmc-card:where([data-fr-host="initiatives"] *) { min-height: 64px; padding: 12px 14px; justify-content: center; }
|
|
511
|
+
.pmc-card:where([data-fr-host="initiatives"] *) .pmc-card__title { font-size: 16px; line-height: 1.3; font-weight: 600; }
|
|
512
|
+
.pmc-card:where([data-fr-host="initiatives"] *) .pmc-card__meta { font-size: 14px; line-height: 1.4; }
|
|
513
|
+
.pmc-card:where([data-fr-host="initiatives"] *) .pmc-card__open { gap: 4px; }
|
|
514
|
+
.pmc-card:where([data-fr-host="board"] *) { min-height: 64px; padding: 12px 14px; justify-content: center; }
|
|
515
|
+
.pmc-card:where([data-fr-host="board"] *) .pmc-card__title { font-size: 16px; line-height: 1.3; font-weight: 600; }
|
|
516
|
+
.pmc-card:where([data-fr-host="board"] *) .pmc-card__meta { font-size: 14px; line-height: 1.4; }
|
|
517
|
+
.pmc-card:where([data-fr-host="board"] *) .pmc-card__open { gap: 4px; }
|
|
518
|
+
}
|
|
515
519
|
.pmc-card:focus-visible,
|
|
516
520
|
.pmc-card:has(.pmc-card__open:focus-visible) {
|
|
517
521
|
outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
|
|
@@ -6467,6 +6471,21 @@
|
|
|
6467
6471
|
color: var(--foreground);
|
|
6468
6472
|
font-weight: 500;
|
|
6469
6473
|
}
|
|
6474
|
+
.nt-para {
|
|
6475
|
+
margin: 0 0 8px;
|
|
6476
|
+
font-size: 14px;
|
|
6477
|
+
line-height: 20px;
|
|
6478
|
+
}
|
|
6479
|
+
.nt-quote {
|
|
6480
|
+
display: block;
|
|
6481
|
+
font-size: 12px;
|
|
6482
|
+
line-height: 18px;
|
|
6483
|
+
color: var(--muted-foreground);
|
|
6484
|
+
}
|
|
6485
|
+
.nt-topic {
|
|
6486
|
+
margin: 4px 0 8px;
|
|
6487
|
+
font-size: 14px;
|
|
6488
|
+
}
|
|
6470
6489
|
|
|
6471
6490
|
/* -- layouts · .lg- the legacy and fixture shells (Run 3, slice 3D) -- */
|
|
6472
6491
|
/* The legacy and fixture cockpit's two columns: the list head (a stack) and the rail's tile column. */
|