create-pmos 0.528.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 +39 -35
- 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/briefing-premium-calendar-3-test.js +11 -9
- package/workspace/briefing-premium-calendar-test.js +1 -1
- package/workspace/changelog.json +38 -0
- package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
- package/workspace/guide/idle-lab.md +1 -1
- package/workspace/guide/notes.md +20 -8
- package/workspace/guide/reports-set-up.md +3 -1
- package/workspace/lib/bg-queue.js +5 -1
- package/workspace/lib/jev-notes.js +58 -3
- package/workspace/lib/jev-screen.js +110 -0
- package/workspace/lib/meeting-replay.js +14 -2
- package/workspace/lib/meeting-suggest.js +105 -0
- package/workspace/lib/meeting-terms.js +57 -17
- package/workspace/lib/notes-screen.js +15 -1
- package/workspace/lib/report-chat.js +3 -3
- package/workspace/lib/routes-lab.js +44 -2
- package/workspace/lib/routes-meeting-points.js +27 -3
- package/workspace/lib/routes-meeting-replay.js +13 -2
- package/workspace/lib/routes-notes.js +25 -4
- package/workspace/lib/routes-report-flow.js +35 -5
- package/workspace/lib/typesafe.js +71 -5
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/premium-vw-meet-test.js +17 -11
- package/workspace/public/pm/cozy-lab-ins.js +13 -4
- package/workspace/public/pm/cozy-vw-mtg.css +122 -24
- package/workspace/public/pm/notes-screen-capture.js +5 -4
- package/workspace/public/pm/premium-meeting.js +2 -1
- package/workspace/public/pm/premium-mtg-replay.js +149 -73
- package/workspace/public/pm/premium-report-tab.js +33 -4
- package/workspace/public/pm/premium-vw-mtg.js +191 -53
- package/workspace/public/pm/report-chat.js +1 -0
- package/workspace/public/pm/report-flow.js +7 -3
- package/workspace/public/pm/view-notes.js +2 -2
- package/workspace/test-files.json +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* meeting-suggest.js — the ✦ spelling the word fix offers (batch 27 item 12, word fix A: MpW1, MpWPhone). Local and read-only.
|
|
3
|
+
*
|
|
4
|
+
* suggest({ heard, fixes, invite, people, repo }) → { means, from: ['fix' | 'invite' | 'repo'], file?, alts: [spelling] } | null pure
|
|
5
|
+
* repoWords(root, { exclude }) → [{ w, file, n }] the names and terms the repository's Markdown writes (capitalised words, CamelCase,
|
|
6
|
+
* and two capitalised words in a row), cached with lib/notes-repo.js passages
|
|
7
|
+
*
|
|
8
|
+
* WHERE A SPELLING COMES FROM. Three places, and nothing else: a word the PM fixed before (the same heard words, remembered), the
|
|
9
|
+
* meeting's invite (its title, its people, its agenda and its material, as the page sends them), and the repository of the note's
|
|
10
|
+
* workspace (lib/notes-repo.js reads its Markdown; D79 exclusions are honoured). A spelling counts only when it is the heard words
|
|
11
|
+
* written another way ("Light box" → "Lightbox") or one or two letters away from them ("Lite box" → "Lightbox"), with the same first
|
|
12
|
+
* letter. When nothing matches, the answer is null and the page shows no ✦ line (zero is silence): a guess is never shown.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IT NEVER DOES. It writes nothing and sends nothing. No model is asked. */
|
|
15
|
+
const notesRepo = require('./notes-repo.js');
|
|
16
|
+
|
|
17
|
+
const squash = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
18
|
+
const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
19
|
+
function lev(a, b, max) {
|
|
20
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
21
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
22
|
+
for (let i = 1; i <= a.length; i++) {
|
|
23
|
+
const cur = [i]; let low = i;
|
|
24
|
+
for (let j = 1; j <= b.length; j++) { cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); low = Math.min(low, cur[j]); }
|
|
25
|
+
if (low > max) return max + 1;
|
|
26
|
+
prev = cur;
|
|
27
|
+
}
|
|
28
|
+
return prev[b.length];
|
|
29
|
+
}
|
|
30
|
+
/** The words as they sound, roughly: "light" and "lite" fold to "lit", "Scarlett" to "scarlet". English spelling only; nothing more clever. */
|
|
31
|
+
const fold = (s) => (String(s || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || []).map((w) => w.replace(/ght/g, 't').replace(/igh/g, 'i').replace(/ph/g, 'f').replace(/ck/g, 'k')
|
|
32
|
+
.replace(/(\p{L})\1/gu, '$1').replace(/^(.{3,})e$/u, '$1')).join('');
|
|
33
|
+
/** How far a spelling is from the heard words: 0 the same letters written another way, 1–2 letters away, or null (not the same word). */
|
|
34
|
+
function distance(heard, cand) {
|
|
35
|
+
const h = squash(heard), c = squash(cand);
|
|
36
|
+
if (h.length < 3 || c.length < 3 || h[0] !== c[0]) return null;
|
|
37
|
+
if (clean(heard) === clean(cand)) return null;
|
|
38
|
+
if (h === c) return 0;
|
|
39
|
+
const max = h.length >= 5 ? 2 : 0; // a short word must be the same letters: three or four letters one away is another word
|
|
40
|
+
const f = fold(heard), g = fold(cand);
|
|
41
|
+
const d = Math.min(lev(h, c, max), f.length >= 3 && f === g ? 1 : lev(f, g, max) + 1);
|
|
42
|
+
return d <= max ? d : null;
|
|
43
|
+
}
|
|
44
|
+
/** The names and terms a text writes: capitalised words and CamelCase (two letters or more), and two capitalised words in a row. */
|
|
45
|
+
function namesIn(text) {
|
|
46
|
+
const out = [];
|
|
47
|
+
const t = String(text || '');
|
|
48
|
+
const ws = t.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) || [];
|
|
49
|
+
for (let i = 0; i < ws.length; i++) {
|
|
50
|
+
const w = ws[i].replace(/['’-]+$/, '');
|
|
51
|
+
const cap = /^\p{Lu}/u.test(w) || /\p{Ll}\p{Lu}/u.test(w);
|
|
52
|
+
if (cap && w.length >= 3) out.push(w);
|
|
53
|
+
const nx = ws[i + 1] ? ws[i + 1].replace(/['’-]+$/, '') : '';
|
|
54
|
+
if (cap && nx && /^\p{Lu}/u.test(nx)) out.push(w + ' ' + nx);
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** A spelling no common word has: CamelCase ("MoEngage", "ePaper"), capitals ("IDPF") or a digit. */
|
|
59
|
+
const distinct = (w) => /\p{Ll}\p{Lu}/u.test(w) || /^\p{Lu}{2,}$/u.test(w.replace(/\s+/g, '')) || /\d/.test(w);
|
|
60
|
+
const CACHE_MS = 60000;
|
|
61
|
+
const cache = new Map();
|
|
62
|
+
function repoWords(root, { exclude = [], now = Date.now() } = {}) {
|
|
63
|
+
if (!root) return [];
|
|
64
|
+
const key = root + '|' + JSON.stringify(exclude);
|
|
65
|
+
const hit = cache.get(key);
|
|
66
|
+
if (hit && now - hit.at < CACHE_MS) return hit.words;
|
|
67
|
+
const count = new Map();
|
|
68
|
+
for (const p of notesRepo.passages(root, { exclude, now })) {
|
|
69
|
+
for (const w of namesIn(p.text)) { const x = count.get(w); if (x) x.n++; else count.set(w, { w, file: p.path, n: 1 }); }
|
|
70
|
+
}
|
|
71
|
+
const words = [...count.values()];
|
|
72
|
+
cache.set(key, { at: now, words });
|
|
73
|
+
return words;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The best spelling for the heard words, and up to two others. fixes: [{ heard, means }] remembered fixes (phrase lower case);
|
|
77
|
+
* invite: [text] the meeting's title, agenda and material; people: [name] the invite's people; repo: repoWords(). */
|
|
78
|
+
function suggest({ heard, fixes = [], invite = [], people = [], repo = [] } = {}) {
|
|
79
|
+
const h = clean(heard);
|
|
80
|
+
if (!h || h.length > 60) return null;
|
|
81
|
+
const c = new Map(); // spelling → { means, d, from:Set, n, file }
|
|
82
|
+
const words = h.split(' ').length;
|
|
83
|
+
const add = (w, from, n = 1, file = '', person = false) => {
|
|
84
|
+
if (w.toLowerCase() === h.toLowerCase() && !distinct(w)) return; // "users" and "Users" are the same word
|
|
85
|
+
const d = from === 'fix' ? 0 : distance(h, w); if (d == null) return;
|
|
86
|
+
// letters away is a misheard NAME only: CamelCase, capitals or digits, a person of the invite, or several heard words that are one word
|
|
87
|
+
if (d > 0 && from !== 'fix' && !(distinct(w) || person || (words > 1 && !/\s/.test(w)))) return;
|
|
88
|
+
const k = clean(w); const x = c.get(k) || { means: k, d, from: new Set(), n: 0, file: '' };
|
|
89
|
+
x.d = Math.min(x.d, d); x.from.add(from); x.n += n; if (file && !x.file) x.file = file;
|
|
90
|
+
c.set(k, x);
|
|
91
|
+
};
|
|
92
|
+
const hp = squash(h);
|
|
93
|
+
for (const f of fixes) if (f && f.means && squash(f.heard) === hp && clean(f.means) !== h) add(f.means, 'fix');
|
|
94
|
+
for (const t of invite) for (const w of namesIn(t)) add(w, 'invite');
|
|
95
|
+
for (const p of people) for (const w of namesIn(p)) add(w, 'invite', 1, '', true);
|
|
96
|
+
for (const r of repo) if (r && r.w) add(r.w, 'repo', r.n || 1, r.file || '');
|
|
97
|
+
if (!c.size) return null;
|
|
98
|
+
const rank = (x) => (x.from.has('fix') ? 0 : 10) + x.d * 2 - Math.min(3, x.from.size) - Math.min(2, Math.log10(1 + x.n));
|
|
99
|
+
const list = [...c.values()].sort((a, b) => rank(a) - rank(b) || b.n - a.n || a.means.localeCompare(b.means));
|
|
100
|
+
const best = list[0];
|
|
101
|
+
return { means: best.means, from: ['fix', 'invite', 'repo'].filter((k) => best.from.has(k)), ...(best.from.has('repo') && best.file ? { file: best.file } : {}),
|
|
102
|
+
alts: list.slice(1).filter((x) => squash(x.means) !== squash(best.means) || x.means !== best.means).slice(0, 2).map((x) => x.means) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { suggest, repoWords, distance, namesIn, squash, fold };
|
|
@@ -1,34 +1,57 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
/* meeting-terms.js — a word heard wrong, fixed in place (batch 25 item 11
|
|
2
|
+
/* meeting-terms.js — a word heard wrong, fixed in place (batch 25 item 11 MvF/MvN; batch 27 item 12 word fix A: MpW1, MpW2, MpWPhone).
|
|
3
3
|
*
|
|
4
|
-
* The PM selects a word in a meeting's
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The PM selects a word or a few words in a meeting's notes ("Lite box") and fixes it ("Lightbox"). The fix shows at once in every place
|
|
5
|
+
* this note shows the words. Remembering it for LATER notes waits for the PM (batch 27 item 12): the ✦ ask "Remember “Lightbox” for your
|
|
6
|
+
* next meetings?" — ✓ makes it one of the lessons of lib/meeting-learn.js, of kind 'term' (its phrase is the heard words, its meaning the
|
|
7
|
+
* fix), so the agent's handoff carries it for later notes (meeting-learn.js handoffLines) and other notes underline the heard words with
|
|
8
|
+
* the fix as the guess. ✕ keeps it in this note only. A fix made before batch 27 was a lesson at once; it stays remembered.
|
|
9
|
+
* The note's own files are NOT rewritten: rewriting every stored copy of a word is not proven.
|
|
9
10
|
*
|
|
10
|
-
* term(dir, { note, heard, means, now }) → { ok, term, terms } | { ok: false, status, error }
|
|
11
|
-
*
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
11
|
+
* term(dir, { note, heard, means, now }) → { ok, term, terms } | { ok: false, status, error } the fix, in this note only (keep 'ask')
|
|
12
|
+
* keep(dir, { lesson, keep: true | false, now }) → { ok, term, terms } | { ok: false, status, error } the PM's answer to the ✦ ask
|
|
13
|
+
* terms(dir) → [{ id, heard, means, note, at, keep: 'yes' | 'ask' | 'no' }] the fixes that hold now; the latest fix of the same words in a note wins
|
|
14
|
+
* (Undo: lib/meeting-learn.js forget({ lesson: <term id> }), which this module honours too)
|
|
15
|
+
*
|
|
16
|
+
* THE FILE. <runtime>/meeting-lessons.jsonl (meeting-learn.js FILE), append-only, file mode: { t: 'term' } is a fix in its note,
|
|
17
|
+
* { t: 'lesson', kind: 'term' } with the same id is the fix remembered, { t: 'keep', lesson, keep: 'no' } is "this note only", and
|
|
18
|
+
* { t: 'forget', lesson } takes the fix away. Nothing here sends anything. */
|
|
14
19
|
const fs = require('fs');
|
|
15
20
|
const path = require('path');
|
|
16
21
|
const crypto = require('crypto');
|
|
17
22
|
const ML = require('./meeting-learn.js');
|
|
18
23
|
|
|
19
24
|
const NOTE_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
25
|
+
const LESSON_ID = /^l[0-9a-f]{10}$/;
|
|
20
26
|
const MAX_WORD = 60;
|
|
21
27
|
const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
22
28
|
/** The heard words as a lesson's phrase: lower case, the same words meeting-learn.js mentions() matches. */
|
|
23
29
|
const phraseOf = (s) => (clean(s).toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}.'-]*/gu) || []).map((w) => w.replace(/[.'-]+$/, '')).filter(Boolean).join(' ');
|
|
24
30
|
|
|
31
|
+
function readLines(dir) {
|
|
32
|
+
let text = '';
|
|
33
|
+
try { text = fs.readFileSync(path.join(dir, ML.FILE), 'utf8'); } catch { return []; }
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const l of text.split('\n')) { if (!l.trim()) continue; try { const o = JSON.parse(l); if (o && typeof o === 'object') out.push(o); } catch { /* a torn line is skipped */ } }
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
function terms(dir) {
|
|
40
|
+
if (!dir) return [];
|
|
41
|
+
const by = new Map();
|
|
42
|
+
for (const l of readLines(dir)) {
|
|
43
|
+
if (l.t === 'term' && l.id && l.phrase && l.meaning) by.set(l.id, { id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at, keep: 'ask' });
|
|
44
|
+
else if (l.t === 'lesson' && l.kind === 'term' && l.id && l.phrase && l.meaning) {
|
|
45
|
+
const x = by.get(l.id);
|
|
46
|
+
if (x) x.keep = 'yes'; else by.set(l.id, { id: l.id, heard: l.phrase, means: l.meaning, note: l.note, at: l.at, keep: 'yes' });
|
|
47
|
+
} else if (l.t === 'keep' && by.has(l.lesson)) { const x = by.get(l.lesson); if (x.keep === 'ask') x.keep = 'no'; }
|
|
48
|
+
else if (l.t === 'forget' && l.lesson) by.delete(l.lesson);
|
|
49
|
+
}
|
|
26
50
|
const out = [];
|
|
27
|
-
for (const
|
|
28
|
-
|
|
29
|
-
const i = out.findIndex((x) => x.note === l.note && x.heard === l.phrase);
|
|
51
|
+
for (const x of by.values()) {
|
|
52
|
+
const i = out.findIndex((y) => y.note === x.note && y.heard === x.heard);
|
|
30
53
|
if (i >= 0) out.splice(i, 1);
|
|
31
|
-
out.push(
|
|
54
|
+
out.push(x);
|
|
32
55
|
}
|
|
33
56
|
return out;
|
|
34
57
|
}
|
|
@@ -46,8 +69,25 @@ function term(dir, body = {}) {
|
|
|
46
69
|
const at = new Date(body.now || Date.now()).toISOString();
|
|
47
70
|
const id = 'l' + crypto.createHash('sha1').update('term|' + note + '|' + heard + '|' + means + '|' + at).digest('hex').slice(0, 10);
|
|
48
71
|
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
-
fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify({ v: 1, t: '
|
|
50
|
-
|
|
72
|
+
fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify({ v: 1, t: 'term', id, phrase: heard, meaning: means, note, at }) + '\n');
|
|
73
|
+
const all = terms(dir);
|
|
74
|
+
return { ok: true, term: all.find((x) => x.id === id), terms: all };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The PM's answer to "Remember “…” for your next meetings?": ✓ makes the fix a lesson for later notes, ✕ keeps it in its note. */
|
|
78
|
+
function keep(dir, body = {}) {
|
|
79
|
+
if (!dir) return { ok: false, status: 500, error: 'no runtime folder' };
|
|
80
|
+
const id = String(body.lesson || '');
|
|
81
|
+
if (!LESSON_ID.test(id)) return { ok: false, status: 400, error: 'lesson is the id of a word fix' };
|
|
82
|
+
if (typeof body.keep !== 'boolean') return { ok: false, status: 400, error: 'keep is true (remember it) or false (this meeting only)' };
|
|
83
|
+
const x = terms(dir).find((y) => y.id === id);
|
|
84
|
+
if (!x) return { ok: false, status: 404, error: 'no word fix has that id' };
|
|
85
|
+
const at = new Date(body.now || Date.now()).toISOString();
|
|
86
|
+
const line = body.keep ? { v: 1, t: 'lesson', id, phrase: x.heard, kind: 'term', meaning: x.means, orig: null, note: x.note, point: '', at }
|
|
87
|
+
: { v: 1, t: 'keep', lesson: id, keep: 'no', at };
|
|
88
|
+
if (body.keep || x.keep === 'ask') fs.appendFileSync(path.join(dir, ML.FILE), JSON.stringify(line) + '\n');
|
|
89
|
+
const all = terms(dir);
|
|
90
|
+
return { ok: true, term: all.find((y) => y.id === id), terms: all };
|
|
51
91
|
}
|
|
52
92
|
|
|
53
|
-
module.exports = { term, terms, MAX_WORD };
|
|
93
|
+
module.exports = { term, terms, keep, MAX_WORD, phraseOf };
|
|
@@ -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 };
|
|
@@ -31,7 +31,7 @@ const RULE = [
|
|
|
31
31
|
' A change to the draft of a set-up report (a date, a slide, a wording): {"kind":"report","id":"<its id>","change":"the change, in the PM\'s words"}. The page passes it to the report\'s own session by itself, with no press, and the draft updates beside this chat; nothing is issued. Do not change the draft yourself.',
|
|
32
32
|
' Every refinement the PM gives about a set-up report (the tab shows "Draft · <date>"), in this chat or later in it, is such a change: end with the change block, with the whole change in it, and say in ONE line what you passed, e.g. "Passed to the Nov 9 draft: tender stays open with no award date; Feature implementation not started."',
|
|
33
33
|
' Never say "press Draft it", "the proposal is unchanged" or "the report drafts in its own session" for a set-up report. If the draft needs something you do not know (a date), ask it once in the same answer and pass the change with what is known: the draft marks the unknown, it does not wait.',
|
|
34
|
-
' A
|
|
34
|
+
' A LOOK change (a palette, colours, fonts, table format, a Gantt or chart style, "follow this deck or screenshot") is NOT a change to the draft text: the drafting session may not change the look. Send {"kind":"report","id":"<its id>","design":"the look, in the PM\'s words, complete"} and, when the PM attached a file to follow, "sample":"<its path>" (a .pdf, .pptx, .docx, .png or .jpg; one file, the newest). The page starts the next look version by itself; the PM keeps it with ✓ beside the chat and the open draft is redrawn in it, its numbers unchanged. Say that in one line. A message that asks for both sends the look as "design" and the words as "change" in one block.',
|
|
35
35
|
' An issued report (its latest cycle is issued) is frozen: pass nothing, and say the change goes into the template for the next cycle.',
|
|
36
36
|
' The set-up reports are the setup lines of report-setups.jsonl (planning/ or pmos/state/); a later change line wins, a remove line deletes one.',
|
|
37
37
|
'- A chart of one measure: {"kind":"chart","proposal":{"measure":"<KR metric id>","title":"short title","question":"what it answers","form":"line|bars|number|meter","range":null,"target":null,"better":"higher|lower","note":""}}',
|
|
@@ -43,7 +43,7 @@ const RULE = [
|
|
|
43
43
|
'If the message is not about a report, a chart or a dashboard, answer it and add no block.',
|
|
44
44
|
].join('\n');
|
|
45
45
|
|
|
46
|
-
const REMIND = 'This chat is about a report. If it is set up (report-setups.jsonl, by name) every refinement is a change to its open draft: end with {"kind":"report","id":"<its id>","change":"the whole change, in the PM\'s words"} in a pmos-report block, the page passes it by itself, and say in one line what you passed and that the draft is updating (
|
|
46
|
+
const REMIND = 'This chat is about a report. If it is set up (report-setups.jsonl, by name) every refinement is a change to its open draft: end with {"kind":"report","id":"<its id>","change":"the whole change, in the PM\'s words"} in a pmos-report block, the page passes it by itself, and say in one line what you passed and that the draft is updating. A look change (palette, tables, Gantt, a deck or screenshot to follow) is "design":"the look" with "sample":"<the attached file path>" instead of "change": the page starts the next look and the PM keeps it with ✓. Never say "the draft stays as it is" or "press Draft it" or "the proposal is unchanged" for a set-up report; ask anything unknown once and pass what is known. An issued report is frozen: pass nothing. Same rules as before.';
|
|
47
47
|
|
|
48
48
|
/** A later message in a chat that already carried the rule (the PM refines without saying "report"): the short reminder, then the PM's words. */
|
|
49
49
|
function remind(text) { return MARK + ' ' + REMIND + '\n\n' + SAYS + String(text == null ? '' : text); }
|
|
@@ -57,7 +57,7 @@ function linkedNote(rep) {
|
|
|
57
57
|
return [
|
|
58
58
|
`This chat set up the report "${name}" (report id ${id}). The PM pressed Draft it already: the report is set up and its own session drafts it.${rep.draft ? ' Its latest draft: ' + String(rep.draft).slice(0, 200) + '.' : ''}`,
|
|
59
59
|
'Read every message here as feedback on that draft unless it is plainly about something else. Never tell the PM to press Draft it again, and never say the proposal is unchanged.',
|
|
60
|
-
`When the PM asks for a change to the draft in any words (a date, a status, a row, a slide, a wording, a point to keep open), answer in a few lines and end with {"kind":"report","id":"${id}","change":"the change, in the PM's words, complete"}. Put every point of the PM's message in "change"; it is all the report's session reads.`,
|
|
60
|
+
`A look change (palette, tables, a Gantt or chart style, a deck or screenshot to follow) is {"kind":"report","id":"${id}","design":"the look, complete","sample":"<the attached file's path, if any>"}: the page starts the next look and the PM keeps it with ✓. When the PM asks for a change to the draft's content in any words (a date, a status, a row, a slide, a wording, a point to keep open), answer in a few lines and end with {"kind":"report","id":"${id}","change":"the change, in the PM's words, complete"}. Put every point of the PM's message in "change"; it is all the report's session reads.`,
|
|
61
61
|
'PMOS passes the block to the report\'s session as your answer lands, and the draft beside this chat changes. Say so in one line. When you need an answer from the PM first, ask it and add no block.',
|
|
62
62
|
].join('\n');
|
|
63
63
|
}
|
|
@@ -35,19 +35,61 @@ function titles(w) {
|
|
|
35
35
|
try { const b = require('./routes-boards.js').baseRows(w); if (b && b.ok) for (const r of b.rows || []) if (r && r.id) m.set(r.id, { title: r.title || r.id, board: r.board || '' }); } catch { /* none */ }
|
|
36
36
|
return m;
|
|
37
37
|
}
|
|
38
|
+
// Why a grill run started, in plain words (lib/grill-runs.js why), for "Checked <initiative> for risks · <why> · <what it found>"
|
|
39
|
+
// (Wawan, 2026-11-04: "I don't understand" "Grill · … why: monthly").
|
|
40
|
+
const GRILL_WHY = { monthly: 'monthly check', ask: 'you asked', exit: 'it passed a stage', forecast: 'its forecast moved', move: 'its tasks or end date moved', plan: 'its PRD changed' };
|
|
41
|
+
const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
|
|
42
|
+
/** A grill run's findings: filed by the run (proposals.js propose lines carry the run), and the ones still waiting for the PM. */
|
|
43
|
+
function grillFound(P, run) {
|
|
44
|
+
let filed = 0, waiting = 0;
|
|
45
|
+
if (!P) return { filed, waiting };
|
|
46
|
+
const kept = PR.keptIds(P);
|
|
47
|
+
for (const p of P.props.values()) if (p.run === run) { filed++; if (!kept.has(p.id) && !P.drops.has(PR.sigOf(p))) waiting++; }
|
|
48
|
+
return { filed, waiting };
|
|
49
|
+
}
|
|
50
|
+
/** A report job of the shared queue (origin report-design:<id>), named with the report: "Redrew TSC Monthly in its saved look". */
|
|
51
|
+
function reportRow(x, root, runtimeDir) {
|
|
52
|
+
let name = '';
|
|
53
|
+
// a line written before the queue kept `ref`: the session's own record still names its origin
|
|
54
|
+
if (!x.ref && x.session && runtimeDir && /^[A-Za-z0-9-]{1,40}$/.test(x.session)) {
|
|
55
|
+
try { const o = JSON.parse(fs.readFileSync(require('path').join(runtimeDir, 'sessions', x.session + '.json'), 'utf8')).origin; if (typeof o === 'string' && o.startsWith('report-design:')) x = { ...x, ref: o.slice(14) }; } catch { /* the session record is gone */ }
|
|
56
|
+
}
|
|
57
|
+
// or the cycle render it wrote: a <slot>.design.json written within two minutes of the job's end
|
|
58
|
+
if (!x.ref && root) {
|
|
59
|
+
try {
|
|
60
|
+
const path = require('path'), dir = path.join(root, require('./report-draft.js').stateRel(root), 'reports'), end = Date.parse(x.at);
|
|
61
|
+
let best = null;
|
|
62
|
+
for (const id of fs.readdirSync(dir)) {
|
|
63
|
+
let files; try { files = fs.readdirSync(path.join(dir, id)); } catch { continue; }
|
|
64
|
+
for (const f of files) if (f.endsWith('.design.json')) { const d = Math.abs(fs.statSync(path.join(dir, id, f)).mtimeMs - end); if (d <= 120000 && (!best || d < best.d)) best = { id, d }; }
|
|
65
|
+
}
|
|
66
|
+
if (best) x = { ...x, ref: best.id };
|
|
67
|
+
} catch { /* no reports folder */ }
|
|
68
|
+
}
|
|
69
|
+
try { if (x.ref && root) { const s = require('./report-setups.js').stateOf(root).setups.get(x.ref); name = s && s.name ? s.name : ''; } } catch { name = ''; }
|
|
70
|
+
const what = /cycle/.test(x.title || '') ? 'Redrew ' + (name || 'a report') + ' in its saved look' : 'Made the next look of ' + (name || 'a report');
|
|
71
|
+
return { job: x.job, title: what, meta: '', at: x.at };
|
|
72
|
+
}
|
|
38
73
|
/** What the other background jobs did this week, as record rows. Each source that cannot be read is left out. */
|
|
39
74
|
function others(ctx, w, dir, T, now) {
|
|
40
75
|
const out = [], since = now - WEEK, name = (ini) => (T.get(ini) || {}).title || ini;
|
|
41
76
|
try {
|
|
42
77
|
const G = require('./grill.js'), F = ctx.runtimeDir ? G.read(ctx.runtimeDir) : null;
|
|
43
|
-
|
|
78
|
+
const P = (() => { try { return dir ? PR.read(dir) : null; } catch { return null; } })();
|
|
79
|
+
for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) {
|
|
80
|
+
const f = grillFound(P, r.run);
|
|
81
|
+
const found = !f.filed ? 'nothing found' : f.waiting === f.filed ? plural(f.filed, 'finding', 'findings') + ' waiting'
|
|
82
|
+
: plural(f.filed, 'finding', 'findings') + (f.waiting ? ', ' + f.waiting + ' waiting' : ', all seen');
|
|
83
|
+
// the initiative chip only when something waits there for the PM
|
|
84
|
+
out.push({ job: 'grill', title: 'Checked ' + name(r.ini) + ' for risks', meta: [GRILL_WHY[r.why] || r.why, found].filter(Boolean).join(' · '), at: r.endAt || r.at, ...(f.waiting ? { ini: r.ini, board: r.board || '' } : {}) });
|
|
85
|
+
}
|
|
44
86
|
} catch { /* no grill log */ }
|
|
45
87
|
try {
|
|
46
88
|
// the AI jobs' audit (lib/ai-guard.js AUDIT), read as a file: this page files nothing through the guard
|
|
47
89
|
const audit = dir ? fs.readFileSync(require('path').join(dir, 'ai-audit.jsonl'), 'utf8').split('\n').slice(-400).map((s) => { try { return JSON.parse(s); } catch { return null; } }).filter(isObj) : [];
|
|
48
90
|
for (const l of audit) if (l.event === 'end' && (Date.parse(l.at) || 0) >= since) out.push({ job: l.job, title: (JOB_NAMES[l.job] || l.job), meta: l.filed != null ? l.filed + ' filed' : '', at: l.at });
|
|
49
91
|
} catch { /* no audit */ }
|
|
50
|
-
try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
|
|
92
|
+
try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x.job === 'report-design' ? reportRow(x, w.root, ctx.runtimeDir) : x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
|
|
51
93
|
return out;
|
|
52
94
|
}
|
|
53
95
|
|
|
@@ -10,10 +10,14 @@
|
|
|
10
10
|
* POST /api/meeting-points/forget { lesson } → { ok } Undo of what PMOS learned, and of a word fix (its term id)
|
|
11
11
|
* POST /api/meeting-points/term { note, heard, means } → { ok, term, terms } a word fixed in place, in every place the note shows it
|
|
12
12
|
* The GET answers { terms } too: the word fixes that hold now (lib/meeting-terms.js).
|
|
13
|
+
* POST /api/meeting-points/keep { lesson, keep } → { ok, term, terms } the ✦ ask: remember a word fix for the next meetings (true) or not
|
|
14
|
+
* GET /api/meeting-points/suggest?note=<id>&heard=<words>&invite=<text>…&people=<name>… → { ok, suggest: { means, from, file?, alts } | null }
|
|
15
|
+
* the spelling a word fix offers (lib/meeting-suggest.js): a fix remembered before, the invite the page sends, the workspace's repository
|
|
13
16
|
* The POSTs are page presses (lib/pm-guard.js requirePagePress). Handlers are pure over (q | body, ctx) so a test needs no server. */
|
|
14
17
|
const MP = require('./meeting-points.js');
|
|
15
18
|
const ML = require('./meeting-learn.js');
|
|
16
19
|
const MT = require('./meeting-terms.js');
|
|
20
|
+
const MS = require('./meeting-suggest.js');
|
|
17
21
|
const guard = require('./pm-guard.js');
|
|
18
22
|
|
|
19
23
|
const fail = (status, error) => ({ status, body: { ok: false, error } });
|
|
@@ -26,7 +30,7 @@ function read(q, ctx) {
|
|
|
26
30
|
function learn(req, body, ctx, op) {
|
|
27
31
|
const g = guard.requirePagePress(req, body);
|
|
28
32
|
if (!g.ok) return fail(g.status, g.error);
|
|
29
|
-
const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
|
|
33
|
+
const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : op === 'keep' ? MT.keep(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
|
|
30
34
|
if (!r.ok) return fail(r.status || 400, r.error);
|
|
31
35
|
return { status: 200, body: r };
|
|
32
36
|
}
|
|
@@ -37,13 +41,33 @@ function write(req, body, ctx) {
|
|
|
37
41
|
return r.ok ? { status: 200, body: { ok: true, points: r.points } } : fail(r.status || 400, r.error);
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
/** The ✦ spelling for heard words: the remembered fixes, the invite's words the page sends, and the note's workspace repository. */
|
|
45
|
+
function suggest(q, ctx, all = {}) {
|
|
46
|
+
const note = String(q.note || ''), heard = String(q.heard || '').replace(/\s+/g, ' ').trim();
|
|
47
|
+
if (!/^[A-Za-z0-9_-]{1,80}$/.test(note)) return fail(400, 'note is the id of a meeting note');
|
|
48
|
+
if (!heard || heard.length > MT.MAX_WORD) return fail(400, 'heard is the words to fix, at most ' + MT.MAX_WORD + ' characters');
|
|
49
|
+
const list = (k) => (Array.isArray(all[k]) ? all[k] : []).map((x) => String(x).slice(0, 300)).slice(0, 60);
|
|
50
|
+
let repo = [];
|
|
51
|
+
try {
|
|
52
|
+
const n = require('./notes.js').load(ctx.runtimeDir, note);
|
|
53
|
+
const w = n && n.ws && typeof ctx.getWs === 'function' ? ctx.getWs(n.ws) : null;
|
|
54
|
+
if (w && w.root) repo = MS.repoWords(w.root, { exclude: require('./typesafe-settings.js').getRepo(ctx.runtimeDir, w.root).exclude || [] });
|
|
55
|
+
} catch { repo = []; } // no repository to read: the other places still answer
|
|
56
|
+
const fixes = MT.terms(ctx.runtimeDir).filter((x) => x.keep === 'yes' || x.note === note);
|
|
57
|
+
return { status: 200, body: { ok: true, suggest: MS.suggest({ heard, fixes, invite: list('invite'), people: list('people'), repo }) } };
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
41
61
|
|
|
42
62
|
module.exports = {
|
|
43
63
|
prefixes: ['/api/meeting-points'],
|
|
44
|
-
read, write, learn,
|
|
64
|
+
read, write, learn, suggest,
|
|
45
65
|
handle: async (req, res, url, ctx) => {
|
|
46
|
-
|
|
66
|
+
if (url.pathname === '/api/meeting-points/suggest') {
|
|
67
|
+
if (req.method !== 'GET') return respond(ctx, res, fail(405, 'GET only'));
|
|
68
|
+
return respond(ctx, res, suggest(Object.fromEntries(url.searchParams), ctx, { invite: url.searchParams.getAll('invite'), people: url.searchParams.getAll('people') }));
|
|
69
|
+
}
|
|
70
|
+
const op = { '/api/meeting-points/edit': 'edit', '/api/meeting-points/forget': 'forget', '/api/meeting-points/term': 'term', '/api/meeting-points/keep': 'keep' }[url.pathname] || '';
|
|
47
71
|
if (op) {
|
|
48
72
|
if (req.method !== 'POST') return respond(ctx, res, fail(405, 'POST only'));
|
|
49
73
|
const got = await ctx.readJson(req);
|
|
@@ -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()]) };
|
|
@@ -55,6 +55,19 @@ function cyclesOf(root, id) {
|
|
|
55
55
|
}
|
|
56
56
|
function readText(root, rel) { try { return fs.readFileSync(path.join(path.resolve(String(root)), rel), 'utf8'); } catch { return null; } }
|
|
57
57
|
function sessionFile(root, id) { return path.join(path.dirname(RT.ledgerOf(root, id)), 'session.json'); }
|
|
58
|
+
// The drafting session's answer to a change (Wawan, 2026-11-04: it declined a look change in its own terminal and the panel said "drafting it"
|
|
59
|
+
// for good). ask() writes ask.json { at, change } and asks the session to write answer.json { text } when it is done or does nothing;
|
|
60
|
+
// stateOf() shows the answer once it is newer than the ask, and says the change is with the session until then.
|
|
61
|
+
const askFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'ask.json');
|
|
62
|
+
const answerFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'answer.json');
|
|
63
|
+
function readJson(f) { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
|
|
64
|
+
function answerOf(root, id) {
|
|
65
|
+
const a = readJson(askFile(root, id)); if (!a || !a.at) return { asking: null, answer: null };
|
|
66
|
+
let st = null; try { st = fs.statSync(answerFile(root, id)); } catch { st = null; }
|
|
67
|
+
const ans = st && st.mtimeMs >= Date.parse(a.at) ? readJson(answerFile(root, id)) : null;
|
|
68
|
+
const txt = ans && typeof ans.text === 'string' ? ans.text.replace(/\s+/g, ' ').trim().slice(0, 400) : '';
|
|
69
|
+
return txt ? { asking: null, answer: { text: txt, at: new Date(st.mtimeMs).toISOString(), asked: a.at } } : { asking: { at: a.at }, answer: null };
|
|
70
|
+
}
|
|
58
71
|
function readSession(root, id) { try { const o = JSON.parse(fs.readFileSync(sessionFile(root, id), 'utf8')); return isObj(o) && typeof o.session === 'string' ? o : null; } catch { return null; } }
|
|
59
72
|
const alive = (ctx, sid) => !!sid && !!(ctx.chatSessions && typeof ctx.chatSessions.alive === 'function' && ctx.chatSessions.alive(sid));
|
|
60
73
|
function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState === 'function' ? ctx.structureDraftState(w, id) || null : null; } catch { return null; } }
|
|
@@ -81,6 +94,7 @@ function stateOf(ctx, w, id, now = Date.now()) {
|
|
|
81
94
|
template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings, names), cycles: counts.get(v.n) || 0 })) },
|
|
82
95
|
cycles, latest, drafting,
|
|
83
96
|
session: sess ? { id: sess.session, alive: alive(ctx, sess.session), since: sess.since || null } : null,
|
|
97
|
+
...answerOf(root, id),
|
|
84
98
|
review,
|
|
85
99
|
sections: { saved: live.structure, titles: names.sections(live.structure) || (s && Array.isArray(s.sections) ? s.sections.slice() : []) },
|
|
86
100
|
look: lookOf(root, id, live.design, names),
|
|
@@ -152,18 +166,23 @@ function draftWords(id, rel) {
|
|
|
152
166
|
return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place: edit only the note or notes the change is about, and keep every caveat. `
|
|
153
167
|
+ `Do not redraft for a change to the words. Only when the PM asks for fresh numbers, run \`pmos report draft ${id} ${slot} --redraft\`; never \`pmos report draft ${id}\` without the slot, and do not draft a new slot.` : '';
|
|
154
168
|
}
|
|
169
|
+
/** The last line of every change: where the session says what it did, so the PM reads it beside the chat (never only in this terminal). */
|
|
170
|
+
function answerWords(root, id) {
|
|
171
|
+
return `When you are done, or if you change nothing, write ONE JSON object {"text":"<one or two plain sentences for the PM: what you changed, or why you did not>"} to ${answerFile(root, id)}. A look change (palette, tables, a Gantt style) is not yours: say it goes in the report's look, which the PM keeps with ✓ beside the chat.`;
|
|
172
|
+
}
|
|
155
173
|
/** The words the report's session gets for one change. */
|
|
156
|
-
function askPrompt(skill, id, rel, change) {
|
|
174
|
+
function askPrompt(skill, id, rel, change, root) {
|
|
157
175
|
return `${skill}\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${id}\n\n`
|
|
158
176
|
+ `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? draftWords(id, rel) + ' ' : ''}`
|
|
159
|
-
+ `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page
|
|
177
|
+
+ `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page. ${answerWords(root, id)}`;
|
|
160
178
|
}
|
|
161
179
|
function ask(ctx, w, id, change, rel) {
|
|
162
180
|
const root = w.root;
|
|
163
181
|
const d = draftingOf(ctx, w, id);
|
|
164
182
|
const sess = readSession(root, id);
|
|
165
183
|
const target = d && d.state === 'drafting' && alive(ctx, d.session) ? d.session : sess && alive(ctx, sess.session) ? sess.session : null;
|
|
166
|
-
|
|
184
|
+
try { fs.mkdirSync(path.dirname(askFile(root, id)), { recursive: true }); fs.writeFileSync(askFile(root, id), JSON.stringify({ at: new Date().toISOString(), change: String(change).slice(0, 600) }) + '\n'); } catch { /* the panel then shows no answer */ }
|
|
185
|
+
const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it. ${answerWords(root, id)}`;
|
|
167
186
|
if (target) {
|
|
168
187
|
const sent = typeof ctx.sendToSession === 'function' && ctx.sendToSession(target, msg);
|
|
169
188
|
if (sent) return { ok: true, session: target, path: rel, passed: true };
|
|
@@ -174,7 +193,7 @@ function ask(ctx, w, id, change, rel) {
|
|
|
174
193
|
const origin = 'report-session:' + id;
|
|
175
194
|
if (typeof ctx.endSupersededRuns === 'function') { try { ctx.endSupersededRuns(origin); } catch { /* the old one ends by itself */ } }
|
|
176
195
|
const s = setupOf(root, id);
|
|
177
|
-
const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change) + '\r' });
|
|
196
|
+
const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change, root) + '\r' });
|
|
178
197
|
if (!sp || sp.state === 'exited' || !sp.id) return { ok: false, status: 409, error: 'The report\'s session did not start: ' + ((sp && sp.note) || 'no session') };
|
|
179
198
|
try { fs.mkdirSync(path.dirname(sessionFile(root, id)), { recursive: true }); fs.writeFileSync(sessionFile(root, id), JSON.stringify({ session: sp.id, since: new Date().toISOString() }) + '\n'); } catch { /* shown as no session */ }
|
|
180
199
|
return { ok: true, session: sp.id, path: rel, started: true };
|
|
@@ -250,7 +269,18 @@ async function handle(req, res, url, ctx) {
|
|
|
250
269
|
if (!ar.ok) return fail(ar);
|
|
251
270
|
const names = RT.namesOf(w.root, id);
|
|
252
271
|
const v = RT.addVersion(w.root, id, next, { names, why: RT.diffWords(was, next, names).join('; ').slice(0, 200) });
|
|
253
|
-
|
|
272
|
+
if (!v.ok) return fail(v);
|
|
273
|
+
// a new look kept with ✓ redraws the open draft in it at once, as the design studio's "This is it" does (Wawan, 2026-11-04: the look asked
|
|
274
|
+
// for in the chat never reached the draft). Only the look is redrawn: the text and its numbers stay; an issued cycle keeps its look.
|
|
275
|
+
let redraw = null;
|
|
276
|
+
if (ar.done && ar.done.design && next.design != null) {
|
|
277
|
+
const cur = cyclesOf(w.root, id)[0];
|
|
278
|
+
if (cur && cur.state === 'draft') {
|
|
279
|
+
let r; try { r = require('./routes-report-design.js').startCycle(ctx, w, id, cur.path); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
|
|
280
|
+
redraw = { path: cur.path, slot: cur.slot, started: !!(r && r.ok), ...(r && r.ok ? {} : { reason: String((r && r.error) || '') }) };
|
|
281
|
+
} else if (cur) redraw = { path: cur.path, slot: cur.slot, started: false, reason: 'sent' };
|
|
282
|
+
}
|
|
283
|
+
return send({ status: 200, body: { ok: true, n: v.n, redraw, state: stateOf(ctx, w, id) } });
|
|
254
284
|
}
|
|
255
285
|
if (sub === 'restore') {
|
|
256
286
|
const f = RT.backfill(w.root, id, RT.fold(RT.readLines(w.root, id))), v = f.versions.find((x) => x.n === +body.n);
|