create-pmos 0.529.0 → 0.531.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 +27 -49
- 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/changelog.json +26 -0
- package/workspace/guide/notes.md +2 -2
- package/workspace/guide/reports.md +3 -1
- package/workspace/lib/jev-notes.js +58 -3
- package/workspace/lib/jev-screen.js +110 -0
- package/workspace/lib/meeting-replay.js +5 -2
- package/workspace/lib/notes-screen.js +15 -1
- package/workspace/lib/report-cli.js +16 -3
- package/workspace/lib/report-engine.js +67 -17
- package/workspace/lib/report-export.js +155 -3
- package/workspace/lib/report-library.js +76 -0
- package/workspace/lib/report-pyramid.js +2 -0
- package/workspace/lib/report-slides.js +580 -0
- package/workspace/lib/routes-meeting-replay.js +13 -2
- package/workspace/lib/routes-notes.js +25 -4
- package/workspace/lib/typesafe.js +71 -5
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-vw.css +1 -0
- package/workspace/public/pm/notes-screen-capture.js +5 -4
- package/workspace/public/pm/premium-report-read.js +24 -3
- package/workspace/public/pm/premium-vw-mtg.js +15 -0
- package/workspace/public/pm/view-notes.js +2 -2
- package/workspace/skills/report-write.skill +2 -1
- package/workspace/test-files.json +1 -0
- package/workspace/test-lists/report-slides.json +9 -0
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* GET /api/meeting-replay?event=<id>[&date=YYYY-MM-DD] the note linked to that meeting
|
|
6
6
|
* → { ok, note, meeting, audio, start_ms, length_ms, lines, keys, topics, runs, notes, shots, aside_count } (lib/meeting-replay.js)
|
|
7
7
|
* The note (transcript, decisions, next steps, questions), the Focus state file (topics, stretches, PMOS's notes) and the
|
|
8
|
-
* shots, joined.
|
|
8
|
+
* shots, joined. It writes nothing itself. When kept shots have no answer from Jev yet and the gates let notes use Jev, the
|
|
9
|
+
* handler starts Jev on them in the background (routes-notes.js lookAtShots) and sets `jev_running`. A private note is a
|
|
9
10
|
* 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
11
|
* (`aside`) is not in `shots`; `aside_count` says how many there are.
|
|
11
12
|
* Handlers are pure over (query, ctx): ctx = { runtimeDir, readEvents?, now? }, so a test needs no server.
|
|
@@ -16,6 +17,7 @@ const notesAudio = require('./notes-audio.js');
|
|
|
16
17
|
const notesScreen = require('./notes-screen.js');
|
|
17
18
|
const F = require('./meeting-focus.js');
|
|
18
19
|
const R = require('./meeting-replay.js');
|
|
20
|
+
const jevScreen = require('./jev-screen.js');
|
|
19
21
|
|
|
20
22
|
const fail = (status, error) => ({ status, body: { ok: false, error } });
|
|
21
23
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -59,6 +61,8 @@ async function read(q, ctx) {
|
|
|
59
61
|
const rec = notesAudio.info(dir, note.id);
|
|
60
62
|
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
63
|
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 });
|
|
64
|
+
// Jev is cutting this note's shots to the slide now (opening the note starts it, lib/routes-notes.js): the page reads the replay again
|
|
65
|
+
if (jevScreen.busy(note.id)) body.jev_running = true;
|
|
62
66
|
return { status: 200, body };
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -70,6 +74,13 @@ module.exports = {
|
|
|
70
74
|
handle: async (req, res, url, ctx) => {
|
|
71
75
|
if (url.pathname !== '/api/meeting-replay') return false;
|
|
72
76
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
73
|
-
|
|
77
|
+
const r = await read(Object.fromEntries(url.searchParams), { runtimeDir: ctx.runtimeDir, json: ctx.json });
|
|
78
|
+
// shots Jev has not looked at (a past meeting, kept before Jev was on): start Jev on them in the background, as GET
|
|
79
|
+
// /api/notes/one does, so a replay opened from the calendar is cut to the slide too. It never blocks the answer.
|
|
80
|
+
const nid = r.status === 200 && r.body && r.body.note ? r.body.note.id : null;
|
|
81
|
+
if (nid && !r.body.jev_running && notesScreen.list(ctx.runtimeDir, nid).some((f) => !f.jev)) {
|
|
82
|
+
try { if (require('./routes-notes.js').lookAtShots(ctx, nid)) r.body.jev_running = true; } catch { /* advisory */ }
|
|
83
|
+
}
|
|
84
|
+
return respond(ctx, res, r);
|
|
74
85
|
},
|
|
75
86
|
};
|
|
@@ -100,6 +100,7 @@ const notesCapture = require('./notes-capture.js');
|
|
|
100
100
|
const notesRepo = require('./notes-repo.js');
|
|
101
101
|
const notesAudio = require('./notes-audio.js');
|
|
102
102
|
const notesScreen = require('./notes-screen.js');
|
|
103
|
+
const jevScreen = require('./jev-screen.js');
|
|
103
104
|
const notesEcho = require('./notes-echo.js');
|
|
104
105
|
const notesPolish = require('./notes-polish.js');
|
|
105
106
|
const notesFinal = require('./notes-final.js');
|
|
@@ -135,12 +136,28 @@ function enrich(ctx, id, added, kind = 'lines') {
|
|
|
135
136
|
}
|
|
136
137
|
function gatesFor(ctx, root) {
|
|
137
138
|
const dir = ctx.runtimeDir;
|
|
138
|
-
if (!root) return { root: null, exclude: [], ask: null, check: false, key: null, askOpts: {} };
|
|
139
|
+
if (!root) return { root: null, exclude: [], ask: null, askImage: null, check: false, key: null, askOpts: {} };
|
|
139
140
|
const repo = tsSettings.getRepo(dir, root);
|
|
140
141
|
const sw = jevNotes.getSwitch(dir, root);
|
|
141
142
|
const key = tsSettings.getKey(dir);
|
|
142
143
|
const snd = jevNotes.senderFor(key, { consent: repo.consent, jev: sw.jev, check: sw.check }, typeof ctx.jevModelOpts === 'function' ? ctx.jevModelOpts(root) : {});
|
|
143
|
-
return { root, exclude: repo.exclude, ask: snd.ask, check: snd.check, key: snd.ask ? key : null, askOpts: snd.askOpts };
|
|
144
|
+
return { root, exclude: repo.exclude, ask: snd.ask, askImage: snd.askImage, check: snd.check, key: snd.ask ? key : null, askOpts: snd.askOpts };
|
|
145
|
+
}
|
|
146
|
+
/** The repository whose gates hold for Jev looking at a note's shots: the note's own, or for a note bound to no repository
|
|
147
|
+
* (most past meetings) the first repository where "notes may use Jev" is on and the PM gave consent. */
|
|
148
|
+
function shotRootOf(ctx, note) {
|
|
149
|
+
const w = note.ws && typeof ctx.getWs === 'function' ? ctx.getWs(note.ws) : null;
|
|
150
|
+
if (w && w.root) return w.root;
|
|
151
|
+
return jevNotes.onRoots(ctx.runtimeDir).find((r) => tsSettings.getRepo(ctx.runtimeDir, r).consent) || null;
|
|
152
|
+
}
|
|
153
|
+
/** Jev looks at the note's shots it has not seen (MpJ), in the background, when the repository's gates let notes use Jev.
|
|
154
|
+
* Opening a past meeting (GET /api/notes/one, GET /api/meeting-replay) starts it, so old captures are cut to the slide too. */
|
|
155
|
+
function lookAtShots(ctx, id) {
|
|
156
|
+
const note = stillOpen(ctx.runtimeDir, id);
|
|
157
|
+
if (!note) return null;
|
|
158
|
+
const g = gatesFor(ctx, shotRootOf(ctx, note));
|
|
159
|
+
if (!g.askImage) return null;
|
|
160
|
+
return jevScreen.run({ runtimeDir: ctx.runtimeDir, id, g });
|
|
144
161
|
}
|
|
145
162
|
const stillOpen = (dir, id) => { const r = notes.read(dir, id); return r.ok && !r.note.private ? r.note : null; };
|
|
146
163
|
/** Jev's label (when the label gates hold), then for a checkable item the closest repository passage and Jev's comparison
|
|
@@ -798,6 +815,8 @@ async function handle(req, res, url, ctx) {
|
|
|
798
815
|
if (r.ok) {
|
|
799
816
|
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) };
|
|
800
817
|
r.screen = { frames: r.note.private ? [] : notesScreen.list(dir, nid), aside: r.note.private ? 0 : notesScreen.asideCount(dir, nid) };
|
|
818
|
+
// shots Jev has not looked at yet (kept before Jev was switched on, or while it could not be reached): asked now, in the background
|
|
819
|
+
if (!r.note.private && r.screen.frames.some((f) => !f.jev)) { try { lookAtShots(ctx, nid); } catch { /* advisory */ } }
|
|
801
820
|
if (!Array.isArray(r.note.bullets)) r.note.bullets = [];
|
|
802
821
|
r.bullets_state = bulletsState(ctx, r.note);
|
|
803
822
|
// where the note sits in its meeting series, and what changed since the meeting before (c3-notes-series)
|
|
@@ -1027,7 +1046,9 @@ async function handle(req, res, url, ctx) {
|
|
|
1027
1046
|
if (!own.ok) return done(own);
|
|
1028
1047
|
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' });
|
|
1029
1048
|
if (typeof body.img !== 'string' || !body.img) return done({ ok: false, status: 400, error: 'there is no picture to keep' });
|
|
1030
|
-
|
|
1049
|
+
const kept = 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 });
|
|
1050
|
+
if (kept.ok) { try { lookAtShots(ctx, id); } catch { /* Jev is advisory: the shot is kept either way */ } }
|
|
1051
|
+
return done(kept);
|
|
1031
1052
|
}
|
|
1032
1053
|
if (p === '/api/notes/screen-aside') {
|
|
1033
1054
|
const r = notes.read(dir, id);
|
|
@@ -1199,6 +1220,6 @@ function startAgent(ctx, dir, id, w) {
|
|
|
1199
1220
|
return { ok: true, started, session: s ? s.id : null, reason, handoff: h.handoff, out: h.out, withheld: h.withheld };
|
|
1200
1221
|
}
|
|
1201
1222
|
|
|
1202
|
-
module.exports = { prefixes, handle, enrich, runMinute, bulletsState, queueFinal, askWispr, checkWispr, fetchWisprNow, mergeWispr, resumeStuck,
|
|
1223
|
+
module.exports = { prefixes, handle, lookAtShots, shotRootOf, enrich, runMinute, bulletsState, queueFinal, askWispr, checkWispr, fetchWisprNow, mergeWispr, resumeStuck,
|
|
1203
1224
|
__resetResume: () => { resumedFor = null; },
|
|
1204
1225
|
__idle: () => Promise.all([transcriber ? transcriber.idle() : null, finalRunner ? finalRunner.idle() : null, ...chains.values()]) };
|
|
@@ -81,7 +81,7 @@ function validKey(key) {
|
|
|
81
81
|
return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function once({ key, body, timeoutMs, ep = endpoint() }) {
|
|
84
|
+
function once({ key, body, timeoutMs, ep = endpoint(), headers: extra = null }) {
|
|
85
85
|
const mod = ep.protocol === 'http:' ? http : https;
|
|
86
86
|
const payload = Buffer.from(body, 'utf8');
|
|
87
87
|
return new Promise((resolve) => {
|
|
@@ -91,7 +91,7 @@ function once({ key, body, timeoutMs, ep = endpoint() }) {
|
|
|
91
91
|
protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
|
|
92
92
|
headers: {
|
|
93
93
|
'Content-Type': 'application/json', 'Content-Length': payload.length,
|
|
94
|
-
...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace',
|
|
94
|
+
...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace', ...(extra || {}),
|
|
95
95
|
},
|
|
96
96
|
timeout: timeoutMs,
|
|
97
97
|
}, (res) => {
|
|
@@ -135,10 +135,10 @@ function once({ key, body, timeoutMs, ep = endpoint() }) {
|
|
|
135
135
|
* @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
|
|
136
136
|
* `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
|
|
137
137
|
*/
|
|
138
|
-
async function send({ key, body, ep, timeoutMs, retries, backoffMs }) {
|
|
138
|
+
async function send({ key, body, ep, timeoutMs, retries, backoffMs, headers = null }) {
|
|
139
139
|
let last = null;
|
|
140
140
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
141
|
-
last = await once({ key, body, timeoutMs, ep });
|
|
141
|
+
last = await once({ key, body, timeoutMs, ep, headers });
|
|
142
142
|
if (last.ok) return { ...last, requested: true };
|
|
143
143
|
if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
|
|
144
144
|
await sleep(backoffMs * Math.pow(2, attempt));
|
|
@@ -182,4 +182,70 @@ async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 3
|
|
|
182
182
|
return l.ok ? { ...l, fallback: true, hosted_reason: r.reason } : { ...r, reason: r.reason + '; ' + l.reason };
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
/* ── images (docs.typesafe.ai/images.md, read 2026-11-04) ──────────────────────────────────────────────────────────────
|
|
186
|
+
* Jev 1.13 and 1.14 take still PNG or JPEG images; a Box question needs Jev 1.14 (jev-latest). Each request that holds an image
|
|
187
|
+
* or a Box question carries the header `TypeSafe-Danger-Zone-Images: true` (the feature is a preview). The image goes in the
|
|
188
|
+
* top-level `assets` map under a reference, and the reference is written in `state` where the image belongs. The service does
|
|
189
|
+
* not resize: each side must be 280 to 1568 px and the picture 78,400 to 2,458,624 px, so a caller sends a picture in range.
|
|
190
|
+
* The local servers (Winnow, Laya) take text only, so an image is never asked locally. */
|
|
191
|
+
const IMAGE_HEADER = { 'TypeSafe-Danger-Zone-Images': 'true' };
|
|
192
|
+
const IMAGE_SIDE = { min: 280, max: 1568 };
|
|
193
|
+
const IMAGE_PIXELS = { min: 78400, max: 2458624 };
|
|
194
|
+
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
|
195
|
+
|
|
196
|
+
/** The width and height a PNG or JPEG file says it has, from its own bytes → { format: 'png'|'jpg', w, h } | null. */
|
|
197
|
+
function imageSize(buf) {
|
|
198
|
+
if (!Buffer.isBuffer(buf) || buf.length < 24) return null;
|
|
199
|
+
if (buf.readUInt32BE(0) === 0x89504e47 && buf.toString('latin1', 12, 16) === 'IHDR') return { format: 'png', w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
200
|
+
if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
201
|
+
let i = 2;
|
|
202
|
+
while (i + 9 < buf.length) {
|
|
203
|
+
if (buf[i] !== 0xff) { i++; continue; }
|
|
204
|
+
const m = buf[i + 1];
|
|
205
|
+
if (m === 0xff) { i++; continue; }
|
|
206
|
+
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; }
|
|
207
|
+
const len = buf.readUInt16BE(i + 2);
|
|
208
|
+
// a start-of-frame marker (C0–CF, but not C4 DHT, C8 JPG, CC DAC) holds the height then the width
|
|
209
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { format: 'jpg', w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) };
|
|
210
|
+
i += 2 + len;
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
/** Why Jev would refuse this picture, or null when it is in range. */
|
|
215
|
+
function imageRefusal(size, bytes) {
|
|
216
|
+
if (!size) return 'the picture is not a PNG or a JPEG';
|
|
217
|
+
if (bytes > MAX_IMAGE_BYTES) return 'the picture is larger than 16 MB';
|
|
218
|
+
const { w, h } = size;
|
|
219
|
+
if (w < IMAGE_SIDE.min || h < IMAGE_SIDE.min || w > IMAGE_SIDE.max || h > IMAGE_SIDE.max) return `the picture is ${w} × ${h} px; Jev takes 280 to 1568 px a side`;
|
|
220
|
+
if (w * h < IMAGE_PIXELS.min || w * h > IMAGE_PIXELS.max) return `the picture has ${w * h} px; Jev takes 78,400 to 2,458,624`;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Ask Jev typed questions about one picture.
|
|
226
|
+
*
|
|
227
|
+
* @param {object} a
|
|
228
|
+
* @param {string} a.key the API key. Required; no key (or only the local handle) means no request.
|
|
229
|
+
* @param {Buffer} a.image a PNG or JPEG file, in Jev's size range (imageRefusal says why not).
|
|
230
|
+
* @param {string} a.name how `state` and the questions call the picture, e.g. 'capture'. Letters only.
|
|
231
|
+
* @param {object} a.questions { name: { type: 'noul'|'score'|'choice'|'box', instructions, criteria? } }
|
|
232
|
+
* @param {string} [a.text] words to send with the picture (no secret: the caller scans them).
|
|
233
|
+
* @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
|
|
234
|
+
*/
|
|
235
|
+
async function askImage({ key, image, name = 'capture', questions, text = '', model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
|
|
236
|
+
if (!validKey(key) || key === LOCAL_KEY) return { ok: false, requested: false, reason: key === LOCAL_KEY ? 'the local model takes no pictures' : 'no TypeSafe key is set' };
|
|
237
|
+
const size = imageSize(image);
|
|
238
|
+
const refused = imageRefusal(size, image ? image.length : 0);
|
|
239
|
+
if (refused) return { ok: false, requested: false, reason: refused };
|
|
240
|
+
if (!/^[a-z]{1,32}$/.test(name)) return { ok: false, requested: false, reason: 'the picture needs a short name' };
|
|
241
|
+
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
242
|
+
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
243
|
+
// the reference is random, so no words in the questions or the text can match it by chance (images.md "References")
|
|
244
|
+
const ref = require('crypto').randomBytes(6).toString('hex') + '-' + name;
|
|
245
|
+
const words = String(text || '').slice(0, 4000);
|
|
246
|
+
const body = JSON.stringify({ model, state: { [name]: ref, ...(words ? { words } : {}) },
|
|
247
|
+
assets: { [ref]: { type: 'image', format: size.format, data_base64: image.toString('base64') } }, questions });
|
|
248
|
+
return send({ key, body, ep: endpoint(), timeoutMs, retries, backoffMs, headers: IMAGE_HEADER });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = { ask, askImage, imageSize, imageRefusal, IMAGE_SIDE, IMAGE_PIXELS, IMAGE_HEADER, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.531.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.531.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -261,6 +261,7 @@
|
|
|
261
261
|
.vz-words .vz-eye { align-self: flex-start; padding: 3px 8px; border-radius: 4px; background: color-mix(in oklab, var(--cz-c-ffffff) 18%, transparent); font-size: 9px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
262
262
|
.vz-fr { position: absolute; inset: 0; overflow: hidden; }
|
|
263
263
|
.vz-fr iframe { width: 1280px; height: 720px; border: 0; transform-origin: 0 0; transform: scale(var(--rp-scale, .4)); pointer-events: none; background: var(--cz-c-ffffff); }
|
|
264
|
+
.vz-fr.vz-live iframe { pointer-events: auto; } /* a slide with CSS-only controls (report library timeline, actions): premium-report-read.js slideCard */
|
|
264
265
|
.vz-snav { display: flex; align-items: center; gap: 8px; }
|
|
265
266
|
.vz-snav > b { font-size: var(--fs-label); font-weight: var(--fw-strong); line-height: var(--lh-label); }
|
|
266
267
|
.vz-snav > span:not(.vz-sp) { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* pictures of the last RING_MS (20 s) in memory for this, because a line is transcribed some seconds after it is said.
|
|
24
24
|
* - highlight: the PM's Highlight keeps the picture on screen at that moment.
|
|
25
25
|
* A picture the last kept shot already shows is never kept again, whatever the reason. Each shot carries why it was kept.
|
|
26
|
-
* The shot is a JPEG of the frame at most
|
|
26
|
+
* The shot is a JPEG of the frame at most MAX_SIDE px on its longer side: 1568 is the most Jev for images reads (lib/jev-screen.js). It is posted with the meeting time, the note and the capture token, so
|
|
27
27
|
* the server keeps it beside the transcript and refuses a second tab (lib/notes-screen.js).
|
|
28
28
|
*
|
|
29
29
|
* WHAT IT NEVER DOES. It sends a shot only to this workspace's own server (`post`), never anywhere else.
|
|
@@ -32,7 +32,7 @@ import { bind, unbind } from './notes-shots.js';
|
|
|
32
32
|
|
|
33
33
|
const SMALL_W = 64;
|
|
34
34
|
const SMALL_H = 36;
|
|
35
|
-
const
|
|
35
|
+
const MAX_SIDE = 1568;
|
|
36
36
|
const QUALITY = 0.72;
|
|
37
37
|
|
|
38
38
|
export function changed(prev, cur, { pixel = 24, share = 0.02 } = {}) {
|
|
@@ -87,8 +87,9 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
|
|
|
87
87
|
const s = { count: 0, lost: 0, stopped: false, paused: false, last: null, lastAt: 0, prev: null, moving: 0, still: 0, run: 0, ring: [], kept: new Map(), cues: [], heard: new Set(), chain: Promise.resolve(), timer: null };
|
|
88
88
|
|
|
89
89
|
const snap = async (t, grey) => {
|
|
90
|
-
const
|
|
91
|
-
const
|
|
90
|
+
const k = Math.min(1, MAX_SIDE / video.videoWidth, MAX_SIDE / video.videoHeight);
|
|
91
|
+
const w = Math.round(video.videoWidth * k);
|
|
92
|
+
const h = Math.round(video.videoHeight * k);
|
|
92
93
|
big.width = w; big.height = h;
|
|
93
94
|
big.getContext('2d').drawImage(video, 0, 0, w, h);
|
|
94
95
|
const blob = await new Promise((res) => big.toBlob(res, 'image/jpeg', QUALITY));
|
|
@@ -163,11 +163,32 @@ export function splitRender(html) {
|
|
|
163
163
|
return { style, slides, body: d.body.innerHTML };
|
|
164
164
|
}
|
|
165
165
|
const href = (f) => new URL('./' + f, import.meta.url).href;
|
|
166
|
+
/* The workspace face inside the frame (batch 27 item 14, report library B2). The frame's origin is opaque, so a font file it asks for is refused
|
|
167
|
+
* by CORS; the page reads the face once (same origin) and hands it to the frame as a data: font. Until it is read the slide shows the fallback face,
|
|
168
|
+
* then it is drawn again. Only a render that names the face takes it. */
|
|
169
|
+
const FACE = { css: '', state: '' };
|
|
170
|
+
function faceCss(style) {
|
|
171
|
+
if (!/Plus Jakarta Sans/.test(style)) return '';
|
|
172
|
+
if (FACE.state === '' && typeof fetch === 'function' && typeof FileReader === 'function') {
|
|
173
|
+
FACE.state = 'reading';
|
|
174
|
+
fetch('/vendor/plus-jakarta-sans-var.woff2').then((r) => (r.ok ? r.blob() : null)).then((b) => {
|
|
175
|
+
if (!b) { FACE.state = 'none'; return; }
|
|
176
|
+
const fr = new FileReader();
|
|
177
|
+
fr.onload = () => { FACE.css = '@font-face{font-family:"Plus Jakarta Sans";font-style:normal;font-weight:200 800;font-display:block;src:url(' + fr.result + ') format("woff2")}'; FACE.state = 'ready'; if (R) draw(); };
|
|
178
|
+
fr.onerror = () => { FACE.state = 'none'; };
|
|
179
|
+
fr.readAsDataURL(b);
|
|
180
|
+
}).catch(() => { FACE.state = 'none'; });
|
|
181
|
+
}
|
|
182
|
+
return FACE.css;
|
|
183
|
+
}
|
|
166
184
|
function frameDoc(style, inner, cls) {
|
|
167
185
|
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><link rel="stylesheet" href="' + esc(href('tokens.css')) + '"><link rel="stylesheet" href="' + esc(href('premium-report-frame.css')) + '">'
|
|
168
|
-
+ '<style>' + String(style || '').replace(/<\/style/gi, '<\\/style') + '</style></head><body class="pmos-frame ' + cls + '">' + String(inner || '') + '</body></html>';
|
|
186
|
+
+ '<style>' + faceCss(String(style || '')) + String(style || '').replace(/<\/style/gi, '<\\/style') + '</style></head><body class="pmos-frame ' + cls + '">' + String(inner || '') + '</body></html>';
|
|
169
187
|
}
|
|
170
|
-
|
|
188
|
+
/* A slide with CSS-only controls (data-pmos-live: the library's timeline span and filter, the ticked actions) takes the pointer and the keyboard in
|
|
189
|
+
* the big view and in Present; a thumbnail never does. Nothing in the frame runs: its sandbox stays empty. */
|
|
190
|
+
const frame = (style, inner, cls, title, live) => `<iframe sandbox=""${live ? '' : ' tabindex="-1"'} title="${esc(title)}" srcdoc="${esc(frameDoc(style, inner, cls))}"></iframe>`;
|
|
191
|
+
const LIVE_RE = /<section\b[^>]*\bdata-pmos-live=/;
|
|
171
192
|
|
|
172
193
|
/* ── what is shown ── */
|
|
173
194
|
function slides() {
|
|
@@ -210,7 +231,7 @@ export function headFigure(w) {
|
|
|
210
231
|
}
|
|
211
232
|
/** One slide: a designed frame, the title slide or a section's words. */
|
|
212
233
|
export function slideCard(s, mini) {
|
|
213
|
-
if (s.k === 'frame') return `<span class="vz-fr">${frame(s.css, s.html, 'pmos-one', s.h)}</span>`;
|
|
234
|
+
if (s.k === 'frame') { const live = !mini && LIVE_RE.test(s.html); return `<span class="vz-fr${live ? ' vz-live' : ''}">${frame(s.css, s.html, 'pmos-one', s.h, live)}</span>`; }
|
|
214
235
|
if (s.k === 'title') return `<span class="vz-words vz-title"><span class="vz-eye">${esc(s.eye || (R && R.audience) || 'Report')}</span><h4 data-ed="title">${esc(s.title || s.h)}${s.acc ? ` <em class="vz-acc">${esc(s.acc)}</em>` : ''}</h4>${mini ? '' : `${s.sub ? `<b class="vz-tsub">${esc(s.sub)}</b>` : ''}<p>${esc(s.t || '')}</p>`}</span>${!mini && s.fig ? `<span class="vz-tfig"><i class="vz-tdot" aria-hidden="true"></i><span><b>${esc(s.fig.v)}</b>${s.fig.cap ? `<small>${esc(s.fig.cap)}</small>` : ''}</span></span>` : ''}`;
|
|
215
236
|
return `<span class="vz-words"><h4>${esc(s.h)}</h4>${mini ? '' : s.html}</span>`;
|
|
216
237
|
}
|
|
@@ -644,6 +644,21 @@ async function load(me, quiet) {
|
|
|
644
644
|
edits: pts && pts.ok ? pts.edits || {} : (me.D && me.D.edits) || {}, lessons: pts && pts.ok ? pts.lessons || [] : (me.D && me.D.lessons) || [],
|
|
645
645
|
terms: pts && pts.ok ? pts.terms || [] : (me.D && me.D.terms) || [] };
|
|
646
646
|
draw(me);
|
|
647
|
+
if (rp && rp.ok && rp.jev_running) jevWatch(me, n.id);
|
|
648
|
+
}
|
|
649
|
+
/** Jev is cutting a past meeting's captures to the slide (MpJ): read the replay again every few seconds until it ends, so the
|
|
650
|
+
* crops show on this open, not the next. At most JEV_TRIES reads; the page draws the shots it has meanwhile. */
|
|
651
|
+
const JEV_EVERY = 4000, JEV_TRIES = 45;
|
|
652
|
+
function jevWatch(me, id, tries = 0) {
|
|
653
|
+
clearTimeout(me.jevT);
|
|
654
|
+
me.jevT = setTimeout(async () => {
|
|
655
|
+
if (MT !== me || !me.D || !me.n || me.n.id !== id) return;
|
|
656
|
+
const rp = await pmGet('/api/meeting-replay?' + q({ note: id }));
|
|
657
|
+
if (MT !== me || !me.D || !(rp && rp.ok)) return;
|
|
658
|
+
const rep = replayModel(rp); rep.raw = rp.keys || [];
|
|
659
|
+
me.D.rep = rep; draw(me);
|
|
660
|
+
if (rp.jev_running && tries + 1 < JEV_TRIES) jevWatch(me, id, tries + 1);
|
|
661
|
+
}, JEV_EVERY);
|
|
647
662
|
}
|
|
648
663
|
async function keyOf(text) {
|
|
649
664
|
try {
|
|
@@ -1228,8 +1228,8 @@ function openSettings(ctx, n) {
|
|
|
1228
1228
|
const csw = field({ label: 'Check notes against this repository with Jev', kind: 'check', value: !!(j && j.check), onCommit: (v) => setSwitch(ctx, { check: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-check': '' } });
|
|
1229
1229
|
parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'Jev'), jsw, csw,
|
|
1230
1230
|
el('p', { class: 'nt-hint', 'data-nt-gates': '' }, j && j.paused ? j.paused : j && j.sentence ? j.sentence + ' Lines get the local label and the local repository match.'
|
|
1231
|
-
: j && j.check_open ? 'Each line and, for a captured line, one repository passage go to TypeSafe. Audio, names and private notes never do.'
|
|
1232
|
-
: j && j.label_open ? 'Each line goes to TypeSafe for its label. Audio, names and private notes never do.' : 'Off. Lines get the local label and the local repository match.')));
|
|
1231
|
+
: j && j.check_open ? 'Each line and, for a captured line, one repository passage go to TypeSafe, and each screen capture so it can be cut to the slide. Audio, names and private notes never do.'
|
|
1232
|
+
: j && j.label_open ? 'Each line goes to TypeSafe for its label, and each screen capture so it can be cut to the slide. Audio, names and private notes never do.' : 'Off. Lines get the local label and the local repository match.')));
|
|
1233
1233
|
}
|
|
1234
1234
|
parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'This note'),
|
|
1235
1235
|
n.private ? el('p', { class: 'nt-hint' }, 'Private: it is not recorded and nothing from it goes to Jev or to any agent.') : null,
|
|
@@ -36,9 +36,10 @@ Procedure:
|
|
|
36
36
|
- Shapes other reports took, as examples and never a menu: a decision (the lean · the options and what each costs · what could reverse it · your call); a watch (today against yesterday and the week · the drop that moved most · why); a stakeholder update (are the dates on track · what each team needs and by when · open decisions · what could stop it); a review (the summary for decision · the outcomes by segment · the challenges and answers · method); did-it-work (what was promised · what we got · the verdict).
|
|
37
37
|
6. Write `<slot>.report.json` (in a scratch file, then `pmos report write <id> --from <file>`):
|
|
38
38
|
`{"title":"…{{r:…}}…","answer":{"text":"…","supports":["Label: …{{r:…}}…"]},"sections":[{"id":"…","title":"…","blocks":[{"kind":"p","text":"…"},{"kind":"list","items":["…"]},{"kind":"table","columns":["…"],"rows":[["…"]]},{"kind":"bars","title":"…","rows":[["label","{{r:id}}"]]},{"kind":"options","options":[{"name":"…","text":"…"}]},{"kind":"quote","text":"…"}]}],"ask":{"text":"…","who":"…","by":"YYYY-MM-DD","options":["…"]},"beyond":{"why":"…","so_what":"…","now_what":"…","since":"…","would_change":"…","unknown":["…"],"else":["…"],"next_question":"…"},"questions":[{"question":"<a question of the brief>","answered_in":"<section id or answer>","gap":"<why it cannot be answered>"}],"outline_note":"…"}`.
|
|
39
|
+
- A section can take a slide layout from the report library instead of blocks: `{"title":"…","layout":"<name>","data":{…},"insight":{"head":"…","text":"…","points":[{"tone":"ok|watch|late|bad|info|none","text":"…"}],"note":"…"},"eyebrow":"…","sub":"…"}`. The names: overview, tiles, ledger, timeline, compare, lists, risks, ranked, decisions, quotes, actions, agenda, heatmap, network, schedule, donut, bubbles, people, statement, sources, minutes, focus, next. Run `pmos report library` to see each one with its data; `pmos report write` names a field that does not fit. Pick the layout that fits the finding, never to fill a slide. Every text in `data` and `insight` is checked like any other: a figure is a reading token. A bar's `share`, a `target` mark and a bubble's `x`, `y` and `size` are numbers from 0 to 1: take them from the readings. The insight is optional; leave it out when the slide says it already.
|
|
39
40
|
- `ask` is null when the readers have nothing to decide. When it is a call with no better option on the evidence, give the options and say "your call".
|
|
40
41
|
- BEYOND THE ASK. Answer the readers' next three questions before they ask: why (the cause, from the evidence), so what (the effect on the target and its date), now what (the recommendation, who, by), since the last issue (what moved, and each carried ask), what would change it (the one or two readings that would flip the recommendation), what we don't know (the gaps, counted, never padded), what else is here (at most two findings from a named source the readers did not ask about, when their question depends on them) and the next question (the split they will ask for next). Each part cites a reading. Leave a part out when no reading backs it, and leave out a part the brief's rules turn off.
|
|
41
42
|
- `questions`: map each question of the brief after the first to the section that answers it, or give the gap.
|
|
42
|
-
7. Read what `pmos report write` prints. A refusal names the check: fix the report and run it again. When it passes, it writes `<slot>.md` and `<slot>.checks.json`; the deck, the PDF and the PowerPoint are drawn from the same model, in the report's
|
|
43
|
+
7. Read what `pmos report write` prints. A refusal names the check: fix the report and run it again. When it passes, it writes `<slot>.md` and `<slot>.checks.json`; the deck, the PDF and the PowerPoint are drawn from the same model, in the report library's frame.
|
|
43
44
|
8. Learn. When the PM's change in the chat is about the words (shorter, plainer, another tone) and it would apply to every issue, keep it: `pmos report rule <id> add wording "<the rule, one line>"`. When the PM removes a part beyond the ask, `pmos report rule <id> add off <part>`. Then change report.json and run `pmos report write` again. A change to a number is a change to a reading: read the source again and file it.
|
|
44
45
|
9. Finish by saying in one line where the draft is and how many readings, findings and gaps it has. Do not commit; the PM's Sync carries the files.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Batch 27 item 14, the report library: the B2 frame and its 23 layouts, the templates, the engine deck in that frame, the document and PowerPoint lines, and pmos report library (lib/report-slides.js, lib/report-library.js). Pure.",
|
|
3
|
+
"modules": [
|
|
4
|
+
"report-slides.test.js"
|
|
5
|
+
],
|
|
6
|
+
"seconds": {
|
|
7
|
+
"report-slides.test.js": 2
|
|
8
|
+
}
|
|
9
|
+
}
|