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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.529.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
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@96caad55
1
+ kernel-source: pmos@5a2765e8
2
2
  assembled: 2026-11-04
3
3
  mode: vendor
@@ -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-04T13:14:41Z"}
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-04T13:14:41Z"}
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-04T13:14:41Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-04T14:38:23Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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",
@@ -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. Note: PMOS does not yet send the captures to Jev, so for now every capture shows 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, choiceProbs, pauseStatus,
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' || !['content', 'faces'].includes(j.kind)) return null;
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
- return { kind: j.kind, ...(box && j.kind === 'content' ? { box } : {}) };
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. Read only: it writes nothing, starts no run and reaches nothing outside the machine. A private note is a
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
- return respond(ctx, res, await read(Object.fromEntries(url.searchParams), { runtimeDir: ctx.runtimeDir, json: ctx.json }));
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
- 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 }));
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
- module.exports = { ask, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
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.529.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.529.0",
9
+ "version": "0.530.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.529.0",
3
+ "version": "0.530.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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 MAX_W px wide. It is posted with the meeting time, the note and the capture token, so
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 MAX_W = 1600;
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 w = Math.min(MAX_W, video.videoWidth);
91
- const h = Math.round(video.videoHeight * (w / video.videoWidth));
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,
@@ -76,6 +76,7 @@
76
76
  "jev-categorize.test.js",
77
77
  "jev-status.test.js",
78
78
  "jev-route.test.js",
79
+ "jev-screen.test.js",
79
80
  "repo-watch.test.js",
80
81
  "markdown.test.mjs",
81
82
  "start-policy.test.js",