create-pmos 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +10 -1
- package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +67 -1
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/status-facts.js +13 -3
- package/payload/scripts/workflow-state.js +7 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +3 -1
- package/workspace/guide/knowledge-graph.md +12 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/validate-with-typesafe.md +15 -1
- package/workspace/lib/engine/status-facts.js +13 -3
- package/workspace/lib/engine/workflow-state.js +7 -2
- package/workspace/lib/jev-categorize.js +47 -13
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +11 -1
- package/workspace/lib/live-notes.js +171 -26
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +21 -1
- package/workspace/public/pm/modes-live.js +68 -5
- package/workspace/public/pm/panel-cockpit.js +3 -1
- package/workspace/public/pm/panel-detail.js +3 -1
- package/workspace/public/pm/panel-tabs.js +112 -10
- package/workspace/public/pm/state.js +3 -0
- package/workspace/public/pm/view-livenotes.js +397 -0
- package/workspace/server.js +66 -7
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/* pm/view-livenotes.js — the Live notes view tab: confirm each talking point as it arrives (jev-live-notes, Run 6).
|
|
2
|
+
*
|
|
3
|
+
* The PM types or pastes what is being said, one point per line. Each point comes back at once as a card with a guess: the
|
|
4
|
+
* open initiative that shares the most words with it, and an open question when one shares at least two. The guess is a
|
|
5
|
+
* LOCAL WORD MATCH made in the workspace process (lib/live-notes.js, the `local` path): no model, no Jev, no request leaves
|
|
6
|
+
* the machine, and a guess is never shown above "possible". The PM confirms it, picks another relation or initiative, or
|
|
7
|
+
* rejects it, one card at a time. Waiting cards are on top; decided cards drop below. Closing the session files a section in
|
|
8
|
+
* the day's requirements note and deletes the raw transcript.
|
|
9
|
+
*
|
|
10
|
+
* Reads and writes, all on this repository's workspace:
|
|
11
|
+
* GET /api/live-notes?ws=… the sessions GET /api/live-notes?ws=…&session=… one session, ranked, + its shortlist
|
|
12
|
+
* POST /api/live-notes/session start or resume POST /api/live-notes/add typed points
|
|
13
|
+
* POST /api/live-notes/decide accept · change · reject POST /api/live-notes/close file the note
|
|
14
|
+
* Points an agent session adds through the command (Runs 1 to 5) appear here on the next read. In the fixture nothing is read.
|
|
15
|
+
*
|
|
16
|
+
* LISTENING. "Start listening" uses the browser's speech recognition with `processLocally = true`: the audio is recognised on this
|
|
17
|
+
* device (Chrome 139 and later, after a one-time language download) and never sent anywhere. A browser that cannot recognise on
|
|
18
|
+
* the device is told so; there is no fallback to cloud recognition. Interim words show live and are never added. A final phrase
|
|
19
|
+
* is added at a sentence end or after a pause, and the server cleans it (fillers, stumbles, a sentence end) before it becomes a
|
|
20
|
+
* point. "This meeting is private" stops listening and marks the session, so Run 7 never sends a point from it.
|
|
21
|
+
*
|
|
22
|
+
* JEV (Run 7, D85). "Check with Jev" is the per-repository switch, default off. When it, a saved key and consent for the repository
|
|
23
|
+
* all hold, the server hands each cleaned point to Jev with the shortlist, and the card says "from Jev". When a gate is shut the
|
|
24
|
+
* view says which, in words, and the local word match answers. A point Jev could not answer gets the local guess and says so.
|
|
25
|
+
* POST /api/live-notes/switch { ws, live }: the switch; the reply carries the gates again
|
|
26
|
+
*/
|
|
27
|
+
import { el } from './dom.js';
|
|
28
|
+
|
|
29
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
|
|
30
|
+
const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none;white-space:nowrap';
|
|
31
|
+
const CHIP = 'font:500 12px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--border);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
|
|
32
|
+
const CHIP_ON = 'font:500 12px var(--sans);background:var(--foreground);color:var(--background);border:1px solid var(--foreground);border-radius:999px;padding:0 10px;height:26px;cursor:pointer;white-space:nowrap';
|
|
33
|
+
const CARD = 'border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:8px';
|
|
34
|
+
const SELECT = 'font:13px var(--sans);background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;height:30px;padding:0 8px;max-width:100%';
|
|
35
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
|
|
36
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
37
|
+
|
|
38
|
+
/** The relations the PM can pick, in words. `none` is the initiative picker's "none of these", not a chip. */
|
|
39
|
+
export const RELATION_CHIPS = [
|
|
40
|
+
{ key: 'validates', label: 'validates' }, { key: 'clarifies', label: 'clarifies' }, { key: 'extends', label: 'extends' },
|
|
41
|
+
{ key: 'contradicts', label: 'contradicts' }, { key: 'answers', label: 'answers a question' },
|
|
42
|
+
];
|
|
43
|
+
const REFRESH_MS = 5000;
|
|
44
|
+
|
|
45
|
+
/* ── module state: survives the redraw every setState causes ─────────────────────────────────── */
|
|
46
|
+
const reads = new Map(); // key -> { state, data, error, at }
|
|
47
|
+
const drafts = new Map(); // segment -> { initiative, relation, question } the PM's choice before Confirm
|
|
48
|
+
const errors = new Map(); // segment -> the server's refusal, in words
|
|
49
|
+
const sessionOf = new Map(); // ws -> the session shown
|
|
50
|
+
let withheldNow = []; // the last add's withheld points: { id, reason } — the text is never kept
|
|
51
|
+
let banner = null; // { tone, text }
|
|
52
|
+
let anchorAsk = null; // { ws, anchors: [{anchor, open}], error }
|
|
53
|
+
let busy = false;
|
|
54
|
+
let switching = false;
|
|
55
|
+
let input = null; // the textarea, kept across redraws so typing is not lost
|
|
56
|
+
let dataMod = null; let dataTried = false;
|
|
57
|
+
let listen = null; // { ws, session, state: 'checking'|'on'|'needs-install'|'installing'|'unsupported'|'error', rec, interim, buffer, timer, lang, msg }
|
|
58
|
+
const PAUSE_MS = 1500; // a pause this long ends a point
|
|
59
|
+
|
|
60
|
+
function read(ctx, key, url, ttl = REFRESH_MS) {
|
|
61
|
+
const hit = reads.get(key);
|
|
62
|
+
if (hit && (hit.state === 'loading' || Date.now() - hit.at < ttl)) return hit;
|
|
63
|
+
const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, error: hit ? hit.error : null, at: Date.now() };
|
|
64
|
+
reads.set(key, next);
|
|
65
|
+
fetch(url).then((r) => r.json()).then(
|
|
66
|
+
(d) => {
|
|
67
|
+
const same = hit && JSON.stringify(hit.data) === JSON.stringify(d);
|
|
68
|
+
reads.set(key, { state: d && d.ok === false ? 'error' : 'ok', data: d, error: d && d.error, at: Date.now() });
|
|
69
|
+
if (!same) ctx.setState({}); // redraw only when something changed, so a refresh does not disturb typing
|
|
70
|
+
},
|
|
71
|
+
(e) => { reads.set(key, { state: 'error', data: null, error: (e && e.message) || String(e), at: Date.now() }); ctx.setState({}); },
|
|
72
|
+
);
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
async function post(url, body) {
|
|
76
|
+
try { return await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json()); }
|
|
77
|
+
catch (e) { return { ok: false, error: (e && e.message) || 'the workspace server did not answer' }; }
|
|
78
|
+
}
|
|
79
|
+
const forget = (ws, session) => { reads.delete('sessions:' + ws); if (session) reads.delete('session:' + ws + ':' + session); };
|
|
80
|
+
|
|
81
|
+
async function setJev(ctx, ws, on) {
|
|
82
|
+
if (switching) return;
|
|
83
|
+
switching = true; ctx.setState({});
|
|
84
|
+
const r = await post('/api/live-notes/switch', { ws, live: on });
|
|
85
|
+
switching = false;
|
|
86
|
+
if (!r || !r.ok) banner = { tone: 'bad', text: (r && r.error) || 'the switch did not change' };
|
|
87
|
+
else banner = null;
|
|
88
|
+
forget(ws, sessionOf.get(ws));
|
|
89
|
+
ctx.setState({});
|
|
90
|
+
}
|
|
91
|
+
const FLAG_WORDS = {
|
|
92
|
+
'jev-not-reached': 'Jev was not reached, so this is the local word match.',
|
|
93
|
+
'jev-unreadable': 'Jev\'s answer could not be read, so this is the local word match.',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Every point of a ranked session, flat: waiting first (newest on top), then decided (newest on top). */
|
|
97
|
+
export function feedOf(ranked) {
|
|
98
|
+
const all = [];
|
|
99
|
+
for (const sec of (ranked && ranked.sections) || []) for (const x of sec.items) all.push(x);
|
|
100
|
+
const newest = (a, b) => String(b.at || '').localeCompare(String(a.at || '')) || String(b.segment).localeCompare(String(a.segment));
|
|
101
|
+
return { waiting: all.filter((x) => !x.decision).sort(newest), decided: all.filter((x) => x.decision).sort(newest) };
|
|
102
|
+
}
|
|
103
|
+
/** The counter's numbers. */
|
|
104
|
+
export function countsOf(ranked) {
|
|
105
|
+
const f = feedOf(ranked);
|
|
106
|
+
const by = (a) => f.decided.filter((x) => x.decision.action === a).length;
|
|
107
|
+
return { confirmed: by('accept'), changed: by('change'), rejected: by('reject'), waiting: f.waiting.length, withheld: (ranked && ranked.withheld) || 0 };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ── actions ──────────────────────────────────────────────────────────────────────────────────── */
|
|
111
|
+
async function startSession(ctx, ws, anchor) {
|
|
112
|
+
busy = true; banner = null; ctx.setState({});
|
|
113
|
+
const r = await post('/api/live-notes/session', { ws, anchor: anchor || null });
|
|
114
|
+
busy = false;
|
|
115
|
+
if (r && r.ok) { sessionOf.set(ws, r.session); anchorAsk = null; withheldNow = []; forget(ws, r.session); }
|
|
116
|
+
else if (r && Array.isArray(r.anchors)) anchorAsk = { ws, anchors: r.anchors, error: r.error };
|
|
117
|
+
else banner = { tone: 'bad', text: (r && r.error) || 'the session did not start' };
|
|
118
|
+
ctx.setState({});
|
|
119
|
+
}
|
|
120
|
+
async function addPoints(ctx, ws, session) {
|
|
121
|
+
const text = input ? input.value : '';
|
|
122
|
+
if (!text.trim() || busy) return;
|
|
123
|
+
busy = true; banner = null; ctx.setState({});
|
|
124
|
+
const r = await post('/api/live-notes/add', { ws, session, text });
|
|
125
|
+
busy = false;
|
|
126
|
+
if (r && r.ok) {
|
|
127
|
+
if (input) input.value = '';
|
|
128
|
+
withheldNow = r.withheld || [];
|
|
129
|
+
reads.set('session:' + ws + ':' + session, { state: 'ok', data: { ok: true, session, ranked: r.ranked, shortlist: (reads.get('session:' + ws + ':' + session) || {}).data?.shortlist || [] }, at: 0 });
|
|
130
|
+
forget(ws, null);
|
|
131
|
+
} else banner = { tone: 'bad', text: (r && r.error) || 'nothing was added' };
|
|
132
|
+
ctx.setState({});
|
|
133
|
+
}
|
|
134
|
+
async function decide(ctx, ws, session, item, action) {
|
|
135
|
+
const d = drafts.get(item.segment) || {};
|
|
136
|
+
const initiative = d.initiative || item.initiative;
|
|
137
|
+
const relation = initiative === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
|
|
138
|
+
const question = relation === 'answers' ? (d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === initiative ? item.answers_question.n : null)) : null;
|
|
139
|
+
if (action !== 'reject' && initiative !== 'none' && !relation) { errors.set(item.segment, 'Pick how this point relates to ' + initiative + ' first.'); ctx.setState({}); return; }
|
|
140
|
+
if (action !== 'reject' && relation === 'answers' && question == null) { errors.set(item.segment, '"Answers" needs the open question it answers. Pick one.'); ctx.setState({}); return; }
|
|
141
|
+
const same = initiative === item.initiative && relation === item.relation && (relation !== 'answers' || (item.answers_question && item.answers_question.n === question));
|
|
142
|
+
const body = { ws, session, segment: item.segment, action: action === 'reject' ? 'reject' : same ? 'accept' : 'change' };
|
|
143
|
+
if (body.action === 'change') Object.assign(body, { initiative, relation: relation || null, question });
|
|
144
|
+
const r = await post('/api/live-notes/decide', body);
|
|
145
|
+
if (r && r.ok) { errors.delete(item.segment); drafts.delete(item.segment); forget(ws, session); }
|
|
146
|
+
else errors.set(item.segment, (r && r.error) || 'not recorded');
|
|
147
|
+
ctx.setState({});
|
|
148
|
+
}
|
|
149
|
+
/* ── listening: on-device speech recognition only ─────────────────────────────────────────────── */
|
|
150
|
+
const SRClass = () => (typeof window !== 'undefined' ? window.SpeechRecognition || window.webkitSpeechRecognition : null);
|
|
151
|
+
function stopListening() {
|
|
152
|
+
if (!listen) return;
|
|
153
|
+
const l = listen; listen = null;
|
|
154
|
+
clearTimeout(l.timer);
|
|
155
|
+
if (l.rec) { l.rec.onend = null; try { l.rec.stop(); } catch { /* already stopped */ } }
|
|
156
|
+
}
|
|
157
|
+
async function flush(ctx, l) {
|
|
158
|
+
clearTimeout(l.timer); l.timer = null;
|
|
159
|
+
const text = l.buffer.join(' ').trim(); l.buffer = [];
|
|
160
|
+
if (!text) return;
|
|
161
|
+
// one point per sentence; the server cleans each line before it becomes a point
|
|
162
|
+
const lines = text.split(/(?<=[.?!])\s+/).join('\n');
|
|
163
|
+
const r = await post('/api/live-notes/add', { ws: l.ws, session: l.session, text: lines, source: 'speech' });
|
|
164
|
+
if (r && r.ok) { withheldNow = (r.withheld || []).concat(withheldNow).slice(0, 5); reads.delete('session:' + l.ws + ':' + l.session); forget(l.ws, null); }
|
|
165
|
+
else if (r && r.error && !/type a talking point/.test(r.error)) banner = { tone: 'bad', text: 'A spoken point was not added: ' + r.error };
|
|
166
|
+
ctx.setState({});
|
|
167
|
+
}
|
|
168
|
+
async function startListening(ctx, ws, session, install = false) {
|
|
169
|
+
stopListening();
|
|
170
|
+
const C = SRClass();
|
|
171
|
+
const lang = (typeof navigator !== 'undefined' && navigator.language) || 'en-US';
|
|
172
|
+
listen = { ws, session, state: 'checking', rec: null, interim: '', buffer: [], timer: null, lang, msg: null };
|
|
173
|
+
ctx.setState({});
|
|
174
|
+
const l = listen;
|
|
175
|
+
if (!C || typeof C.available !== 'function') {
|
|
176
|
+
l.state = 'unsupported';
|
|
177
|
+
l.msg = 'This browser cannot recognise speech on this device, and live notes never send audio away. Chrome 139 or later can; until then, type the points.';
|
|
178
|
+
ctx.setState({}); return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
if (install) { l.state = 'installing'; ctx.setState({}); await C.install({ langs: [lang], processLocally: true }); }
|
|
182
|
+
const a = await C.available({ langs: [lang], processLocally: true });
|
|
183
|
+
if (listen !== l) return;
|
|
184
|
+
if (a === 'downloadable' || a === 'downloading') { l.state = 'needs-install'; l.msg = 'Recognising ' + lang + ' on this device needs a one-time download by the browser.'; ctx.setState({}); return; }
|
|
185
|
+
if (a !== 'available') { l.state = 'unsupported'; l.msg = 'This browser cannot recognise ' + lang + ' on this device, and live notes never send audio away. Type the points instead.'; ctx.setState({}); return; }
|
|
186
|
+
} catch (e) { l.state = 'error'; l.msg = 'Speech recognition did not start: ' + ((e && e.message) || e); ctx.setState({}); return; }
|
|
187
|
+
const rec = new C();
|
|
188
|
+
rec.lang = lang; rec.continuous = true; rec.interimResults = true; rec.processLocally = true;
|
|
189
|
+
rec.onresult = (e) => {
|
|
190
|
+
if (listen !== l) return; // a result that arrives after Stop or Private is dropped, never added
|
|
191
|
+
let interim = ''; let ended = false;
|
|
192
|
+
for (let i = e.resultIndex; i < e.results.length; i++) {
|
|
193
|
+
const res = e.results[i]; const t = String((res[0] && res[0].transcript) || '').trim();
|
|
194
|
+
if (!t) continue;
|
|
195
|
+
if (res.isFinal) { l.buffer.push(t); if (/[.?!]$/.test(t)) ended = true; } else interim += (interim ? ' ' : '') + t;
|
|
196
|
+
}
|
|
197
|
+
l.interim = interim;
|
|
198
|
+
clearTimeout(l.timer);
|
|
199
|
+
if (ended) flush(ctx, l); else if (l.buffer.length) l.timer = setTimeout(() => flush(ctx, l), PAUSE_MS);
|
|
200
|
+
const live = typeof document !== 'undefined' && document.querySelector('[data-ln-interim]');
|
|
201
|
+
if (live) live.textContent = interim; else ctx.setState({});
|
|
202
|
+
};
|
|
203
|
+
rec.onerror = (e) => {
|
|
204
|
+
if (e && (e.error === 'no-speech' || e.error === 'aborted')) return;
|
|
205
|
+
l.state = 'error'; l.msg = 'Listening stopped: ' + ((e && (e.message || e.error)) || 'the browser gave no reason') + '.'; ctx.setState({});
|
|
206
|
+
};
|
|
207
|
+
// the browser ends a recognition after a silence; while the PM has not pressed Stop, it starts again
|
|
208
|
+
rec.onend = () => { if (listen === l && l.state === 'on') { try { rec.start(); } catch { /* it is starting */ } } };
|
|
209
|
+
l.rec = rec;
|
|
210
|
+
try { rec.start(); l.state = 'on'; } catch (e) { l.state = 'error'; l.msg = 'Listening did not start: ' + ((e && e.message) || e); }
|
|
211
|
+
ctx.setState({});
|
|
212
|
+
}
|
|
213
|
+
async function markPrivate(ctx, ws, session) {
|
|
214
|
+
const l = listen;
|
|
215
|
+
stopListening();
|
|
216
|
+
if (l) l.buffer = [];
|
|
217
|
+
const r = await post('/api/live-notes/private', { ws, session });
|
|
218
|
+
banner = r && r.ok ? { tone: 'ok', text: 'Marked private. Listening stopped, and nothing from this session will be sent to any service.' } : { tone: 'bad', text: (r && r.error) || 'not marked' };
|
|
219
|
+
forget(ws, session);
|
|
220
|
+
ctx.setState({});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function closeSession(ctx, ws, session) {
|
|
224
|
+
if (typeof window !== 'undefined' && window.confirm && !window.confirm('End this session? The points are filed in today\'s requirements note and the raw transcript is deleted.')) return;
|
|
225
|
+
if (listen && listen.session === session) { const l = listen; stopListening(); await flush(ctx, l); }
|
|
226
|
+
const r = await post('/api/live-notes/close', { ws, session });
|
|
227
|
+
banner = r && r.ok ? { tone: 'ok', text: r.already ? 'This session was already closed.' : 'Filed in ' + r.note + '. Raw transcript: ' + r.transcript + '.' } : { tone: 'bad', text: (r && r.error) || 'the session did not close' };
|
|
228
|
+
forget(ws, session);
|
|
229
|
+
ctx.setState({});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* ── drawing ──────────────────────────────────────────────────────────────────────────────────── */
|
|
233
|
+
const titleOf = (list, id) => { const i = (list || []).find((x) => x.id === id); return i ? i.title : id; };
|
|
234
|
+
|
|
235
|
+
function card(ctx, ws, session, item, shortlist, closed) {
|
|
236
|
+
const decided = item.decision;
|
|
237
|
+
const parts = [el('div', { 'data-ln-quote': '', style: 'font-size:14px;line-height:1.5' }, '"' + item.quote + '"')];
|
|
238
|
+
const guess = item.initiative && item.initiative !== 'none'
|
|
239
|
+
? 'Guess: about ' + titleOf(shortlist, item.initiative) + ' (' + (item.initiative_band || 'possible') + ')' + (item.relation ? ', ' + item.relation : '')
|
|
240
|
+
: 'Guess: matches no open initiative';
|
|
241
|
+
const by = item.path === 'local' ? 'local word match' : item.path === 'jev' || item.path === 'jev-agent' ? 'Jev' : 'agent session';
|
|
242
|
+
parts.push(el('div', { 'data-ln-guess': '', style: NOTE }, guess + ' · from ' + by));
|
|
243
|
+
if (item.answers_question) parts.push(el('div', { 'data-ln-question': '', style: NOTE }, 'May answer open question ' + item.answers_question.n + ': ' + item.answers_question.text));
|
|
244
|
+
for (const fl of item.flags || []) if (FLAG_WORDS[fl]) parts.push(el('div', { 'data-ln-flag': fl, style: NOTE }, FLAG_WORDS[fl]));
|
|
245
|
+
if (decided) {
|
|
246
|
+
const d = decided;
|
|
247
|
+
const said = d.action === 'reject' ? 'You rejected this.' : d.action === 'change' && d.now
|
|
248
|
+
? 'You changed it: ' + (d.now.initiative === 'none' ? 'matches no initiative' : d.now.relation + ' ' + titleOf(shortlist, d.now.initiative)) + '.'
|
|
249
|
+
: 'You confirmed it.';
|
|
250
|
+
parts.push(el('div', { 'data-ln-decision': d.action, style: 'font:500 12.5px var(--sans);color:var(--muted-foreground)' }, said));
|
|
251
|
+
return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'decided', style: CARD + ';opacity:.72' }, parts);
|
|
252
|
+
}
|
|
253
|
+
if (closed) return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
|
|
254
|
+
|
|
255
|
+
const d = drafts.get(item.segment) || {};
|
|
256
|
+
const ini = d.initiative || item.initiative || 'none';
|
|
257
|
+
const rel = ini === 'none' ? null : (d.relation !== undefined ? d.relation : item.relation);
|
|
258
|
+
const setDraft = (patch) => { drafts.set(item.segment, { ...d, ...patch }); errors.delete(item.segment); ctx.setState({}); };
|
|
259
|
+
const options = [el('option', { value: 'none', selected: ini === 'none' }, 'none of these initiatives')]
|
|
260
|
+
.concat((shortlist || []).map((i) => el('option', { value: i.id, selected: ini === i.id }, i.title + ' (' + i.id + ')')));
|
|
261
|
+
parts.push(el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
|
|
262
|
+
el('span', { style: LBL }, 'initiative'),
|
|
263
|
+
el('select', { 'data-ln-ini': '', style: SELECT, onChange: (e) => setDraft({ initiative: e.target.value, question: null }) }, options)));
|
|
264
|
+
if (ini !== 'none') {
|
|
265
|
+
parts.push(el('div', { 'data-ln-rels': '', style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
|
|
266
|
+
el('span', { style: LBL }, 'relation'),
|
|
267
|
+
RELATION_CHIPS.map((c) => el('button', { 'data-ln-rel': c.key, 'aria-pressed': rel === c.key ? 'true' : 'false', style: rel === c.key ? CHIP_ON : CHIP,
|
|
268
|
+
onClick: () => setDraft({ relation: c.key }) }, c.label))));
|
|
269
|
+
if (rel === 'answers') {
|
|
270
|
+
const qs = ((shortlist || []).find((i) => i.id === ini) || {}).questions || [];
|
|
271
|
+
const cur = d.question != null ? d.question : (item.answers_question && item.answers_question.initiative === ini ? item.answers_question.n : null);
|
|
272
|
+
parts.push(qs.length
|
|
273
|
+
? el('select', { 'data-ln-q': '', style: SELECT, onChange: (e) => setDraft({ question: e.target.value ? Number(e.target.value) : null }) },
|
|
274
|
+
[el('option', { value: '', selected: cur == null }, 'which open question?')].concat(qs.map((q, n) => el('option', { value: String(n + 1), selected: cur === n + 1 }, (n + 1) + '. ' + q.slice(0, 120)))))
|
|
275
|
+
: el('div', { style: NOTE }, titleOf(shortlist, ini) + ' has no open questions, so this point cannot answer one.'));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (errors.get(item.segment)) parts.push(el('div', { 'data-ln-error': '', role: 'alert', style: 'color:var(--destructive);font-size:12.5px' }, errors.get(item.segment)));
|
|
279
|
+
parts.push(el('div', { style: 'display:flex;gap:8px' },
|
|
280
|
+
el('button', { 'data-ln-action': 'confirm', style: BTNP, onClick: () => decide(ctx, ws, session, item, 'confirm') }, 'Confirm'),
|
|
281
|
+
el('button', { 'data-ln-action': 'reject', style: BTN, hover: 'background:var(--accent)', onClick: () => decide(ctx, ws, session, item, 'reject') }, 'Reject')));
|
|
282
|
+
return el('div', { 'data-ln-card': item.segment, 'data-ln-state': 'waiting', style: CARD }, parts);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function renderBoard(inner, ctx) {
|
|
286
|
+
const { FIXTURE } = ctx;
|
|
287
|
+
const repo = (ctx.tab && ctx.tab.repo) || ctx.repo || null;
|
|
288
|
+
let ws = null;
|
|
289
|
+
if (!FIXTURE) {
|
|
290
|
+
if (!dataTried) { dataTried = true; import('./data.js').then((m) => { dataMod = m; ctx.setState({}); }).catch(() => {}); }
|
|
291
|
+
if (dataMod && dataMod.wsOf) ws = dataMod.wsOf(repo);
|
|
292
|
+
if (!ws && ctx.state && ctx.state.product === repo) ws = ctx.state.wsId || null;
|
|
293
|
+
}
|
|
294
|
+
const typing = typeof document !== 'undefined' && input && document.activeElement === input;
|
|
295
|
+
const caret = typing ? [input.selectionStart, input.selectionEnd] : null;
|
|
296
|
+
|
|
297
|
+
const sess = !FIXTURE && ws ? read(ctx, 'sessions:' + ws, '/api/live-notes?ws=' + encodeURIComponent(ws), 15000) : null;
|
|
298
|
+
const jev = (sess && sess.data && sess.data.jev) || null;
|
|
299
|
+
const jevOn = !!(jev && jev.open);
|
|
300
|
+
inner.appendChild(el('div', { 'data-ln-part': 'header' },
|
|
301
|
+
el('div', { style: 'font:600 22px var(--sans);line-height:1.45' }, 'Live notes'),
|
|
302
|
+
jevOn
|
|
303
|
+
? el('div', { 'data-ln-jev-on': '', style: NOTE },
|
|
304
|
+
'Listen or type what is being said. Each point is cleaned up and checked by Jev against your open initiatives; you confirm it, change it or reject it. '
|
|
305
|
+
+ jev.note + ' Nothing changes an initiative until you confirm.')
|
|
306
|
+
: el('div', { 'data-ln-local': '', style: NOTE },
|
|
307
|
+
'Type what is being said, one point per line. Each point gets a guess at once; you confirm it, change it or reject it. '
|
|
308
|
+
+ 'Guesses here are a local word match. Nothing leaves this machine, and nothing changes an initiative.')));
|
|
309
|
+
if (FIXTURE || !ws) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, FIXTURE ? 'The fixture reads no sessions.' : 'Select a repository to take notes against.')); return; }
|
|
310
|
+
|
|
311
|
+
const sessions = (sess.data && sess.data.sessions) || [];
|
|
312
|
+
let current = sessionOf.get(ws);
|
|
313
|
+
if (!current && sessions.length) { const open = sessions.filter((s) => !s.closed); current = (open[open.length - 1] || sessions[sessions.length - 1]).session; sessionOf.set(ws, current); }
|
|
314
|
+
|
|
315
|
+
const bar = [el('span', { style: LBL }, 'session')];
|
|
316
|
+
if (sessions.length) {
|
|
317
|
+
bar.push(el('select', { 'data-ln-session': '', style: SELECT, onChange: (e) => { stopListening(); sessionOf.set(ws, e.target.value); withheldNow = []; banner = null; ctx.setState({}); } },
|
|
318
|
+
sessions.slice().reverse().map((s) => el('option', { value: s.session, selected: s.session === current }, s.session + (s.closed ? ' · closed' : '') + ' · ' + s.points + ' points'))));
|
|
319
|
+
} else bar.push(el('span', { style: NOTE }, sess.state === 'loading' ? 'reading …' : 'no sessions yet'));
|
|
320
|
+
bar.push(el('button', { 'data-ln-new': '', style: BTN, hover: 'background:var(--accent)', disabled: busy, onClick: () => { stopListening(); startSession(ctx, ws, null); } }, 'New session'));
|
|
321
|
+
inner.appendChild(el('div', { 'data-ln-part': 'sessions', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, bar));
|
|
322
|
+
if (jev) {
|
|
323
|
+
const sw = !!jev.switch;
|
|
324
|
+
const why = jev.open ? 'Jev is checking new points.'
|
|
325
|
+
: sw ? 'Jev is not used yet. ' + (jev.sentence || '')
|
|
326
|
+
: 'Jev is off. Points get the local word match only.';
|
|
327
|
+
inner.appendChild(el('div', { 'data-ln-part': 'jev', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px' },
|
|
328
|
+
el('span', { style: LBL }, 'jev'),
|
|
329
|
+
el('button', { 'data-ln-jev': sw ? 'on' : 'off', 'aria-pressed': sw ? 'true' : 'false', disabled: switching, style: sw ? CHIP_ON : CHIP,
|
|
330
|
+
onClick: () => setJev(ctx, ws, !sw) }, sw ? 'Check with Jev: on' : 'Check with Jev: off'),
|
|
331
|
+
el('span', { 'data-ln-jev-state': jev.open ? 'open' : 'shut', style: NOTE }, why.trim())));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (anchorAsk && anchorAsk.ws === ws) {
|
|
335
|
+
let pick = anchorAsk.anchors[0] ? anchorAsk.anchors[0].anchor : '';
|
|
336
|
+
inner.appendChild(el('div', { 'data-ln-part': 'anchor', style: CARD + ';margin-top:10px' },
|
|
337
|
+
el('div', { style: NOTE }, anchorAsk.error),
|
|
338
|
+
el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap' },
|
|
339
|
+
el('select', { 'data-ln-anchor': '', style: SELECT, onChange: (e) => { pick = e.target.value; } },
|
|
340
|
+
anchorAsk.anchors.map((a) => el('option', { value: a.anchor }, a.anchor + ' · ' + a.open + ' open'))),
|
|
341
|
+
el('button', { 'data-ln-start': '', style: BTNP, onClick: () => startSession(ctx, ws, pick) }, 'Start with this anchor'))));
|
|
342
|
+
}
|
|
343
|
+
if (banner) inner.appendChild(el('div', { 'data-ln-banner': banner.tone, role: banner.tone === 'bad' ? 'alert' : 'status', style: 'margin-top:10px;font-size:13px;color:' + (banner.tone === 'bad' ? 'var(--destructive)' : 'var(--foreground)') }, banner.text));
|
|
344
|
+
if (!current) return;
|
|
345
|
+
|
|
346
|
+
const one = read(ctx, 'session:' + ws + ':' + current, '/api/live-notes?ws=' + encodeURIComponent(ws) + '&session=' + encodeURIComponent(current));
|
|
347
|
+
if (!one.data || !one.data.ranked) { inner.appendChild(el('div', { style: NOTE + ';margin-top:12px' }, one.state === 'error' ? 'Could not read this session: ' + one.error : 'reading the session …')); return; }
|
|
348
|
+
const ranked = one.data.ranked; const shortlist = one.data.shortlist || [];
|
|
349
|
+
const closed = !!ranked.closed;
|
|
350
|
+
const c = countsOf(ranked);
|
|
351
|
+
inner.appendChild(el('div', { 'data-ln-counter': '', style: 'display:flex;gap:14px;flex-wrap:wrap;margin-top:12px;font:500 12.5px var(--sans)' },
|
|
352
|
+
[['confirmed', c.confirmed], ['changed', c.changed], ['rejected', c.rejected], ['waiting', c.waiting], ['withheld', c.withheld]]
|
|
353
|
+
.map(([k, n]) => el('span', { 'data-ln-count': k }, n + ' ' + k))));
|
|
354
|
+
if (ranked.paused) inner.appendChild(el('div', { 'data-ln-paused': '', role: 'status', style: NOTE }, ranked.paused.reason));
|
|
355
|
+
if (ranked.private && jevOn) inner.appendChild(el('div', { 'data-ln-private-jev': '', style: NOTE }, 'Nothing from this meeting goes to Jev, because it is marked private.'));
|
|
356
|
+
|
|
357
|
+
if (!closed) {
|
|
358
|
+
// listening: on-device recognition, a visible "notes are being taken", Stop and Private
|
|
359
|
+
const mine = listen && listen.ws === ws && listen.session === current ? listen : null;
|
|
360
|
+
const lp = [];
|
|
361
|
+
if (ranked.private) lp.push(el('span', { 'data-ln-private': '', style: NOTE }, 'This meeting is marked private: listening is off for it.'));
|
|
362
|
+
else if (mine && mine.state === 'on') {
|
|
363
|
+
lp.push(el('span', { 'data-ln-listening': 'on', role: 'status', style: 'font:600 12.5px var(--sans);color:var(--destructive)' }, '● Listening: notes are being taken'));
|
|
364
|
+
lp.push(el('button', { 'data-ln-stop': '', style: BTN, hover: 'background:var(--accent)', onClick: () => { const l = listen; stopListening(); if (l) flush(ctx, l); ctx.setState({}); } }, 'Stop'));
|
|
365
|
+
lp.push(el('button', { 'data-ln-mark-private': '', style: BTN, hover: 'background:var(--accent)', onClick: () => markPrivate(ctx, ws, current) }, 'This meeting is private'));
|
|
366
|
+
} else {
|
|
367
|
+
lp.push(el('button', { 'data-ln-listen': '', style: BTNP, disabled: !!(mine && (mine.state === 'checking' || mine.state === 'installing')), onClick: () => startListening(ctx, ws, current) },
|
|
368
|
+
mine && mine.state === 'checking' ? 'Starting …' : mine && mine.state === 'installing' ? 'Downloading …' : 'Start listening'));
|
|
369
|
+
if (mine && mine.state === 'needs-install') lp.push(el('button', { 'data-ln-install': '', style: BTN, onClick: () => startListening(ctx, ws, current, true) }, 'Download for ' + mine.lang));
|
|
370
|
+
lp.push(el('span', { style: NOTE }, 'Speech is recognised on this device; no audio leaves it.'));
|
|
371
|
+
}
|
|
372
|
+
inner.appendChild(el('div', { 'data-ln-part': 'listen', style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:12px' }, lp));
|
|
373
|
+
if (mine && mine.msg && mine.state !== 'on') inner.appendChild(el('div', { 'data-ln-listen-msg': mine.state, style: NOTE }, mine.msg));
|
|
374
|
+
if (mine && mine.state === 'on') inner.appendChild(el('div', { 'data-ln-interim': '', style: 'min-height:20px;font-size:13px;font-style:italic;color:var(--muted-foreground)' }, mine.interim || ''));
|
|
375
|
+
if (!input) {
|
|
376
|
+
input = el('textarea', { 'data-ln-input': '', rows: '3', placeholder: 'One talking point per line. Enter adds; Shift+Enter starts a new line.',
|
|
377
|
+
style: 'font:14px var(--sans);width:100%;box-sizing:border-box;background:var(--background);color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:8px 10px;resize:vertical' });
|
|
378
|
+
}
|
|
379
|
+
input.onkeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addPoints(ctx, ws, current); } };
|
|
380
|
+
inner.appendChild(el('div', { 'data-ln-part': 'input', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' }, input,
|
|
381
|
+
el('div', { style: 'display:flex;gap:8px;align-items:center' },
|
|
382
|
+
el('button', { 'data-ln-add': '', style: BTNP, disabled: busy, onClick: () => addPoints(ctx, ws, current) }, busy ? 'Adding …' : 'Add'),
|
|
383
|
+
el('span', { style: 'flex:1' }),
|
|
384
|
+
el('button', { 'data-ln-close': '', style: BTN, hover: 'background:var(--accent)', onClick: () => closeSession(ctx, ws, current) }, 'End and file the note'))));
|
|
385
|
+
if (caret) requestAnimationFrame(() => { try { input.focus(); input.setSelectionRange(caret[0], caret[1]); } catch { /* the textarea is gone */ } });
|
|
386
|
+
} else inner.appendChild(el('div', { 'data-ln-closed': '', style: NOTE + ';margin-top:12px' }, 'This session is closed. Its points are filed in the requirements note; start a new session to take more notes.'));
|
|
387
|
+
|
|
388
|
+
for (const w of withheldNow) inner.appendChild(el('div', { 'data-ln-withheld': w.id, style: CARD + ';margin-top:8px;color:var(--muted-foreground)' }, 'A point was withheld and not kept: it ' + w.reason + '.'));
|
|
389
|
+
|
|
390
|
+
const f = feedOf(ranked);
|
|
391
|
+
const list = el('div', { 'data-ln-part': 'feed', style: 'display:flex;flex-direction:column;gap:8px;margin-top:12px' });
|
|
392
|
+
if (!f.waiting.length && !f.decided.length) list.appendChild(el('div', { style: NOTE }, 'No points yet.'));
|
|
393
|
+
for (const x of f.waiting) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
|
|
394
|
+
if (f.decided.length) list.appendChild(el('div', { style: LBL + ';margin-top:8px' }, 'decided'));
|
|
395
|
+
for (const x of f.decided) list.appendChild(card(ctx, ws, current, x, shortlist, closed));
|
|
396
|
+
inner.appendChild(list);
|
|
397
|
+
}
|
package/workspace/server.js
CHANGED
|
@@ -76,6 +76,7 @@ const jevClaims = require('./lib/jev-claims.js'); // D80 — the bridge betwee
|
|
|
76
76
|
const jevCategorize = require('./lib/jev-categorize.js'); // D82 — the bridge between the document categorizer and the one TypeSafe module
|
|
77
77
|
const validateLib = require('./lib/validate.js');
|
|
78
78
|
const liveNotes = require('./lib/live-notes.js'); // jev-live-notes — reads and appends a repository's live-notes ledger; no network
|
|
79
|
+
const jevLive = require('./lib/jev-live.js'); // jev-live-notes Run 7 (D85) — the live view's one bridge to Jev, behind three gates
|
|
79
80
|
const claimsLib = require('./lib/claims.js');
|
|
80
81
|
const docCategory = require('./lib/doc-category.js'); // jev-doc-categorizer Run 1 — the PM-facing kind of a document, derived, local, no model
|
|
81
82
|
const docCatCorrections = require('./lib/doc-category-corrections.js'); // jev-doc-categorizer Run 3 — the PM's own category, per user, outside the repository
|
|
@@ -291,6 +292,10 @@ function sessionEnv() {
|
|
|
291
292
|
// print its environment, so the key is never handed to one, whether it came from settings or the shell.
|
|
292
293
|
delete env[tsSettings.ENV_NAME];
|
|
293
294
|
if (SET_GIT_OPTIONAL_LOCKS) delete env.GIT_OPTIONAL_LOCKS;
|
|
295
|
+
// prompt-model-routing Run 3 — where the cockpit mod asks for a prompt's route: this server, on loopback.
|
|
296
|
+
// An address only; the Jev key stays here (D79).
|
|
297
|
+
const routeUrl = jevRoute.routeUrl(PORT);
|
|
298
|
+
if (routeUrl) env.PMOS_ROUTE_URL = routeUrl; else delete env.PMOS_ROUTE_URL;
|
|
294
299
|
return env;
|
|
295
300
|
}
|
|
296
301
|
|
|
@@ -470,6 +475,12 @@ function homeWorkspace() {
|
|
|
470
475
|
return HOME_ENABLED ? workspaces.get(wsId(SELF_ROOT)) : [...workspaces.values()][0];
|
|
471
476
|
}
|
|
472
477
|
function getWs(id) { return (id && workspaces.get(id)) || homeWorkspace(); }
|
|
478
|
+
/** The registered workspace whose root is this path exactly, or null. Never the home workspace by default. */
|
|
479
|
+
function wsByRoot(root) {
|
|
480
|
+
const want = path.resolve(String(root));
|
|
481
|
+
for (const w of workspaces.values()) if (w && path.resolve(w.root) === want) return w;
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
473
484
|
// R2/R3 — what the sidebar tree needs and cannot derive for itself: which REPOSITORY a registered
|
|
474
485
|
// working tree belongs to, and which branch it is on. `git worktree list` names the main worktree
|
|
475
486
|
// first from any of a repo's trees (git-info.worktreesAt), so index 0 is the group key: it is what
|
|
@@ -1608,8 +1619,8 @@ function annotateJev(root, node, cache, seen = new Set()) {
|
|
|
1608
1619
|
if (node.category === docCategory.UNDETERMINED && typeof node.path === 'string' && /\.md$/i.test(node.path)) {
|
|
1609
1620
|
const c = categoryInput(path.join(root, node.path));
|
|
1610
1621
|
const l = c ? jevCategorize.lookup(cache, { fm: c.fm, text: c.text }) : null;
|
|
1611
|
-
if (l && l.decided) { node.category = l.category; node.category_basis = 'jev'; node.category_why = l.sentence; if (l.ticket_kind) node.
|
|
1612
|
-
else if (l) node.category_why = l.sentence;
|
|
1622
|
+
if (l && l.decided) { node.category = l.category; node.category_basis = 'jev'; node.category_why = l.sentence; if (l.ticket_kind) node.category_kind = l.ticket_kind; }
|
|
1623
|
+
else if (l) { node.category_why = l.sentence; node.category_jev = 'unsure'; }
|
|
1613
1624
|
}
|
|
1614
1625
|
for (const k of Object.keys(node)) if (node[k] && typeof node[k] === 'object') annotateJev(root, node[k], cache, seen);
|
|
1615
1626
|
return node;
|
|
@@ -4703,6 +4714,18 @@ function jevGateInfo(w) {
|
|
|
4703
4714
|
const { cacheDir } = claimsPaths(w);
|
|
4704
4715
|
return { switch: f.jev, status_read: f.status_read, open: g.open, missing: g.missing, sentence: g.open ? null : jevClaims.gateSentence(g.missing), ...jevClaims.status(cacheDir) };
|
|
4705
4716
|
}
|
|
4717
|
+
// D85 — the live-notes add route's sender: lib/typesafe.js behind the key, consent and the switch, or none. The key goes to the engine
|
|
4718
|
+
// only when the sender does, and never into a response.
|
|
4719
|
+
function liveSender(w) {
|
|
4720
|
+
const key = tsSettings.getKey(STATE);
|
|
4721
|
+
const flags = { consent: tsSettings.getRepo(STATE, w.root).consent, live: liveNotes.getSwitch(STATE, w.root).live };
|
|
4722
|
+
const snd = jevLive.senderFor(key, flags, jevModelOpts(w.root));
|
|
4723
|
+
return { key: snd.ask ? key : null, flags, ask: snd.ask, askOpts: snd.askOpts };
|
|
4724
|
+
}
|
|
4725
|
+
// D85 — what the live-notes view needs to explain its Jev switch. Booleans and sentences only; the key never leaves the store.
|
|
4726
|
+
function liveJevInfo(w) {
|
|
4727
|
+
return jevLive.infoFor(tsSettings.status(STATE).present, { consent: tsSettings.getRepo(STATE, w.root).consent, live: liveNotes.getSwitch(STATE, w.root).live });
|
|
4728
|
+
}
|
|
4706
4729
|
// D82 — what the page needs to explain the categorize switch: which gates hold, and what the last pass did.
|
|
4707
4730
|
const doccatRunning = new Set();
|
|
4708
4731
|
const doccatAutoAt = new Map(); // root → time of the last automatic pass; at most one a minute
|
|
@@ -5555,11 +5578,13 @@ async function handleRequest(req, res) {
|
|
|
5555
5578
|
}
|
|
5556
5579
|
if (p === '/api/model/route' && req.method === 'POST') {
|
|
5557
5580
|
let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
5558
|
-
|
|
5581
|
+
// A terminal's cockpit mod names its repository by root; the page names a workspace by id.
|
|
5582
|
+
const w = typeof body.root === 'string' ? wsByRoot(body.root) : getWs(body.ws);
|
|
5583
|
+
if (!w) return json(res, { ok: false, error: 'no workspace for that repository' }, 400);
|
|
5559
5584
|
if (typeof body.prompt !== 'string' || !body.prompt.trim() || body.prompt.length > 60000) return json(res, { ok: false, error: 'prompt must be text of up to 60000 characters' }, 400);
|
|
5560
5585
|
const f = tsSettings.getRepo(STATE, w.root);
|
|
5561
5586
|
const out = await jevRoute.routePrompt({ root: w.root, prompt: body.prompt, ctx: { skill: typeof body.skill === 'string' ? body.skill : undefined, source: body.source, running: true },
|
|
5562
|
-
key: tsSettings.getKey(STATE), flags: { consent: f.consent, route: f.route, exclude: f.exclude } });
|
|
5587
|
+
key: tsSettings.getKey(STATE), flags: { consent: f.consent, route: f.route, exclude: f.exclude }, askOpts: body.interactive === true ? jevRoute.INTERACTIVE : {} });
|
|
5563
5588
|
if (!out.ok) return json(res, { ok: false, error: out.error }, 400);
|
|
5564
5589
|
if (out.shadow) return json(res, { ok: true, shadow: true, id: out.id }); // shadow: logged, nothing shown, nothing applied
|
|
5565
5590
|
return json(res, { ok: true, ws: w.id, id: out.id, tier: out.tier, model: out.model, effort: out.effort, stage: out.stage, confidence: out.confidence, why: out.why,
|
|
@@ -6566,12 +6591,14 @@ async function handleRequest(req, res) {
|
|
|
6566
6591
|
return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
|
|
6567
6592
|
}
|
|
6568
6593
|
/* ── live notes (jev-live-notes, Run 2): the ranked proposals of a conversation, and the PM's accept, change or reject.
|
|
6569
|
-
* Reads the repository's ledger; the
|
|
6594
|
+
* Reads the repository's ledger; the writes are ledger rows and, on close, the day's note. No request leaves this process.
|
|
6595
|
+
* The page is the cockpit's live-notes view (public/pm/view-livenotes.js, Run 6). ── */
|
|
6570
6596
|
if (p === '/api/live-notes' && req.method === 'GET') {
|
|
6571
6597
|
const wsq = url.searchParams.get('ws');
|
|
6572
6598
|
const w = wsq ? workspaces.get(wsq) : homeWorkspace(); // a named workspace the server does not know is refused, never replaced by the home one
|
|
6573
6599
|
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6574
|
-
const r = liveNotes.apiList({ root: w.root, session: url.searchParams.get('session') || null });
|
|
6600
|
+
const r = liveNotes.apiList({ root: w.root, session: url.searchParams.get('session') || null, exclude: tsSettings.getRepo(STATE, w.root).exclude });
|
|
6601
|
+
if (r.ok) r.jev = liveJevInfo(w);
|
|
6575
6602
|
return json(res, r, r.ok ? 200 : (r.status || 400));
|
|
6576
6603
|
}
|
|
6577
6604
|
if (p === '/api/live-notes/decide' && req.method === 'POST') {
|
|
@@ -6580,7 +6607,39 @@ async function handleRequest(req, res) {
|
|
|
6580
6607
|
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6581
6608
|
const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
|
|
6582
6609
|
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6583
|
-
const r = liveNotes.apiDecide({ root: w.root, runtimeDir: STATE, session: body.session, segment: body.segment, action: body.action, initiative: body.initiative || null, relation: body.relation || null, exclude: tsSettings.getRepo(STATE, w.root).exclude });
|
|
6610
|
+
const r = liveNotes.apiDecide({ root: w.root, runtimeDir: STATE, session: body.session, segment: body.segment, action: body.action, initiative: body.initiative || null, relation: body.relation || null, question: body.question == null ? null : body.question, exclude: tsSettings.getRepo(STATE, w.root).exclude });
|
|
6611
|
+
return json(res, r, r.ok ? 200 : (r.status || 400));
|
|
6612
|
+
}
|
|
6613
|
+
/* Run 7 (D85): the per-repository switch "live notes may use Jev". Default off; it is one of three gates, with the key and consent. */
|
|
6614
|
+
if (p === '/api/live-notes/switch' && req.method === 'POST') {
|
|
6615
|
+
let body = {};
|
|
6616
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6617
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6618
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
|
|
6619
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6620
|
+
if (typeof body.live !== 'boolean') return json(res, { ok: false, error: 'say live: true or live: false' }, 400);
|
|
6621
|
+
liveNotes.setSwitch(STATE, w.root, { live: body.live });
|
|
6622
|
+
return json(res, { ok: true, ws: w.id, jev: liveJevInfo(w) });
|
|
6623
|
+
}
|
|
6624
|
+
/* Run 6: the live-notes view's writes — start or resume a session, add points, close. A proposal is a local word match made in
|
|
6625
|
+
* this process, unless all three D85 gates hold: then `add` hands each cleaned point to Jev through lib/jev-live.js. */
|
|
6626
|
+
if ((p === '/api/live-notes/session' || p === '/api/live-notes/add' || p === '/api/live-notes/close' || p === '/api/live-notes/private') && req.method === 'POST') {
|
|
6627
|
+
let body = {};
|
|
6628
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
6629
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
6630
|
+
const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
|
|
6631
|
+
if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
|
|
6632
|
+
const exclude = tsSettings.getRepo(STATE, w.root).exclude;
|
|
6633
|
+
let r;
|
|
6634
|
+
if (p === '/api/live-notes/session') r = liveNotes.apiOpen({ root: w.root, runtimeDir: STATE, session: body.session || null, anchor: body.anchor || null, exclude });
|
|
6635
|
+
else if (p === '/api/live-notes/add') {
|
|
6636
|
+
const snd = liveSender(w);
|
|
6637
|
+
r = await liveNotes.apiAdd({ root: w.root, runtimeDir: STATE, session: body.session, text: body.text, source: body.source === 'speech' ? 'speech' : 'typed', exclude,
|
|
6638
|
+
key: snd.key, flags: snd.flags, ask: snd.ask, askOpts: snd.askOpts });
|
|
6639
|
+
}
|
|
6640
|
+
else if (p === '/api/live-notes/private') r = liveNotes.apiMarkPrivate({ root: w.root, session: body.session });
|
|
6641
|
+
else r = liveNotes.apiClose({ root: w.root, runtimeDir: STATE, session: body.session, retentionDays: liveNotes.getSwitch(STATE, w.root).retention_days, exclude });
|
|
6642
|
+
if (r.ok && p !== '/api/live-notes/close') { r.jev = liveJevInfo(w); if (r.note) r.note = r.jev.note; }
|
|
6584
6643
|
return json(res, r, r.ok ? 200 : (r.status || 400));
|
|
6585
6644
|
}
|
|
6586
6645
|
if (p === '/api/validate/plan' && req.method === 'POST') {
|