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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/notes.md +15 -10
- package/workspace/lib/notes-whisper.js +94 -22
- package/workspace/lib/notes.js +559 -59
- package/workspace/lib/routes-notes.js +276 -46
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +142 -0
- package/workspace/public/pm/note-dock.js +12 -6
- package/workspace/public/pm/view-notes.js +245 -102
- package/workspace/server.js +12 -1
|
@@ -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 }
|
|
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
|
|
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
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const next = (chains.get(id) || Promise.resolve()).then(
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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:
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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:
|
|
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
|
-
|
|
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);
|
|
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 })),
|
|
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) {
|
|
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')
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
|
247
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()]) };
|
package/workspace/package.json
CHANGED