create-pmos 0.529.0 → 0.530.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 +14 -22
- 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 +13 -0
- package/workspace/guide/notes.md +2 -2
- 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/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/notes-screen-capture.js +5 -4
- package/workspace/public/pm/premium-vw-mtg.js +15 -0
- package/workspace/public/pm/view-notes.js +2 -2
- package/workspace/test-files.json +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.530.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
"audit"
|
|
33
33
|
],
|
|
34
34
|
"pmosChanges": [
|
|
35
|
+
{
|
|
36
|
+
"v": "0.530.0",
|
|
37
|
+
"at": "2026-11-04T14:38:26.528Z",
|
|
38
|
+
"f": 4,
|
|
39
|
+
"prs": [
|
|
40
|
+
{
|
|
41
|
+
"n": 1246,
|
|
42
|
+
"t": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
43
|
+
"s": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
44
|
+
"k": "new"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
35
48
|
{
|
|
36
49
|
"v": "0.529.0",
|
|
37
50
|
"at": "2026-11-04T13:14:44.790Z",
|
|
@@ -420,27 +433,6 @@
|
|
|
420
433
|
"d": "Updates listed pull request titles and numbers, grouped by kind, which was hard to skim. Updates opens with a short summary of the week, then the newest version, then each change as a plain story, with a dot for New and Fixed."
|
|
421
434
|
}
|
|
422
435
|
]
|
|
423
|
-
},
|
|
424
|
-
{
|
|
425
|
-
"v": "0.510.0",
|
|
426
|
-
"at": "2026-11-03T14:33:47.373Z",
|
|
427
|
-
"f": 4,
|
|
428
|
-
"prs": [
|
|
429
|
-
{
|
|
430
|
-
"n": 1197,
|
|
431
|
-
"t": "Clean Updates lines: no, one PR number, no repeated title, right group",
|
|
432
|
-
"s": "Clean Updates lines: no, one PR number, no repeated title, right group",
|
|
433
|
-
"k": "fix",
|
|
434
|
-
"h": "Clearer update notes",
|
|
435
|
-
"d": "Update notes could repeat a line, show odd tags, or put every change under Improved. Now each change shows once, in the right group."
|
|
436
|
-
},
|
|
437
|
-
{
|
|
438
|
-
"n": 1199,
|
|
439
|
-
"t": "A Board product head's name opens its page in the Viewer",
|
|
440
|
-
"s": "A Board product head's name opens its page in the Viewer",
|
|
441
|
-
"k": "new"
|
|
442
|
-
}
|
|
443
|
-
]
|
|
444
436
|
}
|
|
445
437
|
]
|
|
446
438
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T14:38:23Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T14:38:23Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-11-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-04T14:38:23Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"v": "0.530.0",
|
|
4
|
+
"at": "2026-11-04T14:38:26.528Z",
|
|
5
|
+
"f": 4,
|
|
6
|
+
"prs": [
|
|
7
|
+
{
|
|
8
|
+
"n": 1246,
|
|
9
|
+
"t": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
10
|
+
"s": "Send meeting captures to Jev and keep only the slide (MpJ)",
|
|
11
|
+
"k": "new"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"v": "0.529.0",
|
|
4
17
|
"at": "2026-11-04T13:14:44.790Z",
|
package/workspace/guide/notes.md
CHANGED
|
@@ -190,7 +190,7 @@ Without Jev, the workspace labels each line with its own rules and finds the clo
|
|
|
190
190
|
1. Save a TypeSafe key and switch on validation for the repository (see [Validate with TypeSafe](validate-with-typesafe.md)).
|
|
191
191
|
2. Open the settings and switch on **Label lines with Jev**. Switch on **Check notes against this repository with Jev** for the checks too. Both are off until you switch them on, for each repository.
|
|
192
192
|
|
|
193
|
-
With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Audio, speaker names and private notes never go. A line that looks like a secret is not sent.
|
|
193
|
+
With the switches on, each line goes to TypeSafe for its label. For a caught line, that line and one passage of the closest file go too. Each screen capture goes too, so Jev can find the slide or document in it (see the meeting player below). Audio, speaker names and private notes never go. A line that looks like a secret is not sent, and a capture set aside as another app is not sent.
|
|
194
194
|
|
|
195
195
|
## Correct and tag a line
|
|
196
196
|
|
|
@@ -253,7 +253,7 @@ A meeting with a recording opens wide, in two columns. Press **Back to the side*
|
|
|
253
253
|
|
|
254
254
|
The captures row is hidden until you press the captures icon. It shows the five captures near the playing time, and **+N more** shows the rest.
|
|
255
255
|
|
|
256
|
-
When Jev is on for the repository (Settings › **Label lines with Jev**) and Jev gave a box for a capture, the player and the captures row show only the slide or document in that box. The meeting window and the faces are cut off, and a capture that shows only faces is not in the captures row. The icon at the top right of the picture shows the whole frame. When Jev is off, or gave no box, you see the whole frame.
|
|
256
|
+
When Jev is on for the repository (Settings › **Label lines with Jev**) and Jev gave a box for a capture, the player and the captures row show only the slide or document in that box. The meeting window and the faces are cut off, and a capture that shows only faces is not in the captures row. The icon at the top right of the picture shows the whole frame. When Jev is off, or gave no box, you see the whole frame. Captures kept before you switched Jev on are sent the next time you open the meeting. Jev reads pictures up to 1568 px a side. On a Mac a larger capture from an earlier meeting is made smaller first; on another computer it shows the whole frame.
|
|
257
257
|
|
|
258
258
|
### Fix a word that was heard wrong
|
|
259
259
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* gatesFor(key, flags) · checkGatesFor(key, flags) · senderFor(key, flags, askOpts) · infoFor(present, flags)
|
|
5
5
|
* label({ ask, key, askOpts, text, pause }) → { cat, p } | { cat: null, reason }
|
|
6
6
|
* compare({ ask, key, askOpts, text, passage, pause }) → { verdict: 'agrees'|'differs'|'related', words } | { verdict: null, reason }
|
|
7
|
+
* screen({ askImage, key, askOpts, image, root }) → { kind: 'slide'|'document'|'faces'|'unsure', box?, p } | { kind: null, reason }
|
|
7
8
|
* getSwitch(runtimeDir, root) · setSwitch(runtimeDir, root, { jev, check })
|
|
8
9
|
*
|
|
9
10
|
* WHAT MAY LEAVE THE MACHINE. For a label: one cleaned transcript line and one Choice question. For a check: the line and
|
|
@@ -13,6 +14,9 @@
|
|
|
13
14
|
* per-user runtime directory. Never sent: audio, a speaker's name, a line or passage the secret scan matches, a private
|
|
14
15
|
* note (the caller refuses it before this module), anything while paused.
|
|
15
16
|
*
|
|
17
|
+
* For a screen capture (MpJ): the capture's picture and nothing else, with one Choice and one Box question, under the same three
|
|
18
|
+
* gates. The caller sends only shots the note lists: never a shot set aside as another app, never one from a private note.
|
|
19
|
+
*
|
|
16
20
|
* WHAT A RESULT IS. Advisory (D40), shown in words. A label under UNSURE_BELOW keeps the local label. A check says
|
|
17
21
|
* "agrees" or "differs" only above SURE; otherwise "related", which is what the local match alone also says.
|
|
18
22
|
*
|
|
@@ -68,8 +72,8 @@ const checkGatesFor = (key, f) => checkGates({ key: typesafe.validKey(key), cons
|
|
|
68
72
|
/** { ask, check, askOpts }: `ask` sends labels and `check` is true when checks may also be sent. Both shut when a gate is. */
|
|
69
73
|
function senderFor(key, flags, askOpts = {}) {
|
|
70
74
|
const g = gatesFor(key, flags);
|
|
71
|
-
if (!g.open) return { ask: null, check: false, gates: g, askOpts: {} };
|
|
72
|
-
return { ask: (a) => typesafe.ask(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
75
|
+
if (!g.open) return { ask: null, askImage: null, check: false, gates: g, askOpts: {} };
|
|
76
|
+
return { ask: (a) => typesafe.ask(a), askImage: (a) => typesafe.askImage(a), check: checkGatesFor(key, flags).open, gates: g, askOpts: { ...NOTES_ASK, ...askOpts } };
|
|
73
77
|
}
|
|
74
78
|
/** What the view needs to explain the two switches. Booleans and sentences only. */
|
|
75
79
|
function infoFor(present, flags) {
|
|
@@ -86,6 +90,11 @@ function getSwitch(runtimeDir, root) {
|
|
|
86
90
|
const r = s && s.repos && root ? s.repos[root] : null;
|
|
87
91
|
return { jev: !!(r && r.jev === true), check: !!(r && r.check === true) };
|
|
88
92
|
}
|
|
93
|
+
/** The repositories whose switch "notes may use Jev" is on, sorted: a note bound to no repository looks at its shots under the first of these that also has consent (routes-notes.js lookAtShots). */
|
|
94
|
+
function onRoots(runtimeDir) {
|
|
95
|
+
const s = readJson(switchFile(runtimeDir), {});
|
|
96
|
+
return Object.entries((s && s.repos) || {}).filter(([, v]) => v && v.jev === true).map(([r]) => r).sort();
|
|
97
|
+
}
|
|
89
98
|
function setSwitch(runtimeDir, root, { jev, check } = {}) {
|
|
90
99
|
const s = readJson(switchFile(runtimeDir), {});
|
|
91
100
|
const prev = getSwitch(runtimeDir, root);
|
|
@@ -156,7 +165,53 @@ async function compare({ ask, key, askOpts = {}, text, passage, root = '' }) {
|
|
|
156
165
|
if (same != null && same >= SURE) return { verdict: 'agrees', words: wordsFor(same), model: res.model || null };
|
|
157
166
|
return { verdict: 'related', words: null, model: res.model || null };
|
|
158
167
|
}
|
|
168
|
+
/* ── screen captures (MpJ) ──────────────────────────────────────────────────────────────────────── */
|
|
169
|
+
const SHOT_KINDS = {
|
|
170
|
+
slide: 'A shared slide or presentation is on the screen.',
|
|
171
|
+
document: 'A shared document, web page, spreadsheet, board or app screen is on the screen.',
|
|
172
|
+
faces: 'Only the people in the meeting: their video tiles, pictures or names. Nothing is shared.',
|
|
173
|
+
other: 'Something else, such as an empty, loading or desktop screen.',
|
|
174
|
+
};
|
|
175
|
+
const SHOT_KIND_Q = 'The `capture` is a screenshot taken during a video meeting. What does it mainly show?';
|
|
176
|
+
const SHOT_BOX_Q = 'the shared slide or document in `capture`, without the meeting app\'s toolbars and without the video tiles of the people';
|
|
177
|
+
const SHOT_SHOWN = ['slide', 'document'];
|
|
178
|
+
const SHOT_ASK = { timeoutMs: 20000, retries: 1, backoffMs: 600 };
|
|
179
|
+
const r4 = (n) => Math.round(n * 10000) / 10000;
|
|
180
|
+
/** Jev's { top, bottom, left, right } (fractions of the picture) as [x, y, w, h], or null when it is not a useful crop. */
|
|
181
|
+
function boxOf(ans) {
|
|
182
|
+
const b = ans && ans.box;
|
|
183
|
+
if (!b || typeof b !== 'object') return null;
|
|
184
|
+
const [t, bo, l, ri] = [b.top, b.bottom, b.left, b.right].map(Number);
|
|
185
|
+
if (![t, bo, l, ri].every((v) => Number.isFinite(v) && v >= -0.01 && v <= 1.01)) return null;
|
|
186
|
+
const x = Math.max(0, l), y = Math.max(0, t), w = Math.min(1, ri) - x, h = Math.min(1, bo) - y;
|
|
187
|
+
if (!(w > 0.1) || !(h > 0.1)) return null; // a sliver is not a slide
|
|
188
|
+
if (w * h > 0.95) return null; // the whole frame already: nothing to crop
|
|
189
|
+
return [r4(x), r4(y), r4(w), r4(h)];
|
|
190
|
+
}
|
|
191
|
+
/** What one capture shows, from Jev. Faces are named only when Jev is sure; a crop only for a slide or a document. */
|
|
192
|
+
async function screen({ askImage, key, askOpts = {}, image, root = '' }) {
|
|
193
|
+
const p = pauseOf(root);
|
|
194
|
+
if (!askImage) return { kind: null, reason: 'off' };
|
|
195
|
+
if (p.paused) return { kind: null, reason: 'paused', sentence: p.paused };
|
|
196
|
+
const questions = { kind: { type: 'choice', instructions: SHOT_KIND_Q, criteria: SHOT_KINDS }, area: { type: 'box', instructions: SHOT_BOX_Q } };
|
|
197
|
+
const res = await askImage({ key, image, name: 'capture', questions, ...SHOT_ASK, ...askOpts });
|
|
198
|
+
if (!res.requested) return { kind: null, reason: 'refused', sentence: res.reason || null };
|
|
199
|
+
note(p, res);
|
|
200
|
+
if (!res.ok) return { kind: null, reason: 'failed', sentence: res.reason || null };
|
|
201
|
+
const probs = choiceProbs(res.answers && res.answers.kind);
|
|
202
|
+
const top = Object.entries(probs).filter(([k]) => SHOT_KINDS[k]).sort((a, b) => b[1] - a[1])[0];
|
|
203
|
+
const model = res.model || null;
|
|
204
|
+
if (!top) return { kind: null, reason: 'unreadable' };
|
|
205
|
+
if (top[0] === 'faces' && top[1] >= SURE) return { kind: 'faces', p: r4(top[1]), model };
|
|
206
|
+
if (SHOT_SHOWN.includes(top[0]) && top[1] >= UNSURE_BELOW) {
|
|
207
|
+
const box = boxOf(res.answers && res.answers.area);
|
|
208
|
+
return box ? { kind: top[0], box, p: r4(top[1]), model } : { kind: top[0], p: r4(top[1]), model };
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'unsure', p: r4(top[1]), model };
|
|
211
|
+
}
|
|
212
|
+
|
|
159
213
|
function pauseStatus(root) { const p = pauses.get(root); return p && p.paused ? p.paused : null; }
|
|
160
214
|
|
|
161
|
-
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, label, compare,
|
|
215
|
+
module.exports = { gates, checkGates, gatesFor, checkGatesFor, gateSentence, senderFor, infoFor, getSwitch, setSwitch, onRoots, label, compare, screen, boxOf,
|
|
216
|
+
pictures: { imageSize: typesafe.imageSize, imageRefusal: typesafe.imageRefusal, IMAGE_SIDE: typesafe.IMAGE_SIDE }, choiceProbs, pauseStatus, SHOT_KINDS,
|
|
162
217
|
CATS, UNSURE_BELOW, SURE, REJECTED_LIMIT, UNREACHABLE_LIMIT };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jev-screen.js — Jev looks at a note's screen captures and keeps only the useful area (batch 27 item 12, board MpJ).
|
|
3
|
+
*
|
|
4
|
+
* run({ runtimeDir, id, g, screen?, jev?, fit? }) → Promise<{ asked, kept, skipped, stopped? }>
|
|
5
|
+
* busy(id) → boolean a run for the note is in progress
|
|
6
|
+
* fitFor(buf, { platform?, sips? }) → Promise<{ buf } | { reason }> a picture in Jev's size range, or why there is none
|
|
7
|
+
*
|
|
8
|
+
* WHAT IT DOES. For each shot the note lists that has no `jev` yet, in meeting-time order, it sends the picture to Jev
|
|
9
|
+
* (lib/jev-notes.js screen, through lib/typesafe.js askImage) and keeps the answer on the shot's row in the index:
|
|
10
|
+
* { kind: 'slide'|'document', box: [x, y, w, h] } the page shows only the box, with the whole-frame icon (premium-mtg-replay.js)
|
|
11
|
+
* { kind: 'faces' } the page leaves the shot out of the captures row
|
|
12
|
+
* { kind: 'slide'|'document'|'unsure' } (no box) the page shows the whole frame, as without Jev
|
|
13
|
+
* { kind: 'skip', why } not sent (out of Jev's size range and no way to resize here); whole frame
|
|
14
|
+
* x, y, w, h are fractions of the picture, so they hold for the kept file at any size.
|
|
15
|
+
*
|
|
16
|
+
* GATES. `g` is routes-notes.js gatesFor: no `askImage` (no key, no consent, the switch "notes may use Jev" off) means no
|
|
17
|
+
* request. Private notes and shots set aside as another app are never sent: the caller passes only an open note, and
|
|
18
|
+
* notes-screen.js list leaves set-aside shots out. It waits for the text reads first, because a text read is what sets a
|
|
19
|
+
* shot aside. A failed ask leaves the row as it was, so the next run asks again; a pause (jev-notes.js) stops the run.
|
|
20
|
+
*
|
|
21
|
+
* SIZE. Jev does not resize: each side 280 to 1568 px. The page captures at most 1568 px a side (notes-screen-capture.js);
|
|
22
|
+
* older shots were 1600 px wide. On a Mac, `sips` (part of macOS) makes a smaller copy in the temp folder, which is sent and
|
|
23
|
+
* deleted. Elsewhere such a shot is marked skip and shows whole.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { execFile } = require('child_process');
|
|
30
|
+
const jevNotes = require('./jev-notes');
|
|
31
|
+
// the size rules come through the notes bridge: only the bridges require lib/typesafe.js (no-network.test.js)
|
|
32
|
+
const typesafe = jevNotes.pictures;
|
|
33
|
+
const notesScreen = require('./notes-screen');
|
|
34
|
+
|
|
35
|
+
const SIPS = '/usr/bin/sips';
|
|
36
|
+
const MAX_PER_RUN = 400;
|
|
37
|
+
const running = new Map(); // note id → the run in progress, so two triggers never ask twice for one shot
|
|
38
|
+
|
|
39
|
+
/** A copy of the picture in Jev's size range, made by sips on a Mac. */
|
|
40
|
+
function fitFor(buf, { platform = process.platform, sips = SIPS } = {}) {
|
|
41
|
+
const size = typesafe.imageSize(buf);
|
|
42
|
+
if (!size) return Promise.resolve({ reason: 'not a PNG or a JPEG' });
|
|
43
|
+
if (!typesafe.imageRefusal(size, buf.length)) return Promise.resolve({ buf });
|
|
44
|
+
const { min, max } = typesafe.IMAGE_SIDE;
|
|
45
|
+
if (size.w < min || size.h < min) return Promise.resolve({ reason: `${size.w} × ${size.h} px is smaller than Jev reads` });
|
|
46
|
+
let can = platform === 'darwin';
|
|
47
|
+
try { fs.accessSync(sips, fs.constants.X_OK); } catch { can = false; }
|
|
48
|
+
if (!can) return Promise.resolve({ reason: `${size.w} × ${size.h} px is larger than Jev reads, and this machine cannot make it smaller` });
|
|
49
|
+
const tmp = path.join(os.tmpdir(), 'pmos-jev-' + crypto.randomBytes(6).toString('hex'));
|
|
50
|
+
const src = tmp + '.' + size.format, out = tmp + '-fit.' + size.format;
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
try { fs.writeFileSync(src, buf, { mode: 0o600 }); } catch { resolve({ reason: 'the temp folder cannot be written' }); return; }
|
|
53
|
+
execFile(sips, ['-Z', String(max), src, '--out', out], { timeout: 20000, windowsHide: true }, (err) => {
|
|
54
|
+
let fit = null;
|
|
55
|
+
try { if (!err) fit = fs.readFileSync(out); } catch { fit = null; }
|
|
56
|
+
for (const f of [src, out]) { try { fs.unlinkSync(f); } catch { /* gone */ } }
|
|
57
|
+
const s2 = fit ? typesafe.imageSize(fit) : null;
|
|
58
|
+
resolve(fit && s2 && !typesafe.imageRefusal(s2, fit.length) ? { buf: fit } : { reason: 'the smaller copy could not be made' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runNow({ runtimeDir, id, g, screen = notesScreen, jev = jevNotes, fit = fitFor }) {
|
|
64
|
+
const out = { asked: 0, kept: 0, skipped: 0 };
|
|
65
|
+
if (!g || !g.askImage) return { ...out, stopped: 'off' };
|
|
66
|
+
await screen.ocrIdle();
|
|
67
|
+
const todo = screen.list(runtimeDir, id).filter((f) => !f.jev).slice(0, MAX_PER_RUN);
|
|
68
|
+
for (const f of todo) {
|
|
69
|
+
const buf = screen.readShot(runtimeDir, id, f);
|
|
70
|
+
if (!buf) continue;
|
|
71
|
+
const ok = await fit(buf);
|
|
72
|
+
if (!ok.buf) { screen.setJev(runtimeDir, id, f, { kind: 'skip', why: ok.reason }); out.skipped++; continue; }
|
|
73
|
+
out.asked++;
|
|
74
|
+
const r = await jev.screen({ askImage: g.askImage, key: g.key, askOpts: g.askOpts, image: ok.buf, root: g.root });
|
|
75
|
+
if (r.reason === 'paused') return { ...out, stopped: 'paused' };
|
|
76
|
+
if (r.reason === 'refused') { screen.setJev(runtimeDir, id, f, { kind: 'skip', why: r.sentence || 'refused' }); out.skipped++; continue; }
|
|
77
|
+
if (!r.kind) continue; // failed or unreadable: asked again next run
|
|
78
|
+
const row = { kind: r.kind, at: Date.now() };
|
|
79
|
+
if (r.box) row.box = r.box;
|
|
80
|
+
if (r.p != null) row.p = r.p;
|
|
81
|
+
if (r.model) row.model = r.model;
|
|
82
|
+
if (screen.setJev(runtimeDir, id, f, row)) out.kept++;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Ask Jev about the note's shots that have no answer yet. One run per note at a time: a call during a run makes the run
|
|
88
|
+
* go once more at its end, so a shot kept meanwhile is asked about too. */
|
|
89
|
+
function run(a) {
|
|
90
|
+
const cur = running.get(a.id);
|
|
91
|
+
if (cur) { cur.again = a; return cur.p; }
|
|
92
|
+
const st = { again: null, p: null };
|
|
93
|
+
st.p = (async () => {
|
|
94
|
+
let last;
|
|
95
|
+
let next = a;
|
|
96
|
+
while (next) {
|
|
97
|
+
st.again = null;
|
|
98
|
+
last = await runNow(next).catch(() => ({ asked: 0, kept: 0, skipped: 0, stopped: 'error' }));
|
|
99
|
+
next = last.stopped ? null : st.again;
|
|
100
|
+
}
|
|
101
|
+
return last;
|
|
102
|
+
})().finally(() => running.delete(a.id));
|
|
103
|
+
running.set(a.id, st);
|
|
104
|
+
return st.p;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Whether Jev is looking at this note's shots right now (the replay tells the page to read again when it ends). */
|
|
108
|
+
const busy = (id) => running.has(id);
|
|
109
|
+
|
|
110
|
+
module.exports = { run, busy, fitFor, MAX_PER_RUN };
|
|
@@ -107,11 +107,14 @@ function timeOfKey(item, lines) {
|
|
|
107
107
|
|
|
108
108
|
/** What Jev for images said of a shot (batch 27 item 12, MpJ), when a shot's index row holds it: the kind ('content', a slide or a
|
|
109
109
|
* document; 'faces', only people) and the box around the useful area, as fractions of the frame [x, y, w, h]. Anything else is dropped. */
|
|
110
|
+
/* lib/jev-screen.js keeps Jev's kind as slide or document; both are content here */
|
|
111
|
+
const CONTENT = ['content', 'slide', 'document'];
|
|
110
112
|
function jevOf(j) {
|
|
111
|
-
if (!j || typeof j !== 'object' || !
|
|
113
|
+
if (!j || typeof j !== 'object' || !(CONTENT.includes(j.kind) || j.kind === 'faces')) return null;
|
|
112
114
|
const b = Array.isArray(j.box) && j.box.length === 4 && j.box.every((x) => Number.isFinite(x) && x >= 0 && x <= 1) ? j.box.map((x) => Math.round(x * 1000) / 1000) : null;
|
|
113
115
|
const box = b && b[2] >= 0.05 && b[3] >= 0.05 && b[0] + b[2] <= 1.001 && b[1] + b[3] <= 1.001 ? b : null;
|
|
114
|
-
|
|
116
|
+
const kind = j.kind === 'faces' ? 'faces' : 'content';
|
|
117
|
+
return { kind, ...(box && kind === 'content' ? { box } : {}) };
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/** Join the three records. */
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* list(runtimeDir, id) → [{ t, file, w, h, bytes, why?, text? }] sorted by t; empty when the note has none
|
|
6
6
|
* serve(req, res, runtimeDir, id, t) the shot at t as its image, cached for good; 404 JSON when there is none
|
|
7
7
|
* remove(runtimeDir, id) → boolean delete the note's screenshots
|
|
8
|
+
* readShot(runtimeDir, id, frame) → Buffer | null · setJev(runtimeDir, id, frame, jev) → boolean what Jev saw in a shot (lib/jev-screen.js)
|
|
8
9
|
* getOn(runtimeDir) · setOn(runtimeDir, bool) "Capture the shared screen", on by default, for this machine
|
|
9
10
|
* ocrDetect({ env? }) → { bin } | null the tesseract the PM installed
|
|
10
11
|
* ocrIdle() → Promise resolves when the text queue is empty (tests)
|
|
@@ -294,7 +295,20 @@ function readText(bin, runtimeDir, id, frame) {
|
|
|
294
295
|
});
|
|
295
296
|
});
|
|
296
297
|
}
|
|
298
|
+
/** The bytes of one listed shot, or null. */
|
|
299
|
+
function readShot(runtimeDir, id, frame) {
|
|
300
|
+
if (!ID_RE.test(String(id || '')) || !frame || !FILE_RE.test(String(frame.file || ''))) return null;
|
|
301
|
+
try { return fs.readFileSync(path.join(dirOf(runtimeDir, id), frame.file)); } catch { return null; }
|
|
302
|
+
}
|
|
303
|
+
/** Keep what Jev saw in a shot ({ kind, box?, … }) on its row, only if the same shot is still there and not set aside. */
|
|
304
|
+
function setJev(runtimeDir, id, frame, jev) {
|
|
305
|
+
const rows = readIndex(runtimeDir, id);
|
|
306
|
+
const row = rows.find((f) => f.t === frame.t && f.file === frame.file && f.bytes === frame.bytes && !f.aside);
|
|
307
|
+
if (!row) return false;
|
|
308
|
+
row.jev = jev;
|
|
309
|
+
try { writeIndex(runtimeDir, id, rows); return true; } catch { return false; }
|
|
310
|
+
}
|
|
297
311
|
/** Resolves when every text read queued so far has ended. */
|
|
298
312
|
function ocrIdle() { return queue.then(() => (waiting > 0 ? ocrIdle() : undefined)); }
|
|
299
313
|
|
|
300
|
-
module.exports = { add, list, listAside, asideCount, keep, discard, discardAll, sweepAside, classifyShot, setHints, ASIDE_DAYS, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
314
|
+
module.exports = { add, list, readShot, setJev, listAside, asideCount, keep, discard, discardAll, sweepAside, classifyShot, setHints, ASIDE_DAYS, serve, remove, getOn, setOn, ocrDetect, ocrIdle, kindOf, MAX_SHOT_BYTES, MAX_SHOTS, MAX_BYTES };
|
|
@@ -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.530.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.530.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -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));
|
|
@@ -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,
|