create-pmos 0.63.2 → 0.65.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/guide/notes.md +35 -6
- package/workspace/guide/people.md +19 -10
- package/workspace/lib/notes-audio.js +111 -0
- package/workspace/lib/notes.js +105 -12
- package/workspace/lib/people-cli.js +7 -8
- package/workspace/lib/people-store.js +110 -1
- package/workspace/lib/people.js +74 -50
- package/workspace/lib/routes-notes.js +33 -9
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +82 -0
- package/workspace/public/pm/dest-people.js +13 -27
- package/workspace/public/pm/view-notes.js +228 -19
- package/workspace/server.js +8 -5
- package/workspace/skills/gather-requirements.skill +2 -2
- package/workspace/skills/jira-sync.skill +1 -1
- package/workspace/skills/live-notes.skill +2 -2
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
* record(root, { by, sightings, merges, now }) → { ok, recorded, duplicates, refused[], error? }
|
|
9
9
|
* decide(root, { actor, body, now }) → { ok, row } | { ok:false, error } one PM page press
|
|
10
10
|
* settings(root) → { ok, calendar_names, file }
|
|
11
|
+
* scan(root, { now, calendar, notesDir, ws }) → { ok, recorded, found } the names the workspace's own records hold
|
|
11
12
|
*
|
|
12
13
|
* Every write is an append through people.js's validators, so a refused row is never on disk. No network, no process.
|
|
14
|
+
*
|
|
15
|
+
* SCAN (the PM, 2026-10-06: "If you detect a person's name, add it to the people directory"). People no longer waits for
|
|
16
|
+
* an agent session alone. `scan` reads the names the workspace already holds and records each as a sighting by
|
|
17
|
+
* `workspace:scan`, which adds the person (people.js): plan item owners (`person:<name>`), the speakers of this
|
|
18
|
+
* repository's Live notes sessions, the renamed speakers and action owners of the PM's meeting notes for this repository,
|
|
19
|
+
* and, only while calendar names are on, the attendees this machine remembers from the calendar. A private Live notes
|
|
20
|
+
* session or a private note is skipped. A sighting already on disk is not written again, so a scan writes only what is new.
|
|
13
21
|
*/
|
|
14
22
|
const crypto = require('crypto');
|
|
15
23
|
const P = require('./people');
|
|
@@ -17,6 +25,8 @@ const fourPanel = require('./four-panel');
|
|
|
17
25
|
const consoleState = require('./console-state');
|
|
18
26
|
|
|
19
27
|
const MAX_PER_RECORD = 500;
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
20
30
|
|
|
21
31
|
function stateDir(root) {
|
|
22
32
|
const e = consoleState.loadEngine();
|
|
@@ -120,4 +130,103 @@ function decide(root, { actor, body, now = Date.now() } = {}) {
|
|
|
120
130
|
return { ok: true, row: w.recorded, person };
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
/* ── scan: the names the workspace's own records hold ─────────────────────────────────────────────────────────────── */
|
|
134
|
+
// Not names: the notes' default speakers, the action owners that are not a person, and numbered speakers.
|
|
135
|
+
const NOT_A_NAME = /^(you|me|meeting|mic|speech|unknown|someone|everyone|the speaker|the team|team|we|speaker\s*\d*|participant\s*\d*|guest\s*\d*)$/i;
|
|
136
|
+
function personName(v) {
|
|
137
|
+
const n = String(v == null ? '' : v).replace(/\s+/g, ' ').trim().replace(/^@/, '');
|
|
138
|
+
if (n.length < 2 || n.length > 80 || !/\p{L}/u.test(n) || NOT_A_NAME.test(n)) return null;
|
|
139
|
+
return n;
|
|
140
|
+
}
|
|
141
|
+
const day = (v) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v.slice(0, 10) : null);
|
|
142
|
+
const lines = (file) => { try { return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
|
|
143
|
+
/** "Ana Lee, Bo Chen and kim@x.io" → ["Ana Lee", "Bo Chen", "kim@x.io"]. */
|
|
144
|
+
const splitNames = (w) => String(w || '').split(/\s*(?:,|;|&|\band\b|\/)\s*/i).map((x) => x.replace(/\(.*?\)/g, '').trim()).filter(Boolean);
|
|
145
|
+
|
|
146
|
+
/** The sightings the workspace's records hold for this repository. Pure apart from reading files. */
|
|
147
|
+
function sightingsFrom(root, { items = [], calendar = [], calendarOn = false, notesDir = null, ws = null } = {}) {
|
|
148
|
+
const out = [];
|
|
149
|
+
const push = (source, kind, value, name, interaction) => {
|
|
150
|
+
const n = personName(name);
|
|
151
|
+
if (!n) return;
|
|
152
|
+
const s = { source, handle: { kind, value: String(value).slice(0, 120) }, name: n };
|
|
153
|
+
if (interaction && interaction.date) s.interaction = { ...interaction, text: interaction.text.slice(0, 200), pointer: interaction.pointer.slice(0, 200) };
|
|
154
|
+
out.push(s);
|
|
155
|
+
};
|
|
156
|
+
// plan item owners: the person's shared work already lists the items, so the sighting carries no interaction
|
|
157
|
+
for (const it of items || []) {
|
|
158
|
+
if (!it || it.state === 'withdrawn' || typeof it.owner !== 'string' || !it.owner.startsWith('person:')) continue;
|
|
159
|
+
const n = personName(it.owner.slice(7));
|
|
160
|
+
if (n) push('plan', 'name', n, n, null);
|
|
161
|
+
}
|
|
162
|
+
// Live notes sessions in this repository: one interaction per speaker per session
|
|
163
|
+
const lnDir = path.join(fs.existsSync(path.join(root, 'pmos', 'state')) ? path.join(root, 'pmos', 'state') : path.join(root, 'planning'), 'live-notes');
|
|
164
|
+
let files = []; try { files = fs.readdirSync(lnDir).filter((f) => f.endsWith('.jsonl')).sort(); } catch { /* no sessions */ }
|
|
165
|
+
for (const f of files) {
|
|
166
|
+
const rows = lines(path.join(lnDir, f));
|
|
167
|
+
if (rows.some((r) => r.row === 'private')) continue;
|
|
168
|
+
const session = f.replace(/\.jsonl$/, '');
|
|
169
|
+
const start = day((rows.find((r) => r.row === 'session') || {}).at) || day((rows[0] || {}).at);
|
|
170
|
+
const said = new Set();
|
|
171
|
+
for (const r of rows) {
|
|
172
|
+
const sp = r && r.pointer && personName(r.pointer.speaker);
|
|
173
|
+
if (!sp || said.has(sp.toLowerCase())) continue;
|
|
174
|
+
said.add(sp.toLowerCase());
|
|
175
|
+
push('live', 'speaker', sp, sp, { date: start || day(r.at), kind: 'Meeting', text: 'Spoke in a Live notes session', pointer: `live notes ${session}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// the PM's meeting notes for this repository: renamed speakers and the action owners the local summary reads
|
|
179
|
+
if (notesDir && ws) {
|
|
180
|
+
let nf = []; try { nf = fs.readdirSync(path.join(notesDir, 'notes')).filter((f) => f.endsWith('.json')); } catch { /* no notes */ }
|
|
181
|
+
let notesLib = null; try { notesLib = require('./notes'); } catch { /* notes unavailable */ }
|
|
182
|
+
for (const f of nf) {
|
|
183
|
+
let note = null; try { note = JSON.parse(fs.readFileSync(path.join(notesDir, 'notes', f), 'utf8')); } catch { continue; }
|
|
184
|
+
if (!note || note.private || note.ws !== ws || !note.id) continue;
|
|
185
|
+
const date = day(note.recorded_from) || day(note.created_at);
|
|
186
|
+
if (!date) continue;
|
|
187
|
+
const pointer = `note ${note.id}`;
|
|
188
|
+
for (const sp of Object.values(note.speakers || {})) push('notes', 'speaker', sp, sp, { date, kind: 'Meeting', text: 'Spoke in a meeting note', pointer });
|
|
189
|
+
if (notesLib) {
|
|
190
|
+
let actions = []; try { actions = (notesLib.summarize(note).sections || {}).actions || []; } catch { /* unreadable note */ }
|
|
191
|
+
const owners = new Set();
|
|
192
|
+
for (const a of actions) for (const o of String(a.owner || '').split(/\s+and\s+/)) { const n = personName(o); if (n) owners.add(n); }
|
|
193
|
+
for (const n of owners) push('notes', 'name', n, n, { date, kind: 'Meeting', text: 'Has an action in a meeting note', pointer });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// the calendar this machine remembers, only while calendar names are on (R3)
|
|
198
|
+
if (calendarOn) {
|
|
199
|
+
for (const e of calendar || []) {
|
|
200
|
+
const date = e && day(e.start);
|
|
201
|
+
if (!date || !e.with) continue;
|
|
202
|
+
for (const w of splitNames(e.with)) {
|
|
203
|
+
const mail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(w);
|
|
204
|
+
push('calendar', mail ? 'mail' : 'name', mail ? w.toLowerCase() : w, mail ? w.split('@')[0] : w,
|
|
205
|
+
{ date, kind: 'Meeting', text: e.title ? `Meeting: ${e.title}` : 'Meeting', pointer: 'calendar' });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Record the names the workspace's records hold. Writes only sightings not already on disk. */
|
|
213
|
+
function scan(root, { now = Date.now(), calendar = [], notesDir = null, ws = null } = {}) {
|
|
214
|
+
const r = readRows(root);
|
|
215
|
+
if (!r.ok) return r;
|
|
216
|
+
const calendarOn = P.fold(r.rows).settings.calendar_names;
|
|
217
|
+
const found = sightingsFrom(root, { items: planItems(root), calendar, calendarOn, notesDir, ws });
|
|
218
|
+
const seen = new Set(r.rows.filter((x) => x && x.kind === 'sighting').map(sightKey));
|
|
219
|
+
let recorded = 0;
|
|
220
|
+
for (const s of found) {
|
|
221
|
+
const c = P.checkSighting(s);
|
|
222
|
+
if (!c.ok) continue;
|
|
223
|
+
const k = sightKey(c.row);
|
|
224
|
+
if (seen.has(k)) continue;
|
|
225
|
+
const w = fourPanel.appendDeclaration(r.dir, P.LEDGER, stamp(c.row, 'workspace:scan', now));
|
|
226
|
+
if (!w.ok) return { ok: false, error: w.error, recorded, found: found.length };
|
|
227
|
+
seen.add(k); recorded++;
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, recorded, found: found.length };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { view, record, decide, settings, scan, sightingsFrom, stateDir, readRows };
|
package/workspace/lib/people.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* checkSighting(s) → { ok, row } | { ok:false, error } one sighting an agent session records
|
|
6
6
|
* checkMergeProposal(m) → { ok, row } | { ok:false, error }
|
|
7
|
-
* checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore,
|
|
8
|
-
* edit, tone, note, note-remove,
|
|
7
|
+
* checkDecision(body) → { ok, row } | { ok:false, error } one PM press (add, ignore, remove, merge,
|
|
8
|
+
* keep-separate, edit, tone, note, note-remove,
|
|
9
|
+
* setting)
|
|
9
10
|
* fold(rows, { now, items, meetings }) → { people, review, settings, counts }
|
|
10
11
|
* handleKey(h) · slugOf(name) · toneProposal(interactions) · matchPeople(people, text)
|
|
11
12
|
* SOURCES · HANDLE_KINDS · INTERACTION_KINDS · TONES · ACTS · LEDGER
|
|
@@ -13,21 +14,24 @@
|
|
|
13
14
|
* Pure: no file, no clock unless given, no network. lib/people-store.js reads and appends; the route, the CLI and the
|
|
14
15
|
* People place all answer from fold(), so the terminal and the page cannot hold two opinions about a person.
|
|
15
16
|
*
|
|
16
|
-
* WHO DECIDES (D90). A sighting row is anyone's: an agent session records it through
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* WHO DECIDES (D90, as amended 2026-10-06). A sighting row is anyone's: an agent session records it through
|
|
18
|
+
* `pmos people record`, and the workspace records the names its own records hold (people-store.js scan). A sighting of
|
|
19
|
+
* a handle nobody holds ADDS that person: the PM asked for every detected name to be in People, not to wait in a list.
|
|
20
|
+
* A sighting whose name is a person's full name joins that person. Every decision row (a person added or removed, a
|
|
21
|
+
* handle ignored or merged, a tone, a note, the calendar setting) counts only when its `by` is the PM (`pm:<slug>`),
|
|
22
|
+
* which only a page press writes. A decision row with any other `by` is skipped and counted in counts.skipped, so a
|
|
23
|
+
* hand-appended line cannot make an agent's choice the PM's. Remove and Ignore keep a person's handles out for good.
|
|
20
24
|
*
|
|
21
25
|
* CALENDAR NAMES (R3). The setting is off until the PM turns it on. While it is off, the store refuses a calendar
|
|
22
26
|
* sighting, and this fold also drops the calendar sightings recorded while it was on.
|
|
23
27
|
*/
|
|
24
28
|
|
|
25
29
|
const LEDGER = 'people.jsonl';
|
|
26
|
-
const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira']);
|
|
30
|
+
const SOURCES = Object.freeze(['gather', 'calendar', 'live', 'jira', 'plan', 'notes']);
|
|
27
31
|
const HANDLE_KINDS = Object.freeze(['mail', 'slack', 'jira', 'confluence', 'speaker', 'name']);
|
|
28
32
|
const INTERACTION_KINDS = Object.freeze(['Mail', 'Chat', 'Meeting', '1:1', 'Jira', 'Doc', 'Call', 'Mention']);
|
|
29
33
|
const TONES = Object.freeze(['warm', 'neutral', 'cautious', 'tense']);
|
|
30
|
-
const ACTS = Object.freeze(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
34
|
+
const ACTS = Object.freeze(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
31
35
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
36
|
const ID_RE = /^[a-z0-9][a-z0-9-]{0,59}$/;
|
|
33
37
|
const NOTE_ID_RE = /^n-[0-9a-z]{4,16}$/;
|
|
@@ -148,6 +152,7 @@ function checkDecision(body) {
|
|
|
148
152
|
return { ok: true, row: { kind: 'keep-separate', a: a.value, b: b.value } };
|
|
149
153
|
}
|
|
150
154
|
if (!person()) return { ok: false, error: 'say which person' };
|
|
155
|
+
if (act === 'remove') return { ok: true, row: { kind: 'remove', person: person() } };
|
|
151
156
|
if (act === 'edit') {
|
|
152
157
|
const row = { kind: 'edit', person: person() };
|
|
153
158
|
for (const [k, max] of [['name', 80], ['role', 120], ['org', 80]]) {
|
|
@@ -217,7 +222,7 @@ function matchPeople(people, hay) {
|
|
|
217
222
|
const sightKey = (r) => [handleKey(r.handle), r.interaction ? r.interaction.date : '', r.interaction ? r.interaction.pointer : '', r.interaction ? r.interaction.text : ''].join('\u0000');
|
|
218
223
|
const pairKey = (a, b) => [handleKey(a), handleKey(b)].sort().join('\u0000');
|
|
219
224
|
const isPm = (r) => typeof r.by === 'string' && PM_BY_RE.test(r.by);
|
|
220
|
-
const DECISION_KINDS = new Set(['add', 'ignore', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
225
|
+
const DECISION_KINDS = new Set(['add', 'ignore', 'remove', 'merge', 'keep-separate', 'edit', 'tone', 'note', 'note-remove', 'setting']);
|
|
221
226
|
|
|
222
227
|
/**
|
|
223
228
|
* The one fold. `rows` are the ledger's rows in file order (torn lines already dropped). `items` are plan items (the
|
|
@@ -233,7 +238,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
233
238
|
const sightings = [];
|
|
234
239
|
const proposals = [];
|
|
235
240
|
|
|
236
|
-
// 1 ·
|
|
241
|
+
// 1 · the calendar setting first, so every sighting below reads its final value
|
|
237
242
|
for (const r of rows || []) {
|
|
238
243
|
if (!isObj(r)) continue;
|
|
239
244
|
counts.rows++;
|
|
@@ -249,11 +254,44 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
249
254
|
const p = people.get(id);
|
|
250
255
|
if (!p.handles.some((x) => handleKey(x) === k)) p.handles.push({ kind: h.kind, value: h.value });
|
|
251
256
|
};
|
|
257
|
+
const create = (name, h, at) => {
|
|
258
|
+
let id = slugOf(name);
|
|
259
|
+
for (let n = 2; people.has(id); n++) id = `${slugOf(name).slice(0, 56)}-${n}`;
|
|
260
|
+
people.set(id, { id, name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: at || null, edited: {} });
|
|
261
|
+
attach(id, h);
|
|
262
|
+
return id;
|
|
263
|
+
};
|
|
264
|
+
// A sighting whose name is a person's full name is that person: "Ana Lee" from Jira and "Ana Lee" from a meeting. A
|
|
265
|
+
// bare first name from a weak handle (a name or a speaker, never a mail or chat handle) joins the one person with that
|
|
266
|
+
// first name; when two people share it, it joins nobody.
|
|
267
|
+
const byName = (name, kind) => {
|
|
268
|
+
const n = str(name).toLowerCase();
|
|
269
|
+
const live = [...people.values()].filter((p) => p.handles.length);
|
|
270
|
+
const full = live.find((p) => str(p.name).toLowerCase() === n);
|
|
271
|
+
if (full) return full.id;
|
|
272
|
+
if ((kind !== 'name' && kind !== 'speaker') || n.includes(' ')) return null;
|
|
273
|
+
const firsts = live.filter((p) => str(p.name).toLowerCase().split(' ')[0] === n);
|
|
274
|
+
return firsts.length === 1 ? firsts[0].id : null;
|
|
275
|
+
};
|
|
276
|
+
// One pass in file order, so a person exists before the PM's later presses about them.
|
|
277
|
+
const seen = new Set();
|
|
252
278
|
for (const r of rows || []) {
|
|
253
279
|
if (!isObj(r)) continue;
|
|
254
280
|
if (r.kind === 'sighting') {
|
|
255
281
|
const c = checkSighting(r);
|
|
256
|
-
if (c.ok)
|
|
282
|
+
if (!c.ok) continue;
|
|
283
|
+
const s = { ...c.row, at: r.at || null };
|
|
284
|
+
const key = sightKey(s);
|
|
285
|
+
if (seen.has(key)) { counts.duplicates++; continue; }
|
|
286
|
+
seen.add(key);
|
|
287
|
+
if (s.source === 'calendar' && !settings.calendar_names) { counts.hiddenCalendar++; continue; }
|
|
288
|
+
const k = handleKey(s.handle);
|
|
289
|
+
if (ignored.has(k)) continue;
|
|
290
|
+
if (!owner.has(k) || !people.has(owner.get(k))) {
|
|
291
|
+
const id = byName(s.name, s.handle.kind);
|
|
292
|
+
if (id) attach(id, s.handle); else create(s.name, s.handle, s.at);
|
|
293
|
+
}
|
|
294
|
+
sightings.push(s);
|
|
257
295
|
continue;
|
|
258
296
|
}
|
|
259
297
|
if (r.kind === 'merge-proposal') {
|
|
@@ -268,20 +306,23 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
268
306
|
if (d.kind === 'add') {
|
|
269
307
|
const k = handleKey(d.handle);
|
|
270
308
|
if (owner.has(k) && people.has(owner.get(k))) continue;
|
|
271
|
-
|
|
272
|
-
for (let n = 2; people.has(id); n++) id = `${slugOf(d.name).slice(0, 56)}-${n}`;
|
|
273
|
-
people.set(id, { id, name: d.name, role: null, org: null, external: null, handles: [], tone: null, notes: new Map(), addedAt: r.at || null, edited: {} });
|
|
274
|
-
attach(id, d.handle);
|
|
309
|
+
create(d.name, d.handle, r.at);
|
|
275
310
|
} else if (d.kind === 'ignore') {
|
|
276
311
|
const k = handleKey(d.handle);
|
|
277
|
-
|
|
312
|
+
const o = owner.get(k);
|
|
313
|
+
if (o && people.has(o)) { const pp = people.get(o); pp.handles = pp.handles.filter((x) => handleKey(x) !== k); }
|
|
314
|
+
owner.delete(k);
|
|
315
|
+
ignored.add(k);
|
|
278
316
|
} else if (d.kind === 'merge') {
|
|
279
317
|
if (people.has(d.person)) attach(d.person, d.handle);
|
|
280
318
|
} else if (d.kind === 'keep-separate') {
|
|
281
319
|
closed.add(pairKey(d.a, d.b));
|
|
282
320
|
} else if (people.has(d.person)) {
|
|
283
321
|
const p = people.get(d.person);
|
|
284
|
-
if (d.kind === '
|
|
322
|
+
if (d.kind === 'remove') {
|
|
323
|
+
for (const h of p.handles) { owner.delete(handleKey(h)); ignored.add(handleKey(h)); }
|
|
324
|
+
p.handles = [];
|
|
325
|
+
} else if (d.kind === 'edit') {
|
|
285
326
|
for (const k of ['name', 'role', 'org', 'external']) if (d[k] !== undefined) { p[k] = d[k]; p.edited[k] = true; }
|
|
286
327
|
} else if (d.kind === 'tone') p.tone = d.tone;
|
|
287
328
|
else if (d.kind === 'note') p.notes.set(d.id, { id: d.id, text: d.text, at: r.at || null });
|
|
@@ -289,38 +330,21 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
289
330
|
}
|
|
290
331
|
}
|
|
291
332
|
|
|
292
|
-
// 2 ·
|
|
293
|
-
const seen = new Set();
|
|
294
|
-
const fresh = new Map(); // handle key -> { handle, name, role, org, external, count, sources, last }
|
|
333
|
+
// 2 · each sighting's details go to the person who holds its handle at the end
|
|
295
334
|
const interactions = new Map(); // person id -> []
|
|
335
|
+
const sourcesOf = new Map(); // person id -> Set of sources
|
|
296
336
|
for (const s of sightings) {
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
|
|
302
|
-
const
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
if (s.interaction) {
|
|
308
|
-
if (!interactions.has(id)) interactions.set(id, []);
|
|
309
|
-
interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
|
|
310
|
-
}
|
|
311
|
-
continue;
|
|
337
|
+
const id = owner.get(handleKey(s.handle));
|
|
338
|
+
if (!id || !people.has(id)) continue;
|
|
339
|
+
const p = people.get(id);
|
|
340
|
+
if (!sourcesOf.has(id)) sourcesOf.set(id, new Set());
|
|
341
|
+
sourcesOf.get(id).add(s.source);
|
|
342
|
+
for (const f of ['role', 'org']) if (s[f] && !p.edited[f]) p[f] = s[f];
|
|
343
|
+
if (s.external === true && !p.edited.external) p.external = true;
|
|
344
|
+
if (s.interaction) {
|
|
345
|
+
if (!interactions.has(id)) interactions.set(id, []);
|
|
346
|
+
interactions.get(id).push({ ...s.interaction, source: s.source, handle: s.handle, at: s.at });
|
|
312
347
|
}
|
|
313
|
-
if (ignored.has(k)) continue;
|
|
314
|
-
const f = fresh.get(k) || { handle: s.handle, name: s.name, role: null, org: null, external: false, count: 0, sources: [], last: null };
|
|
315
|
-
f.name = s.name;
|
|
316
|
-
if (s.role) f.role = s.role;
|
|
317
|
-
if (s.org) f.org = s.org;
|
|
318
|
-
if (s.external) f.external = true;
|
|
319
|
-
f.count++;
|
|
320
|
-
if (!f.sources.includes(s.source)) f.sources.push(s.source);
|
|
321
|
-
const d = s.interaction ? s.interaction.date : (s.at || '').slice(0, 10);
|
|
322
|
-
if (d && (!f.last || d > f.last)) f.last = d;
|
|
323
|
-
fresh.set(k, f);
|
|
324
348
|
}
|
|
325
349
|
|
|
326
350
|
// 3 · each person's page
|
|
@@ -340,6 +364,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
340
364
|
id: p.id, name: p.name, role: p.role || null, org: p.org || null, external: p.external === true,
|
|
341
365
|
group: p.external === true ? 'External' : 'Team',
|
|
342
366
|
handles: p.handles.slice(),
|
|
367
|
+
sources: [...(sourcesOf.get(p.id) || [])],
|
|
343
368
|
interactions: ints,
|
|
344
369
|
tone: { proposed, confirmed: p.tone || null, mix, pending: !!proposed && !p.tone },
|
|
345
370
|
notes: [...p.notes.values()],
|
|
@@ -367,7 +392,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
367
392
|
}
|
|
368
393
|
list.sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || a.name.localeCompare(b.name));
|
|
369
394
|
|
|
370
|
-
// 5 · the review list:
|
|
395
|
+
// 5 · the review list: open merge proposals and tones the PM has not confirmed (a new person is added, not listed)
|
|
371
396
|
const merges = [];
|
|
372
397
|
const shown = new Set();
|
|
373
398
|
for (const m of proposals) {
|
|
@@ -375,7 +400,7 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
375
400
|
if (closed.has(pk) || shown.has(pk)) continue;
|
|
376
401
|
const oa = owner.get(handleKey(m.a)); const ob = owner.get(handleKey(m.b));
|
|
377
402
|
if (oa && ob && oa === ob) continue;
|
|
378
|
-
// Merge needs a person to join: the one holding a (or else b).
|
|
403
|
+
// Merge needs a person to join: the one holding a (or else b).
|
|
379
404
|
const into = oa && people.has(oa) ? oa : ob && people.has(ob) ? ob : null;
|
|
380
405
|
if (!into) continue;
|
|
381
406
|
const other = into === oa ? m.b : m.a;
|
|
@@ -383,11 +408,10 @@ function fold(rows, { now = Date.now(), items = [], meetings = [] } = {}) {
|
|
|
383
408
|
shown.add(pk);
|
|
384
409
|
merges.push({ a: m.a, b: m.b, why: m.why, person: into, personName: people.get(into).name, handle: other });
|
|
385
410
|
}
|
|
386
|
-
const freshList = [...fresh.values()].sort((a, b) => String(b.last || '').localeCompare(String(a.last || '')) || b.count - a.count);
|
|
387
411
|
const tones = list.filter((p) => p.tone.pending).map((p) => p.id);
|
|
388
412
|
return {
|
|
389
413
|
people: list,
|
|
390
|
-
review: {
|
|
414
|
+
review: { merges, tones, count: merges.length + (tones.length ? 1 : 0) },
|
|
391
415
|
settings,
|
|
392
416
|
counts,
|
|
393
417
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1
|
|
2
|
+
/* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
|
|
5
5
|
* GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
|
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
* POST /api/notes/audio { id, source, pcm, final? } → { ok, cut } 16 kHz mono 16-bit samples, base64; local Whisper (D91)
|
|
12
12
|
* POST /api/notes/label { id, index, cat } → { ok, segment } the PM's own label for a line
|
|
13
13
|
* POST /api/notes/speaker { id, source, name } → { ok, speakers }
|
|
14
|
+
* POST /api/notes/line { id, index, cat?, summary?, tags?, people?, text? } → { ok, segment, summary } the PM's correction (Run 3)
|
|
15
|
+
* GET /api/notes/recording?id=… → audio/wav, with byte ranges the note's kept audio (Run 3, D92)
|
|
16
|
+
* POST /api/notes/recording-delete { id } → { ok } deletes the note's audio and keeps the rest
|
|
14
17
|
* GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
|
|
15
18
|
* POST /api/notes/switch { ws, jev?, check? } → { ok, jev } the two D91 switches, default off
|
|
16
|
-
* { language?, model? }
|
|
19
|
+
* { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
|
|
17
20
|
* POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
|
|
18
21
|
* POST /api/notes/private { id, private } → { ok, private }
|
|
19
22
|
* POST /api/notes/summarize { id } → { ok, summary } the local summary
|
|
@@ -34,6 +37,7 @@ const guard = require('./pm-guard.js');
|
|
|
34
37
|
const notes = require('./notes.js');
|
|
35
38
|
const whisper = require('./notes-whisper.js');
|
|
36
39
|
const notesRepo = require('./notes-repo.js');
|
|
40
|
+
const notesAudio = require('./notes-audio.js');
|
|
37
41
|
const jevNotes = require('./jev-notes.js');
|
|
38
42
|
const tsSettings = require('./typesafe-settings.js');
|
|
39
43
|
|
|
@@ -43,7 +47,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
|
|
|
43
47
|
const prefixes = ['/api/notes'];
|
|
44
48
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
45
49
|
'/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
46
|
-
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup']);
|
|
50
|
+
'/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
|
|
47
51
|
|
|
48
52
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
49
53
|
const chains = new Map(); // note id → the promise of its enrichment so far
|
|
@@ -109,7 +113,7 @@ function engineInfo(ctx, w) {
|
|
|
109
113
|
const d = whisper.detect(ctx.runtimeDir);
|
|
110
114
|
const out = { ready: d.ready, missing: d.missing, setup: d.setup, model_dir: d.model_dir, model_size: d.model_size,
|
|
111
115
|
language: whisper.getLanguage(ctx.runtimeDir), languages: whisper.LANGUAGES.map(([value, label]) => ({ value, label })),
|
|
112
|
-
model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })) };
|
|
116
|
+
model: whisper.getModel(ctx.runtimeDir), models: whisper.MODELS.map((m) => ({ value: m.id, label: m.label })), keep_audio: notesAudio.getKeep(ctx.runtimeDir) };
|
|
113
117
|
if (!w) return { whisper: out, jev: null };
|
|
114
118
|
const repo = tsSettings.getRepo(ctx.runtimeDir, w.root);
|
|
115
119
|
const sw = jevNotes.getSwitch(ctx.runtimeDir, w.root);
|
|
@@ -128,10 +132,18 @@ async function handle(req, res, url, ctx) {
|
|
|
128
132
|
if (p === '/api/notes') { send(ctx, res, notes.list(dir)); return true; }
|
|
129
133
|
const nid = url.searchParams.get('id');
|
|
130
134
|
const r = notes.read(dir, nid);
|
|
131
|
-
if (r.ok) r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null };
|
|
135
|
+
if (r.ok) { r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); }
|
|
132
136
|
send(ctx, res, r);
|
|
133
137
|
return true;
|
|
134
138
|
}
|
|
139
|
+
if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
140
|
+
const nid = url.searchParams.get('id');
|
|
141
|
+
const r = notes.read(dir, nid);
|
|
142
|
+
if (!r.ok) { send(ctx, res, r); return true; }
|
|
143
|
+
if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no audio' }, 404); return true; }
|
|
144
|
+
notesAudio.serve(req, res, dir, nid);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
135
147
|
if (p === '/api/notes/engine') {
|
|
136
148
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
137
149
|
const wsId = url.searchParams.get('ws');
|
|
@@ -180,17 +192,29 @@ async function handle(req, res, url, ctx) {
|
|
|
180
192
|
if (!whisper.detect(dir).ready) return done({ ok: false, status: 409, error: 'local transcription is not set up' });
|
|
181
193
|
const ms = (pcm.length / 2 / whisper.RATE) * 1000;
|
|
182
194
|
const t = Math.max(0, Date.now() - Date.parse(note.note.recorded_from) - ms);
|
|
195
|
+
// the audio is kept for replay unless the PM switched that off (D92); a private note never reaches this line
|
|
196
|
+
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) }); } }
|
|
183
197
|
return done(transcriberFor(ctx).push({ id, source, pcm, t, final: body.final === true }));
|
|
184
198
|
}
|
|
185
199
|
if (p === '/api/notes/label') return done(notes.setLabel(dir, { id, index: Number(body.index), cat: body.cat, drop: body.drop === true }));
|
|
186
200
|
if (p === '/api/notes/speaker') return done(notes.renameSpeaker(dir, { id, source: body.source, name: body.name }));
|
|
201
|
+
if (p === '/api/notes/line') {
|
|
202
|
+
const pick = (k) => (body[k] === undefined ? undefined : body[k]);
|
|
203
|
+
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') }));
|
|
204
|
+
}
|
|
205
|
+
if (p === '/api/notes/recording-delete') {
|
|
206
|
+
const r = notes.read(dir, id);
|
|
207
|
+
if (!r.ok) return done(r);
|
|
208
|
+
return done({ ok: true, deleted: notesAudio.remove(dir, id) });
|
|
209
|
+
}
|
|
187
210
|
if (p === '/api/notes/switch') {
|
|
188
211
|
const o = wsOf();
|
|
189
212
|
// the spoken language belongs to this machine, not to a repository
|
|
190
|
-
if (body.language !== undefined || body.model !== undefined) {
|
|
213
|
+
if (body.language !== undefined || body.model !== undefined || body.keep_audio !== undefined) {
|
|
191
214
|
const r = body.language !== undefined ? whisper.setLanguage(dir, String(body.language)) : { ok: true };
|
|
192
215
|
const m = r.ok && body.model !== undefined ? whisper.setModel(dir, String(body.model)) : { ok: true };
|
|
193
|
-
|
|
216
|
+
const k = r.ok && m.ok && body.keep_audio !== undefined ? notesAudio.setKeep(dir, body.keep_audio) : { ok: true };
|
|
217
|
+
if (!r.ok || !m.ok || !k.ok) { ctx.json(res, { ok: false, error: r.error || m.error || k.error }, 400); return true; }
|
|
194
218
|
if (body.jev === undefined && body.check === undefined) { ctx.json(res, { ok: true, ...engineInfo(ctx, o.ok ? o.w : null) }); return true; }
|
|
195
219
|
}
|
|
196
220
|
if (!o.ok || !o.w) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
|
|
@@ -211,11 +235,11 @@ async function handle(req, res, url, ctx) {
|
|
|
211
235
|
}
|
|
212
236
|
if (p === '/api/notes/private') {
|
|
213
237
|
const r = notes.setPrivate(dir, { id, private: body.private });
|
|
214
|
-
if (r.ok && r.private
|
|
238
|
+
if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
|
|
215
239
|
return done(r);
|
|
216
240
|
}
|
|
217
241
|
if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
|
|
218
|
-
if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id);
|
|
242
|
+
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); }
|
|
219
243
|
// summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
|
|
220
244
|
let w = null;
|
|
221
245
|
if (body.ws != null) {
|
package/workspace/package.json
CHANGED
|
@@ -4929,6 +4929,88 @@
|
|
|
4929
4929
|
white-space: pre-wrap;
|
|
4930
4930
|
font-size: 14px;
|
|
4931
4931
|
}
|
|
4932
|
+
.nt-now {
|
|
4933
|
+
background: var(--accent);
|
|
4934
|
+
}
|
|
4935
|
+
.nt-ln.nt-now {
|
|
4936
|
+
color: var(--foreground);
|
|
4937
|
+
}
|
|
4938
|
+
.nt-tags {
|
|
4939
|
+
display: flex;
|
|
4940
|
+
flex-wrap: wrap;
|
|
4941
|
+
gap: 4px;
|
|
4942
|
+
margin-top: 4px;
|
|
4943
|
+
}
|
|
4944
|
+
.nt-tag {
|
|
4945
|
+
margin-left: 4px;
|
|
4946
|
+
padding: 0 4px;
|
|
4947
|
+
border: 1px solid var(--border);
|
|
4948
|
+
font-size: 11px;
|
|
4949
|
+
color: var(--muted-foreground);
|
|
4950
|
+
}
|
|
4951
|
+
.nt-tags .nt-tag {
|
|
4952
|
+
margin-left: 0;
|
|
4953
|
+
}
|
|
4954
|
+
.nt-edited {
|
|
4955
|
+
font-size: 11px;
|
|
4956
|
+
font-style: italic;
|
|
4957
|
+
color: var(--muted-foreground);
|
|
4958
|
+
}
|
|
4959
|
+
.nt-heard {
|
|
4960
|
+
display: flex;
|
|
4961
|
+
flex-direction: column;
|
|
4962
|
+
align-items: flex-start;
|
|
4963
|
+
gap: 4px;
|
|
4964
|
+
}
|
|
4965
|
+
.nt-ver {
|
|
4966
|
+
display: flex;
|
|
4967
|
+
flex-direction: column;
|
|
4968
|
+
align-items: flex-start;
|
|
4969
|
+
gap: 8px;
|
|
4970
|
+
padding: 12px 0;
|
|
4971
|
+
border-bottom: 1px solid var(--border);
|
|
4972
|
+
}
|
|
4973
|
+
.nt-diff {
|
|
4974
|
+
align-self: stretch;
|
|
4975
|
+
font-family: var(--mono);
|
|
4976
|
+
font-size: 12px;
|
|
4977
|
+
line-height: 18px;
|
|
4978
|
+
white-space: pre-wrap;
|
|
4979
|
+
}
|
|
4980
|
+
.nt-diff__ln--same {
|
|
4981
|
+
color: var(--muted-foreground);
|
|
4982
|
+
}
|
|
4983
|
+
.nt-diff__ln--add {
|
|
4984
|
+
color: var(--foreground);
|
|
4985
|
+
background: var(--accent);
|
|
4986
|
+
}
|
|
4987
|
+
.nt-diff__ln--del {
|
|
4988
|
+
color: var(--muted-foreground);
|
|
4989
|
+
text-decoration: line-through;
|
|
4990
|
+
}
|
|
4991
|
+
.nt-replay {
|
|
4992
|
+
display: flex;
|
|
4993
|
+
align-items: center;
|
|
4994
|
+
flex-wrap: wrap;
|
|
4995
|
+
gap: 12px;
|
|
4996
|
+
padding-bottom: 12px;
|
|
4997
|
+
border-bottom: 1px solid var(--border);
|
|
4998
|
+
}
|
|
4999
|
+
.nt-audio {
|
|
5000
|
+
flex: 1 1 320px;
|
|
5001
|
+
min-width: 0;
|
|
5002
|
+
height: 32px;
|
|
5003
|
+
}
|
|
5004
|
+
.nt-notelines {
|
|
5005
|
+
display: flex;
|
|
5006
|
+
flex-direction: column;
|
|
5007
|
+
min-height: 96px;
|
|
5008
|
+
font-size: 14px;
|
|
5009
|
+
line-height: 22px;
|
|
5010
|
+
}
|
|
5011
|
+
.nt-noteline {
|
|
5012
|
+
white-space: pre-wrap;
|
|
5013
|
+
}
|
|
4932
5014
|
@media (max-width: 760px) {
|
|
4933
5015
|
.nt-body2 {
|
|
4934
5016
|
grid-template-columns: 1fr;
|