create-pmos 0.240.0 → 0.242.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/c3-report-structure-real-test.js +211 -0
- package/workspace/c3-report-structure-test.js +382 -0
- package/workspace/guide/console-calendar.md +14 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/notes.md +22 -1
- package/workspace/guide/report-design.md +4 -0
- package/workspace/guide/report-structure.md +91 -0
- package/workspace/guide/reports-dashboards.md +1 -1
- package/workspace/guide/reports-set-up.md +2 -0
- package/workspace/lib/charts-store.js +124 -0
- package/workspace/lib/headless.js +17 -3
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +170 -0
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/notes-screen.js +128 -8
- package/workspace/lib/report-structure-needs.js +64 -0
- package/workspace/lib/report-structure.js +515 -0
- package/workspace/lib/route-modules.js +3 -0
- package/workspace/lib/routes-meeting-focus.js +5 -3
- package/workspace/lib/routes-meeting-material.js +143 -0
- package/workspace/lib/routes-meeting-replay.js +75 -0
- package/workspace/lib/routes-notes.js +37 -4
- package/workspace/lib/routes-report-setups.js +11 -6
- package/workspace/lib/routes-report-structure.js +619 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +15 -3
- package/workspace/public/pm/c3-demo-material.js +58 -0
- package/workspace/public/pm/c3-demo-rd.js +4 -0
- package/workspace/public/pm/c3-demo.js +5 -1
- package/workspace/public/pm/c3-design-studio.js +7 -3
- package/workspace/public/pm/c3-focus.js +83 -16
- package/workspace/public/pm/c3-listen.css +10 -0
- package/workspace/public/pm/c3-material.css +22 -0
- package/workspace/public/pm/c3-material.js +311 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +127 -0
- package/workspace/public/pm/c3-replay.js +413 -0
- package/workspace/public/pm/c3-report-setup.js +3 -0
- package/workspace/public/pm/c3-report-structure.css +40 -0
- package/workspace/public/pm/c3-report-structure.js +455 -0
- package/workspace/public/pm/c3-reports.css +2 -0
- package/workspace/public/pm/c3-reports.js +22 -3
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- package/workspace/public/pm/listen-sheet.js +94 -0
- package/workspace/public/pm/listen-sources.js +82 -0
- package/workspace/public/pm/notes-aside.js +62 -0
- package/workspace/public/pm/notes-screen-capture.js +23 -10
- package/workspace/public/pm/notes-screen-view.js +18 -5
- package/workspace/public/pm/notes-shots.js +55 -0
- package/workspace/public/pm/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- package/workspace/server.js +38 -9
- package/workspace/skills/report-source-check.skill +45 -0
- package/workspace/skills/report-structure-dry.skill +51 -0
- package/workspace/skills/report-structure.skill +61 -0
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/test-lists/meeting-material.json +6 -0
- package/workspace/test-lists/meeting-replay.json +6 -0
- package/workspace/test-lists/mm-capture.json +15 -0
- package/workspace/test-lists/report-structure-backend.json +5 -0
- package/workspace/test-lists/report-structure-ui.json +11 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-meeting-material.js — the material of a meeting: what the PM linked to it (initiative meeting-material-replay, part 1; lib/route-modules.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/meeting-material?event=<id>&series=&title=[&date=][&topics=1][&ws=]
|
|
5
|
+
* → { ok, key, items: [item], topics? } item: { id, kind, ref, url, title, ws, topic, present, scope: meeting|series, from: 'you', by, at }
|
|
6
|
+
* The items of the meeting: the ones for every meeting of its series and the ones for this one. title is required (it names the series when
|
|
7
|
+
* series is empty). With topics=1 the answer also lists the topics Focus starts the meeting with, for the Add material sheet.
|
|
8
|
+
* POST /api/meeting-material { op?: 'add'|'remove', event: { id?, title, series? }, scope: 'meeting'|'series', item: { kind, ref|url, title?, ws? }, topic?, present? }
|
|
9
|
+
* → { ok, items } page press (lib/pm-guard.js requirePagePress). Appends one line to <runtime>/meeting-material.jsonl. The calendar is never written.
|
|
10
|
+
* GET /api/meeting-material/suggest?ws=&initiative=&board=&q=[&limit=]
|
|
11
|
+
* → { ok, suggested: [pick], found: [pick] } pick: { kind, ref, title, meta, why?, present, ws }
|
|
12
|
+
* suggested: the documents of the meeting's level (the initiative's, newest first) and the newest report cycles. found: a search over the
|
|
13
|
+
* titles and paths of PMOS documents and report cycles (two letters or more). Read-only.
|
|
14
|
+
*
|
|
15
|
+
* Handlers are pure over (req, body, ctx): ctx = { runtimeDir, getWs, stateDirFor?, initiativeIndex?, readEvents?, briefFor?, json } so a test needs no server. */
|
|
16
|
+
const store = require('./meeting-material.js');
|
|
17
|
+
const guard = require('./pm-guard.js');
|
|
18
|
+
|
|
19
|
+
const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
|
|
20
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
const str = (v, n) => String(v == null ? '' : v).trim().slice(0, n || 300);
|
|
22
|
+
const eventOf = (q) => ({ id: str(q.event, 120), title: str(q.title, 300), series: str(q.series, 300) || null });
|
|
23
|
+
|
|
24
|
+
/** The topics Focus starts the meeting with (lib/meeting-focus.js topicsFrom). Best effort: no calendar read, no topics. */
|
|
25
|
+
async function topicsOf(q, ctx) {
|
|
26
|
+
try {
|
|
27
|
+
const F = require('./meeting-focus.js');
|
|
28
|
+
const R = require('./routes-meeting-focus.js');
|
|
29
|
+
const events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: Date.now() });
|
|
30
|
+
const date = q.date && DAY.test(String(q.date)) ? String(q.date) : '';
|
|
31
|
+
const ev = R.findEvent(events, str(q.event, 120), date);
|
|
32
|
+
if (!ev) return [];
|
|
33
|
+
const day = date || String(ev.at || '').slice(0, 10);
|
|
34
|
+
const w = ctx.getWs ? ctx.getWs(q.ws || null) : null;
|
|
35
|
+
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await R.realBrief({ ...ctx, w }, ev, day);
|
|
36
|
+
return F.topicsFrom(ev, brief).map((t) => ({ id: t.id, text: t.text }));
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function list(q, ctx) {
|
|
41
|
+
const e = eventOf(q);
|
|
42
|
+
if (!e.title) return fail(400, 'title is required');
|
|
43
|
+
if (!ctx.runtimeDir) return { status: 200, body: { ok: true, key: store.keyOf(e), items: [] } };
|
|
44
|
+
const body = { ok: true, key: store.keyOf(e), items: store.forMeeting(ctx.runtimeDir, e) };
|
|
45
|
+
if (q.topics === '1' || q.topics === 'true') body.topics = await topicsOf(q, ctx);
|
|
46
|
+
return { status: 200, body };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function post(req, body, ctx) {
|
|
50
|
+
const g = guard.requirePagePress(req, body);
|
|
51
|
+
if (!g.ok) return fail(g.status, g.error);
|
|
52
|
+
if (!ctx.runtimeDir) return fail(409, 'this server has no runtime folder, so the material cannot be saved');
|
|
53
|
+
const out = store.append(ctx.runtimeDir, body, g.actor && g.actor.by ? g.actor.by : null);
|
|
54
|
+
return out.ok ? { status: 200, body: out } : fail(400, out.error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ── what PMOS can offer ──────────────────────────────────────────────────────────────────────────────────────────── */
|
|
58
|
+
|
|
59
|
+
const dayText = (d) => {
|
|
60
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(d || '')); if (!m) return '';
|
|
61
|
+
const t = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
|
62
|
+
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][t.getUTCDay()] + ' ' + (+m[3]) + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][+m[2] - 1];
|
|
63
|
+
};
|
|
64
|
+
const isReportPath = (p) => /(^|\/)reports\//.test(p);
|
|
65
|
+
|
|
66
|
+
/** Every document and report cycle PMOS holds for a workspace, as picks. Each read is best effort: a failing source adds nothing. */
|
|
67
|
+
function candidates(ctx, w) {
|
|
68
|
+
const docs = []; const reports = [];
|
|
69
|
+
const root = w.root;
|
|
70
|
+
const owner = new Map();
|
|
71
|
+
try {
|
|
72
|
+
if (typeof ctx.initiativeIndex === 'function') {
|
|
73
|
+
for (const g of ctx.initiativeIndex(root).initiatives || []) for (const a of g.artifacts || []) { const rel = String(a.path).split('\\').join('/'); if (!a.shared && !owner.has(rel)) owner.set(rel, { id: g.id, title: g.title || g.id }); }
|
|
74
|
+
}
|
|
75
|
+
} catch { /* no attribution: a document has no initiative */ }
|
|
76
|
+
try {
|
|
77
|
+
const lib = require('./doc-library.js').library(root, { stateDir: typeof ctx.stateDirFor === 'function' ? ctx.stateDirFor(w) : undefined, owners: () => owner });
|
|
78
|
+
if (lib && lib.ok) {
|
|
79
|
+
const cat = new Map((lib.categories || []).map((c) => [c.key, c.name]));
|
|
80
|
+
for (const f of lib.files || []) {
|
|
81
|
+
if (f.node || isReportPath(f.path)) continue;
|
|
82
|
+
const o = owner.get(f.path) || (f.initiative ? { id: f.initiative } : null);
|
|
83
|
+
docs.push({ kind: 'pmos-doc', ref: f.path, title: f.title || f.path, date: f.date || '', ini: o ? o.id : null, ws: w.id,
|
|
84
|
+
meta: (o ? o.id + ' documents' : cat.get(f.category) || 'Documents') + (f.date ? ' · ' + dayText(f.date) : '') });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch { /* no library */ }
|
|
88
|
+
try {
|
|
89
|
+
const rs = require('./routes-report-setups.js');
|
|
90
|
+
for (const d of rs.documentsOf({ runtimeDir: ctx.runtimeDir, getWs: ctx.getWs }, w).docs || []) {
|
|
91
|
+
const deck = d.output === 'google-slides';
|
|
92
|
+
reports.push({ kind: deck ? 'pmos-deck' : 'report', ref: d.path, title: (d.name || d.id) + (d.slot ? ' · ' + d.slot : ''), date: String(d.slot || '').slice(0, 10), ini: null, ws: w.id,
|
|
93
|
+
meta: 'Reports · ' + (deck ? 'deck' : 'document') + (d.state === 'issued' ? ' · issued' : ' · draft'), present: deck });
|
|
94
|
+
}
|
|
95
|
+
} catch { /* no reports */ }
|
|
96
|
+
return { docs, reports };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const newest = (a, b) => String(b.date || '').localeCompare(String(a.date || ''));
|
|
100
|
+
const pick = (c, why) => ({ kind: c.kind, ref: c.ref, title: c.title, meta: c.meta, ...(why ? { why } : {}), present: c.present === true, ws: c.ws });
|
|
101
|
+
|
|
102
|
+
function suggest(q, ctx) {
|
|
103
|
+
const w = ctx.getWs ? ctx.getWs(q.ws || null) : null;
|
|
104
|
+
if (!w || !w.root) return fail(404, 'no such workspace');
|
|
105
|
+
const limit = Math.max(1, Math.min(20, Number(q.limit) || 6));
|
|
106
|
+
const ini = str(q.initiative, 120);
|
|
107
|
+
const c = candidates(ctx, w);
|
|
108
|
+
const suggested = [];
|
|
109
|
+
const seen = new Set();
|
|
110
|
+
const add = (x, why) => { if (suggested.length >= limit || seen.has(x.ref)) return; seen.add(x.ref); suggested.push(pick(x, why)); };
|
|
111
|
+
if (ini) {
|
|
112
|
+
for (const d of c.docs.filter((x) => x.ini === ini).sort(newest).slice(0, 4)) add(d, 'In ' + ini + (d.date ? ', edited ' + dayText(d.date) : ''));
|
|
113
|
+
}
|
|
114
|
+
for (const r of c.reports.slice().sort(newest).slice(0, 2)) add(r, 'A report PMOS keeps' + (r.date ? ', ' + dayText(r.date) : ''));
|
|
115
|
+
const text = str(q.q, 120).toLowerCase();
|
|
116
|
+
let found = [];
|
|
117
|
+
if (text.length >= 2) {
|
|
118
|
+
const hit = (x) => (x.title + ' ' + x.ref).toLowerCase().includes(text);
|
|
119
|
+
found = [...c.reports.filter(hit), ...c.docs.filter(hit)].sort(newest).slice(0, 8).map((x) => pick(x));
|
|
120
|
+
}
|
|
121
|
+
return { status: 200, body: { ok: true, suggested, found } };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
prefixes: ['/api/meeting-material'],
|
|
128
|
+
list, post, suggest, candidates,
|
|
129
|
+
handle: async (req, res, url, ctx) => {
|
|
130
|
+
const p = url.pathname;
|
|
131
|
+
const q = Object.fromEntries(url.searchParams);
|
|
132
|
+
if (p === '/api/meeting-material/suggest') {
|
|
133
|
+
if (req.method !== 'GET') return respond(ctx, res, fail(405, 'GET only'));
|
|
134
|
+
return respond(ctx, res, suggest(q, ctx));
|
|
135
|
+
}
|
|
136
|
+
if (p !== '/api/meeting-material') return false;
|
|
137
|
+
if (req.method === 'GET') return respond(ctx, res, await list(q, ctx));
|
|
138
|
+
if (req.method !== 'POST') return respond(ctx, res, fail(405, 'GET or POST only'));
|
|
139
|
+
const got = await ctx.readJson(req);
|
|
140
|
+
if (!got.ok || !got.body || typeof got.body !== 'object' || Array.isArray(got.body)) return respond(ctx, res, fail(400, 'the body must be a JSON object'));
|
|
141
|
+
return respond(ctx, res, post(req, got.body, ctx));
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-meeting-replay.js — the meeting replay's server side (initiative meeting-material-replay, part 2; lib/route-modules.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/meeting-replay?note=<id>[&event=<id>&date=YYYY-MM-DD]
|
|
5
|
+
* GET /api/meeting-replay?event=<id>[&date=YYYY-MM-DD] the note linked to that meeting
|
|
6
|
+
* → { ok, note, meeting, audio, start_ms, length_ms, lines, keys, topics, runs, notes, shots, aside_count } (lib/meeting-replay.js)
|
|
7
|
+
* The note (transcript, decisions, next steps, questions), the Focus state file (topics, stretches, PMOS's notes) and the
|
|
8
|
+
* shots, joined. Read only: it writes nothing, starts no run and reaches nothing outside the machine. A private note is a
|
|
9
|
+
* 404 (it keeps no audio and no shots); an unknown note or a meeting with no note is a 404. A shot that was set aside
|
|
10
|
+
* (`aside`) is not in `shots`; `aside_count` says how many there are.
|
|
11
|
+
* Handlers are pure over (query, ctx): ctx = { runtimeDir, readEvents?, now? }, so a test needs no server.
|
|
12
|
+
*/
|
|
13
|
+
const focus = require('./routes-meeting-focus.js');
|
|
14
|
+
const notes = require('./notes.js');
|
|
15
|
+
const notesAudio = require('./notes-audio.js');
|
|
16
|
+
const notesScreen = require('./notes-screen.js');
|
|
17
|
+
const F = require('./meeting-focus.js');
|
|
18
|
+
const R = require('./meeting-replay.js');
|
|
19
|
+
|
|
20
|
+
const fail = (status, error) => ({ status, body: { ok: false, error } });
|
|
21
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
|
|
23
|
+
/** The note's shots, the kept ones and the set-aside ones, so the join can count the set-aside ones and leave them out.
|
|
24
|
+
* notesScreen.list(dir, id) lists the kept shots; { aside: true } lists only the set-aside ones. */
|
|
25
|
+
function framesOf(dir, id) {
|
|
26
|
+
let all = [];
|
|
27
|
+
try { all = [...(notesScreen.list(dir, id) || []), ...(notesScreen.list(dir, id, { aside: true }) || [])]; } catch { all = []; }
|
|
28
|
+
return all.filter((f) => f && typeof f.t === 'number').sort((a, b) => a.t - b.t);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function read(q, ctx) {
|
|
32
|
+
const dir = ctx.runtimeDir;
|
|
33
|
+
const noteId = String((q && q.note) || '').slice(0, 120);
|
|
34
|
+
const eventId = String((q && q.event) || '').slice(0, 120);
|
|
35
|
+
const date = q && q.date ? String(q.date) : '';
|
|
36
|
+
if (!noteId && !eventId) return fail(400, 'note or event is required');
|
|
37
|
+
if (date && !DAY.test(date)) return fail(400, 'date must be YYYY-MM-DD');
|
|
38
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
39
|
+
let events = [];
|
|
40
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(dir, { now: nowMs }); } catch { events = []; }
|
|
41
|
+
let note = null; let ev = null;
|
|
42
|
+
if (noteId) {
|
|
43
|
+
const r = notes.read(dir, noteId);
|
|
44
|
+
if (!r.ok) return fail(404, 'no such note');
|
|
45
|
+
note = r.note;
|
|
46
|
+
if (eventId) ev = focus.findEvent(events, eventId, date);
|
|
47
|
+
const m = note.meeting;
|
|
48
|
+
if (!ev && m && m.kind === 'event' && m.event_id) ev = focus.findEvent(events, String(m.event_id), date || String(m.start || '').slice(0, 10));
|
|
49
|
+
} else {
|
|
50
|
+
ev = focus.findEvent(events, eventId, date);
|
|
51
|
+
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
52
|
+
note = focus.noteFor(dir, ev, null);
|
|
53
|
+
if (!note) return fail(404, 'this meeting has no note to replay');
|
|
54
|
+
}
|
|
55
|
+
if (note.private) return fail(404, 'This note is private. It keeps no audio and no shots, so it has no replay.');
|
|
56
|
+
const talk = !ev;
|
|
57
|
+
if (!ev) ev = focus.noteEvent(note);
|
|
58
|
+
const keys = [focus.keyOf(ev.id), focus.keyOf(note.id)];
|
|
59
|
+
const rec = notesAudio.info(dir, note.id);
|
|
60
|
+
const audio = rec ? { url: '/api/notes/recording?id=' + encodeURIComponent(note.id) + '&b=' + rec.bytes + (notesAudio.hasClean(dir, note.id) ? '&c=1' : ''), ms: rec.ms } : null;
|
|
61
|
+
const body = R.build({ note, event: ev, stateLines: focus.linesOf(dir, keys), frames: framesOf(dir, note.id), audio, topics: talk ? [] : F.topicsFrom(ev, null), now: nowMs });
|
|
62
|
+
return { status: 200, body };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
prefixes: ['/api/meeting-replay'],
|
|
69
|
+
read,
|
|
70
|
+
handle: async (req, res, url, ctx) => {
|
|
71
|
+
if (url.pathname !== '/api/meeting-replay') return false;
|
|
72
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
73
|
+
return respond(ctx, res, await read(Object.fromEntries(url.searchParams), { runtimeDir: ctx.runtimeDir, json: ctx.json }));
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
* Refused: a private note (409), a note not listening (409), "Capture the shared screen" switched off (409),
|
|
41
41
|
* a token that is not the live capture's (409), a bad t or picture (400), a full note (409)
|
|
42
42
|
* GET /api/notes/screen?id=…&t=… → image/jpeg|png|webp the shot kept at meeting time t; 404 for a private note or no such shot
|
|
43
|
+
* (a shot set aside is not served, unless aside=1 is added: the review sheet's thumbnails)
|
|
44
|
+
* POST /api/notes/screen also takes surface: 'window' | 'browser' | 'monitor', what the picture shows; a 'monitor' shot is checked for other apps
|
|
45
|
+
* GET /api/notes/screen-aside?id=… → { ok, frames: [{ t, w, h, bytes, app, text? }], days } the shots set aside as another app (meeting-material-replay)
|
|
46
|
+
* POST /api/notes/screen-aside { id, action: 'keep' | 'delete' | 'delete-all', t? } → { ok, frame? , deleted?, aside } the PM's answer; GET /api/notes/one
|
|
47
|
+
* also carries screen.aside, the count. Set-aside shots older than 7 days are deleted on their own.
|
|
43
48
|
* POST /api/notes/screen-delete { id } → { ok, deleted } deletes the note's screenshots and keeps the rest
|
|
44
49
|
* POST /api/notes/clean-audio { id } → { ok, started } makes the cleaned copy of the replay now (Run 3 of notes-audio-quality; needs ffmpeg)
|
|
45
50
|
* GET /api/notes/engine?ws=… → { ok, whisper, jev } is Whisper set up; which Jev gates hold (booleans only)
|
|
@@ -111,7 +116,7 @@ const VOCAB_TTL_MS = 60 * 1000;
|
|
|
111
116
|
const prefixes = ['/api/notes'];
|
|
112
117
|
const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
|
|
113
118
|
'/api/notes/private', '/api/notes/pause', '/api/notes/stop', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
|
|
114
|
-
'/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/screen', '/api/notes/screen-delete', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
|
|
119
|
+
'/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/screen', '/api/notes/screen-delete', '/api/notes/screen-aside', '/api/notes/clean-audio', '/api/notes/link', '/api/notes/bullet', '/api/notes/highlight', '/api/notes/highlight-remove', '/api/notes/final-edit', '/api/notes/wispr-fetch', '/api/notes/series-done', '/api/notes/series-bring', '/api/notes/series-unbring', '/api/notes/series-member']);
|
|
115
120
|
|
|
116
121
|
/* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
|
|
117
122
|
const chains = new Map(); // note id → the promise of its work so far (minute batches, imports, labels, checks)
|
|
@@ -674,6 +679,13 @@ function resumeStuck(ctx) {
|
|
|
674
679
|
|
|
675
680
|
const send = (ctx, res, r) => ctx.json(res, r, r.ok ? 200 : (r.status || 400));
|
|
676
681
|
|
|
682
|
+
/** Delete the shots set aside for more than 7 days. At most once in ten minutes unless `now` is true. */
|
|
683
|
+
let asideSweptAt = 0;
|
|
684
|
+
function sweepAsideSoon(dir, now = false) {
|
|
685
|
+
if (!now && Date.now() - asideSweptAt < 10 * 60000) return;
|
|
686
|
+
asideSweptAt = Date.now();
|
|
687
|
+
try { notesScreen.sweepAside(dir); } catch { /* a folder that cannot be read stays as it was */ }
|
|
688
|
+
}
|
|
677
689
|
async function handle(req, res, url, ctx) {
|
|
678
690
|
const p = url.pathname;
|
|
679
691
|
const dir = ctx.runtimeDir;
|
|
@@ -683,6 +695,7 @@ async function handle(req, res, url, ctx) {
|
|
|
683
695
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
684
696
|
if (p === '/api/notes') {
|
|
685
697
|
const l = notes.list(dir, { resumeMs: resumeMsOf(ctx) });
|
|
698
|
+
sweepAsideSoon(dir);
|
|
686
699
|
if (l.ok) { sweepExpired(ctx, l.notes); try { l.series_open = notesSeries.openCounts(dir, l.notes); } catch { l.series_open = {}; } }
|
|
687
700
|
send(ctx, res, l); return true;
|
|
688
701
|
}
|
|
@@ -697,7 +710,7 @@ async function handle(req, res, url, ctx) {
|
|
|
697
710
|
if (r.ok && mergeWispr(ctx, nid)) r = notes.read(dir, nid, { resumeMs: resumeMsOf(ctx) });
|
|
698
711
|
if (r.ok) {
|
|
699
712
|
r.audio = { pending: transcriber ? transcriber.pending() : 0, error: audioErrors.get(nid) || null }; r.recording = notesAudio.info(dir, nid); if (r.recording) r.recording = { ...r.recording, cleaned: notesAudio.hasClean(dir, nid) };
|
|
700
|
-
r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid) };
|
|
713
|
+
r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid), aside: r.note.private ? 0 : notesScreen.asideCount(dir, nid) };
|
|
701
714
|
if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
|
|
702
715
|
r.bullets_state = bulletsState(ctx, r.note);
|
|
703
716
|
// where the note sits in its meeting series, and what changed since the meeting before (c3-notes-series)
|
|
@@ -717,7 +730,16 @@ async function handle(req, res, url, ctx) {
|
|
|
717
730
|
const r = notes.read(dir, nid);
|
|
718
731
|
if (!r.ok) { send(ctx, res, r); return true; }
|
|
719
732
|
if (r.note.private) { ctx.json(res, { ok: false, error: 'this note is private and keeps no screenshots' }, 404); return true; }
|
|
720
|
-
notesScreen.serve(req, res, dir, nid, url.searchParams.get('t'));
|
|
733
|
+
notesScreen.serve(req, res, dir, nid, url.searchParams.get('t'), { aside: url.searchParams.get('aside') === '1' });
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
if (p === '/api/notes/screen-aside' && req.method === 'GET') {
|
|
737
|
+
const nid = url.searchParams.get('id');
|
|
738
|
+
const r = notes.read(dir, nid);
|
|
739
|
+
if (!r.ok) { send(ctx, res, r); return true; }
|
|
740
|
+
if (r.note.private) { ctx.json(res, { ok: true, frames: [], days: notesScreen.ASIDE_DAYS }); return true; }
|
|
741
|
+
sweepAsideSoon(dir, true);
|
|
742
|
+
ctx.json(res, { ok: true, days: notesScreen.ASIDE_DAYS, frames: notesScreen.listAside(dir, nid).map((f) => ({ t: f.t, w: f.w, h: f.h, bytes: f.bytes, app: f.asideApp || '', text: f.text || '', at: f.asideAt || null })) });
|
|
721
743
|
return true;
|
|
722
744
|
}
|
|
723
745
|
if (p === '/api/notes/recording' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
@@ -915,7 +937,18 @@ async function handle(req, res, url, ctx) {
|
|
|
915
937
|
if (!own.ok) return done(own);
|
|
916
938
|
if (typeof body.t !== 'number' || !Number.isFinite(body.t) || body.t < 0) return done({ ok: false, status: 400, error: 'a screenshot needs its meeting time' });
|
|
917
939
|
if (typeof body.img !== 'string' || !body.img) return done({ ok: false, status: 400, error: 'there is no picture to keep' });
|
|
918
|
-
return done(notesScreen.add(dir, { id, t: body.t, buf: Buffer.from(body.img, 'base64'), w: body.w, h: body.h, why: body.why }));
|
|
940
|
+
return done(notesScreen.add(dir, { id, t: body.t, buf: Buffer.from(body.img, 'base64'), w: body.w, h: body.h, why: body.why, surface: body.surface }));
|
|
941
|
+
}
|
|
942
|
+
if (p === '/api/notes/screen-aside') {
|
|
943
|
+
const r = notes.read(dir, id);
|
|
944
|
+
if (!r.ok) return done(r);
|
|
945
|
+
const act = body.action;
|
|
946
|
+
let out;
|
|
947
|
+
if (act === 'keep') out = notesScreen.keep(dir, id, body.t);
|
|
948
|
+
else if (act === 'delete') out = notesScreen.discard(dir, id, body.t);
|
|
949
|
+
else if (act === 'delete-all') out = notesScreen.discardAll(dir, id);
|
|
950
|
+
else return done({ ok: false, status: 400, error: 'say action: keep, delete or delete-all' });
|
|
951
|
+
return done(out.ok ? { ...out, aside: notesScreen.asideCount(dir, id) } : out);
|
|
919
952
|
}
|
|
920
953
|
if (p === '/api/notes/screen-delete') {
|
|
921
954
|
const r = notes.read(dir, id);
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/* routes-report-setups.js — the Reports workspace's routes (initiative report-workspace, PRD sections 2 to 6).
|
|
3
3
|
*
|
|
4
4
|
* GET /api/report-setups?ws → { setups: [{ id, name, purpose, audience, source, output, schedule, label, next_draft, last_issued, status, cycles,
|
|
5
|
-
* copy: { kind, state: none|in-step|pmos-ahead|copy-changed|not-checked, url }
|
|
5
|
+
* copy: { kind, state: none|in-step|pmos-ahead|copy-changed|not-checked, url },
|
|
6
|
+
* structure: { saved, at, charts, sections } | null the saved report structure (initiative report-structure) }],
|
|
6
7
|
* findings: [{ id, name, why, evidence: [{ kind, text }], confidence: likely|not-sure, proposal }],
|
|
7
8
|
* asks: [{ id, text, state: running|ready|failed, changed, proposal, error }],
|
|
8
9
|
* finder: { cadence, sources: { calendar, slack, mail, drive }, last_run, next_run } }
|
|
@@ -119,7 +120,10 @@ function documentsOf(ctx, w) {
|
|
|
119
120
|
return { docs, reports: r.reports };
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
/** The saved structure of a report (initiative report-structure): { saved, at, charts, sections }, else null. */
|
|
124
|
+
function structureOf(root, id) { try { return require('./report-structure.js').savedOf(root, id); } catch { return null; } }
|
|
125
|
+
|
|
126
|
+
function setupOut(s, now, docsOf, idx, root) {
|
|
123
127
|
const mine = (docsOf.reports || []).filter((x) => x.cadence === s.id);
|
|
124
128
|
const issued = mine.filter((x) => x.issued).map((x) => x.slot).sort().pop() || null;
|
|
125
129
|
const latest = mine.slice().sort((a, b) => (a.slot < b.slot ? 1 : -1))[0] || null;
|
|
@@ -129,6 +133,7 @@ function setupOut(s, now, docsOf, idx) {
|
|
|
129
133
|
id: s.id, name: s.name, purpose: s.purpose, audience: s.audience, source: s.source, output: s.output, schedule: s.schedule, label: RS.scheduleLabel(s.schedule),
|
|
130
134
|
next_draft: next, last_issued: issued, status: s.status, cycles: mine.length,
|
|
131
135
|
copy: copyOf(latest ? linkFor(idx, latest.path, s.output) : null, docSync.copyKindOf(s.output)),
|
|
136
|
+
structure: structureOf(root, s.id),
|
|
132
137
|
};
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -157,7 +162,7 @@ function listAnswer(ctx, w, now) {
|
|
|
157
162
|
const docsOf = documentsOf(ctx, w);
|
|
158
163
|
const idx = copyIndex(w.root);
|
|
159
164
|
return {
|
|
160
|
-
setups: [...st.setups.values()].map((s) => setupOut(s, new Date(now), docsOf, idx)),
|
|
165
|
+
setups: [...st.setups.values()].map((s) => setupOut(s, new Date(now), docsOf, idx, w.root)),
|
|
161
166
|
findings: RS.openFindings(st).map((f) => ({ id: f.id, name: f.name, why: f.why, evidence: f.evidence, confidence: f.confidence, proposal: f.proposal })),
|
|
162
167
|
asks: [...st.asks.values()].map((a) => ({ id: a.id, text: a.text, state: a.state, changed: !!a.changed, proposal: a.proposal, error: a.error })),
|
|
163
168
|
finder: finderOut(ctx, st, now),
|
|
@@ -341,7 +346,7 @@ async function handle(req, res, url, ctx) {
|
|
|
341
346
|
// in place and says why; Draft it now on the row tries again.
|
|
342
347
|
const drafted = firstDraft(root, r, g.actor);
|
|
343
348
|
if (drafted.path) ctx.invalidateNeedsAgg && ctx.invalidateNeedsAgg();
|
|
344
|
-
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), documentsOf(ctx, w), copyIndex(root)), first_draft: r.first_draft, drafted } });
|
|
349
|
+
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), documentsOf(ctx, w), copyIndex(root), root), first_draft: r.first_draft, drafted } });
|
|
345
350
|
}
|
|
346
351
|
if (sub === 'dismiss') {
|
|
347
352
|
const r = RS.dismissFinding(file, { id: text(body.finding), by: g.actor });
|
|
@@ -363,7 +368,7 @@ async function handle(req, res, url, ctx) {
|
|
|
363
368
|
if (sub === 'pause' || sub === 'resume') {
|
|
364
369
|
const r = RS[sub](root, text(body.id), opts);
|
|
365
370
|
if (!r.ok) return fail(r);
|
|
366
|
-
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), documentsOf(ctx, w), copyIndex(root)) } });
|
|
371
|
+
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), documentsOf(ctx, w), copyIndex(root), root) } });
|
|
367
372
|
}
|
|
368
373
|
if (sub === 'from-dashboard') {
|
|
369
374
|
const key = text(body.key);
|
|
@@ -376,7 +381,7 @@ async function handle(req, res, url, ctx) {
|
|
|
376
381
|
schedule: body.schedule, sources: [`The ${name} dashboard`], sections: RS.FRAME.slice(), learned_from: 'Set up from the dashboard.', fixes: [] };
|
|
377
382
|
const r = RS.setUp(root, proposal, opts);
|
|
378
383
|
if (!r.ok) return fail(r);
|
|
379
|
-
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), { reports: [] }, new Map()) } });
|
|
384
|
+
return send({ status: 200, body: { ok: true, setup: setupOut(r.setup, new Date(), { reports: [] }, new Map(), root) } });
|
|
380
385
|
}
|
|
381
386
|
if (sub === 'finder') {
|
|
382
387
|
const r = RS.setFinder(file, { cadence: body.cadence, sources: body.sources, by: g.actor });
|