create-pmos 0.163.0 → 0.165.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 +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/guide/console-calendar.md +17 -0
- package/workspace/guide/settings.md +1 -1
- package/workspace/guide/validate-with-typesafe.md +14 -1
- package/workspace/lib/headless.js +1 -1
- package/workspace/lib/meeting-focus.js +292 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-meeting-focus.js +291 -0
- package/workspace/lib/typesafe-settings.js +48 -15
- package/workspace/lib/typesafe.js +73 -12
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +7 -1
- package/workspace/public/pm/c3-demo-focus.js +133 -0
- package/workspace/public/pm/c3-demo.js +3 -0
- package/workspace/public/pm/c3-focus.css +169 -0
- package/workspace/public/pm/c3-focus.js +587 -0
- package/workspace/public/pm/c3-settings.js +29 -1
- package/workspace/public/pm/c3-shell.js +1 -0
- package/workspace/public/pm/components.css +1 -0
- package/workspace/public/pm/shell-recording.js +18 -2
- package/workspace/replaced-tests.json +11 -0
- package/workspace/server.js +17 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/c3-meeting-focus.json +6 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-meeting-focus.js — the Meeting focus page's server side (initiative c3-meeting-focus; lib/route-modules.js).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/meeting-focus?event=<id>&date=YYYY-MM-DD[&ws=]
|
|
5
|
+
* → { ok, now_ms, event, brief, note, topics, current, runs, tail, captured, gist, chips, notes, asks, companion }
|
|
6
|
+
* The meeting (the calendar read for that day), its brief (lib/calendar-layers.js briefOf), the note linked to it, the last
|
|
7
|
+
* minutes of that note's transcript with who said them, the decisions, actions and questions so far, and the companion's state.
|
|
8
|
+
* An unknown meeting is a 404. The read also starts the companion when lib/meeting-focus.js shouldRun says it may: the note
|
|
9
|
+
* records, new lines arrived, 40 s passed, no run is under way, PMOS_FOCUS_AGENT is not off. The run is fire and forget.
|
|
10
|
+
* POST /api/meeting-focus/topic { event | note, date?, action: 'set'|'add'|'park', topic } page press; the PM's correction, appended to the state
|
|
11
|
+
* POST /api/meeting-focus/ask { event, note?, date?, question } page press; one headless read-only run with the PM's question
|
|
12
|
+
* GET /api/meeting-focus/ask?id= → { ok, id, state: working | answered | failed, error? } the answer itself comes back as a note in GET /api/meeting-focus
|
|
13
|
+
*
|
|
14
|
+
* The companion runs as origin `focus-companion:<key>` (lib/headless.js 'ask': Read, Glob and Grep only). It sends nothing to anyone and
|
|
15
|
+
* writes nothing: its answer is parsed here and appended to <runtimeDir>/focus/<note id or event id>.jsonl. Handlers are pure over
|
|
16
|
+
* (req, body, ctx): ctx = { runtimeDir, spawnSession, w, readEvents?, briefFor?, env?, now? }, so a test needs no server.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
const guard = require('./pm-guard.js');
|
|
22
|
+
const headless = require('./headless.js');
|
|
23
|
+
const notes = require('./notes.js');
|
|
24
|
+
const F = require('./meeting-focus.js');
|
|
25
|
+
|
|
26
|
+
const fail = (status, error, extra) => ({ status, body: { ok: false, error, ...(extra || {}) } });
|
|
27
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'focus');
|
|
28
|
+
const KEY_BAD = /[^A-Za-z0-9_@.-]/g;
|
|
29
|
+
const keyOf = (id) => String(id == null ? '' : id).replace(KEY_BAD, '_').slice(0, 80);
|
|
30
|
+
const baseId = (id) => String(id || '').split('@')[0];
|
|
31
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
32
|
+
const localDay = (ms) => { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
|
|
33
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
34
|
+
|
|
35
|
+
function readLines(file) {
|
|
36
|
+
let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const l of raw.split('\n')) { if (!l.trim()) continue; try { out.push(JSON.parse(l)); } catch { /* a torn line */ } }
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function append(runtimeDir, name, row) {
|
|
42
|
+
fs.mkdirSync(dirOf(runtimeDir), { recursive: true });
|
|
43
|
+
fs.appendFileSync(path.join(dirOf(runtimeDir), name), JSON.stringify(row) + '\n');
|
|
44
|
+
}
|
|
45
|
+
/** The state of a meeting: the lines of its note's file and of its event's file (a topic set before the note existed), in order. */
|
|
46
|
+
function linesOf(runtimeDir, keys) {
|
|
47
|
+
const all = [];
|
|
48
|
+
for (const k of [...new Set(keys.filter(Boolean))]) all.push(...readLines(path.join(dirOf(runtimeDir), k + '.jsonl')));
|
|
49
|
+
return all.map((l, i) => [l, i]).sort((a, b) => (Number(a[0].at) || 0) - (Number(b[0].at) || 0) || a[1] - b[1]).map((x) => x[0]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ── the meeting, its note and its brief ──────────────────────────────────────────────────────────────────────────── */
|
|
53
|
+
|
|
54
|
+
function findEvent(events, id, date, by) {
|
|
55
|
+
if (!id && by && by.at) { // a meeting without a stored id: its start and title name it
|
|
56
|
+
const hit = (events || []).filter((e) => e && e.id && e.at === String(by.at) && (!by.title || e.title === String(by.title)));
|
|
57
|
+
return hit[0] || null;
|
|
58
|
+
}
|
|
59
|
+
const rows = (events || []).filter((e) => e && e.id && (String(e.id) === id || baseId(e.id) === baseId(id)));
|
|
60
|
+
const onDay = rows.filter((e) => !date || String(e.at || '').slice(0, 10) === date);
|
|
61
|
+
return onDay.find((e) => String(e.id) === id) || onDay[0] || null;
|
|
62
|
+
}
|
|
63
|
+
function noteFor(runtimeDir, ev, noteId) {
|
|
64
|
+
if (noteId) { const r = notes.read(runtimeDir, String(noteId)); return r.ok ? r.note : null; }
|
|
65
|
+
const hit = notes.findByEvent(runtimeDir, { event_id: String(ev.id), start: ev.at, title: ev.title }) || notes.findByEvent(runtimeDir, { event_id: baseId(ev.id), start: ev.at, title: ev.title });
|
|
66
|
+
if (!hit) return null;
|
|
67
|
+
const r = notes.read(runtimeDir, hit.id);
|
|
68
|
+
return r.ok ? r.note : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const briefCache = new Map(); // runtimeDir|event|date → { at, brief }
|
|
72
|
+
const BRIEF_MS = 60000;
|
|
73
|
+
/** The brief of one meeting, as the Calendar card reads it (briefOf). Kept for a minute: the page asks every few seconds. */
|
|
74
|
+
async function realBrief(ctx, ev, date) {
|
|
75
|
+
const key = ctx.runtimeDir + '|' + ev.id + '|' + date;
|
|
76
|
+
const hit = briefCache.get(key);
|
|
77
|
+
if (hit && Date.now() - hit.at < BRIEF_MS) return hit.brief;
|
|
78
|
+
let brief = null;
|
|
79
|
+
try {
|
|
80
|
+
const root = ctx.w && ctx.w.root ? path.resolve(ctx.w.root) : null;
|
|
81
|
+
const layers = require('./calendar-layers.js');
|
|
82
|
+
const views = require('./routes-calendar-views.js');
|
|
83
|
+
let inis = [];
|
|
84
|
+
if (root) { try { const facts = await require('./facts-async.js').readFactsAsync(root); inis = ((facts && facts.inis) || []).filter((i) => i && i.id).map((i) => ({ id: i.id, title: i.title || i.id, status: null })); } catch { inis = []; } }
|
|
85
|
+
const src = root ? views.readers(root, ctx.runtimeDir).readContextSources() : { notes: [], planItems: [], ownItems: [] };
|
|
86
|
+
const mine = (src.notes || []).filter((n) => n.meeting && (n.meeting.event_id === ev.id || n.meeting.event_id === baseId(ev.id)));
|
|
87
|
+
let links = null; try { links = require('./calendar-links.js').read(ctx.runtimeDir); } catch { links = null; }
|
|
88
|
+
let boards = null; try { boards = require('./routes-boards.js').meetingBoards(ctx); } catch { boards = null; }
|
|
89
|
+
brief = layers.briefOf(ev, mine, src, { today: localDay(Date.now()), inis, links, boards }).brief || null;
|
|
90
|
+
} catch { brief = null; }
|
|
91
|
+
briefCache.set(key, { at: Date.now(), brief });
|
|
92
|
+
return brief;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const kindOk = () => headless.kindFor({ agent: 'claude', origin: 'focus-companion:x' }) === 'ask';
|
|
96
|
+
const spawnable = (ctx) => typeof ctx.spawnSession === 'function' && kindOk();
|
|
97
|
+
|
|
98
|
+
/* ── the companion run ────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
99
|
+
|
|
100
|
+
function settleRun(ctx, key, done, now) {
|
|
101
|
+
const at = typeof now === 'function' ? now() : Date.now();
|
|
102
|
+
const res = done && done.result;
|
|
103
|
+
if (done && done.stopped) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The run was stopped before it answered.' });
|
|
104
|
+
if (done && done.timedOut) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The companion took too long and was stopped.' });
|
|
105
|
+
if (!res || !res.ok || res.isError) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The companion did not answer (' + ((done && done.note) || 'no result') + ').' });
|
|
106
|
+
const parsed = F.parseCompanion(res.text);
|
|
107
|
+
// a malformed answer is dropped, not shown
|
|
108
|
+
if (!parsed) return append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at, error: 'The companion answered in a form PMOS could not read, so nothing was shown.' });
|
|
109
|
+
return append(ctx.runtimeDir, key + '.jsonl', F.outLine(parsed, at));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function inputFor(ctx, ev, brief, note, state, topics, question) {
|
|
113
|
+
const now = ctx.nowMs;
|
|
114
|
+
const names = new Map(state.topics.map((t) => [t.id, t.text]));
|
|
115
|
+
return { event: ev, brief: brief || {}, topics: state.topics.length ? state.topics : topics, current: state.current ? names.get(state.current) : null,
|
|
116
|
+
tail: note ? F.tailOf(note, { now }) : [], earlier: state.notes, question: question || null, now };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function startRun(ctx, key, ev, brief, note, state, topics, now) {
|
|
120
|
+
const origin = 'focus-companion:' + key;
|
|
121
|
+
const lines = note ? (note.transcript || []).length : 0;
|
|
122
|
+
append(ctx.runtimeDir, key + '.jsonl', { k: 'run', at: now, lines });
|
|
123
|
+
const prompt = F.companionPrompt(inputFor({ ...ctx, nowMs: now }, ev, brief, note, state, topics, null));
|
|
124
|
+
let s = null;
|
|
125
|
+
try {
|
|
126
|
+
s = ctx.spawnSession({ name: 'focus: companion', agent: 'claude', ws: ctx.w ? ctx.w.id : undefined, origin, initialInput: prompt,
|
|
127
|
+
onDone: (done) => settleRun(ctx, key, done, ctx.now) });
|
|
128
|
+
} catch (e) { s = null; }
|
|
129
|
+
if (!s || s.state === 'exited') append(ctx.runtimeDir, key + '.jsonl', { k: 'fail', at: now, error: 'The companion could not start: ' + ((s && s.note) || 'no session') + '.' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── GET /api/meeting-focus ───────────────────────────────────────────────────────────────────────────────────────── */
|
|
133
|
+
|
|
134
|
+
async function read(q, ctx) {
|
|
135
|
+
const id = String((q && q.event) || '');
|
|
136
|
+
const by = q && q.at ? { at: String(q.at).slice(0, 40), title: String(q.title || '').slice(0, 200) } : null;
|
|
137
|
+
if ((!id && !by) || id.length > 120) return fail(400, 'event is required');
|
|
138
|
+
const date = q && q.date ? String(q.date) : '';
|
|
139
|
+
if (date && !DAY.test(date)) return fail(400, 'date must be YYYY-MM-DD');
|
|
140
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
141
|
+
let events = [];
|
|
142
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir, { now: nowMs }); } catch { events = []; }
|
|
143
|
+
const ev = findEvent(events, id, date, by);
|
|
144
|
+
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
145
|
+
const day = date || String(ev.at || '').slice(0, 10);
|
|
146
|
+
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
147
|
+
const note = noteFor(ctx.runtimeDir, ev, q && q.note);
|
|
148
|
+
const startMs = Date.parse(ev.at);
|
|
149
|
+
const topics = F.topicsFrom(ev, brief);
|
|
150
|
+
const keys = [keyOf(ev.id), note ? keyOf(note.id) : null];
|
|
151
|
+
const key = note ? keyOf(note.id) : keyOf(ev.id);
|
|
152
|
+
const state = F.foldState(linesOf(ctx.runtimeDir, keys), { topics, startMs: Number.isFinite(startMs) ? startMs : null, nowMs });
|
|
153
|
+
const env = ctx.env || process.env;
|
|
154
|
+
const call = F.shouldRun(nowMs, state, note ? { state: note.state, lines: (note.transcript || []).length } : null, { env, timeoutMs: headless.timeoutMs(env) });
|
|
155
|
+
let started = false;
|
|
156
|
+
if (call.run && spawnable(ctx)) { startRun(ctx, key, ev, brief, note, state, topics, nowMs); started = true; }
|
|
157
|
+
const fresh = started ? F.foldState(linesOf(ctx.runtimeDir, keys), { topics, startMs: Number.isFinite(startMs) ? startMs : null, nowMs }) : state;
|
|
158
|
+
const links = [];
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
for (const l of [...(Array.isArray(ev.links) ? ev.links : []), ...((brief && brief.links) || []), ...(((brief && brief.refs) || []).filter((r) => r.link).map((r) => ({ label: r.label, url: r.link })))]) {
|
|
161
|
+
const url = l && typeof l.url === 'string' && /^https?:\/\//i.test(l.url) ? l.url : ''; if (!url || seen.has(url)) continue;
|
|
162
|
+
seen.add(url); links.push({ label: F.str(l.label, 120) || url, url });
|
|
163
|
+
}
|
|
164
|
+
const sessions = note && Array.isArray(note.sessions) && note.sessions.length ? note.sessions : [];
|
|
165
|
+
return { status: 200, body: {
|
|
166
|
+
ok: true, now_ms: nowMs, date: day,
|
|
167
|
+
event: { id: ev.id, title: ev.title, at: ev.at, end: ev.end, attendees: [].concat(ev.attendees || ev.people || []).map((p) => (p && typeof p === 'object' ? p.name || p.id : p)).filter(Boolean).map(String),
|
|
168
|
+
initiative: ev.initiative || null, purpose: ev.purpose || (brief && brief.purpose) || null, links },
|
|
169
|
+
brief: brief || null,
|
|
170
|
+
note: note ? { id: note.id, title: note.title || ev.title, state: note.state, recorded_from: note.recorded_from || null, lines: (note.transcript || []).length, sessions } : null,
|
|
171
|
+
topics: fresh.topics, current: fresh.current, runs: fresh.runs,
|
|
172
|
+
tail: note ? F.tailOf(note, { now: nowMs }) : [], captured: F.capturedFrom(note),
|
|
173
|
+
gist: fresh.gist, chips: fresh.chips, notes: fresh.notes, asks: fresh.asks,
|
|
174
|
+
companion: { on: !F.off(env) && kindOk() && typeof ctx.spawnSession === 'function', running: !!fresh.pending, started, reason: call.reason, last_run_at: fresh.last_run_at || null, error: fresh.error },
|
|
175
|
+
} };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ── POST /api/meeting-focus/topic ────────────────────────────────────────────────────────────────────────────────── */
|
|
179
|
+
|
|
180
|
+
async function topic(req, body, ctx) {
|
|
181
|
+
const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
|
|
182
|
+
const action = String(body.action || '');
|
|
183
|
+
if (!['set', 'add', 'park'].includes(action)) return fail(400, 'action must be set, add or park');
|
|
184
|
+
const text = F.str(body.topic, 120);
|
|
185
|
+
if (!text) return fail(400, 'Name the topic.');
|
|
186
|
+
const key = await keyFromBody(body, ctx); if (key.error) return key.error;
|
|
187
|
+
const now = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
188
|
+
append(ctx.runtimeDir, key.key + '.jsonl', F.moveLine(action, text, now));
|
|
189
|
+
return { status: 200, body: { ok: true, key: key.key } };
|
|
190
|
+
}
|
|
191
|
+
/** The state file a body names: its note, else its event. */
|
|
192
|
+
async function keyFromBody(body, ctx) {
|
|
193
|
+
if (body.note) {
|
|
194
|
+
const r = notes.read(ctx.runtimeDir, String(body.note));
|
|
195
|
+
if (!r.ok) return { error: fail(404, 'no such note') };
|
|
196
|
+
return { key: keyOf(r.note.id), note: r.note };
|
|
197
|
+
}
|
|
198
|
+
if (!body.event) return { error: fail(400, 'event or note is required') };
|
|
199
|
+
let events = [];
|
|
200
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir); } catch { events = []; }
|
|
201
|
+
const ev = findEvent(events, String(body.event), body.date && DAY.test(String(body.date)) ? String(body.date) : '');
|
|
202
|
+
if (!ev) return { error: fail(404, 'no such meeting on the calendar') };
|
|
203
|
+
const note = noteFor(ctx.runtimeDir, ev, null);
|
|
204
|
+
return { key: note ? keyOf(note.id) : keyOf(ev.id), note, ev };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ── Ask PMOS quietly ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
208
|
+
|
|
209
|
+
const asksFile = 'asks.jsonl';
|
|
210
|
+
const asksOf = (runtimeDir) => { const by = new Map(); for (const e of readLines(path.join(dirOf(runtimeDir), asksFile))) by.set(e.id, { ...(by.get(e.id) || {}), ...e }); return by; };
|
|
211
|
+
|
|
212
|
+
/** What a private question's run answered → a note on the stream, or a failure the page shows. Exported so a test can settle without a process. */
|
|
213
|
+
function settleAsk(ctx, a, done, nowFn) {
|
|
214
|
+
const at = typeof nowFn === 'function' ? nowFn() : Date.now();
|
|
215
|
+
const end = (row) => { append(ctx.runtimeDir, asksFile, { id: a.id, at, ...row }); return row; };
|
|
216
|
+
const bad = (error) => { append(ctx.runtimeDir, a.key + '.jsonl', { k: 'fail', id: a.id, at, error }); return end({ event: 'failed', state: 'failed', error }); };
|
|
217
|
+
if (done && done.stopped) return bad('The run was stopped before it answered.');
|
|
218
|
+
if (done && done.timedOut) return bad('The agent took too long and was stopped. Ask again with a shorter question.');
|
|
219
|
+
const res = done && done.result;
|
|
220
|
+
if (!res || !res.ok || res.isError) return bad('The agent did not answer (' + ((done && done.note) || 'no result') + ').');
|
|
221
|
+
let list = (F.parseCompanion(res.text) || { notes: [] }).notes;
|
|
222
|
+
// a plain-text answer is one Answer note; an answer that looks like broken JSON is dropped
|
|
223
|
+
if (!list.length && !/[{}]/.test(String(res.text || '')) && String(res.text || '').trim()) list = [{ cat: 'Answer', heard: '', body: F.str(res.text, 700), why: '', src: 'PMOS', show: 'now' }];
|
|
224
|
+
if (!list.length) return bad('The agent answered in a form PMOS could not read, so nothing was shown.');
|
|
225
|
+
const out = list.map((n) => ({ ...n, show: 'now', heard: n.heard || a.question }));
|
|
226
|
+
append(ctx.runtimeDir, a.key + '.jsonl', { k: 'answer', id: a.id, at, notes: out });
|
|
227
|
+
return end({ event: 'answered', state: 'answered' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function ask(req, body, ctx) {
|
|
231
|
+
const g = guard.requirePagePress(req, body); if (!g.ok) return fail(g.status, g.error);
|
|
232
|
+
const question = F.str(body.question, 1000);
|
|
233
|
+
if (!question) return fail(400, 'Type your question.');
|
|
234
|
+
if (typeof ctx.spawnSession !== 'function') return fail(409, 'this workspace cannot start sessions');
|
|
235
|
+
if (!kindOk()) return fail(409, 'Background runs are switched off here (PMOS_HEADLESS=off), so PMOS cannot answer from this page.');
|
|
236
|
+
const k = await keyFromBody({ ...body, note: body.note }, ctx); if (k.error) return k.error;
|
|
237
|
+
const nowMs = typeof ctx.now === 'function' ? ctx.now() : Date.now();
|
|
238
|
+
let ev = k.ev;
|
|
239
|
+
if (!ev) {
|
|
240
|
+
let events = [];
|
|
241
|
+
try { events = typeof ctx.readEvents === 'function' ? ctx.readEvents() : require('./calendar-events.js').readAll(ctx.runtimeDir); } catch { events = []; }
|
|
242
|
+
ev = body.event ? findEvent(events, String(body.event), '') : null;
|
|
243
|
+
if (!ev && k.note && k.note.meeting) ev = { id: k.note.meeting.event_id || k.note.id, title: k.note.meeting.title || k.note.title, at: k.note.meeting.start, end: k.note.meeting.end };
|
|
244
|
+
}
|
|
245
|
+
if (!ev) return fail(404, 'no such meeting on the calendar');
|
|
246
|
+
const day = String(ev.at || '').slice(0, 10);
|
|
247
|
+
const brief = typeof ctx.briefFor === 'function' ? await ctx.briefFor(ev, day) : await realBrief(ctx, ev, day);
|
|
248
|
+
const topics = F.topicsFrom(ev, brief);
|
|
249
|
+
const keys = [keyOf(ev.id), k.note ? keyOf(k.note.id) : null];
|
|
250
|
+
const startMs = Date.parse(ev.at);
|
|
251
|
+
const state = F.foldState(linesOf(ctx.runtimeDir, keys), { topics, startMs: Number.isFinite(startMs) ? startMs : null, nowMs });
|
|
252
|
+
const a = { id: 'fa-' + crypto.randomBytes(6).toString('hex'), key: k.key, question };
|
|
253
|
+
const origin = 'focus-companion:' + k.key;
|
|
254
|
+
const prompt = F.companionPrompt(inputFor({ ...ctx, nowMs }, ev, brief, k.note, state, topics, question));
|
|
255
|
+
const s = ctx.spawnSession({ name: 'focus: ask', agent: 'claude', ws: ctx.w ? ctx.w.id : undefined, origin, initialInput: prompt, onDone: (done) => settleAsk(ctx, a, done, ctx.now) });
|
|
256
|
+
if (!s || s.state === 'exited') return fail(409, 'The agent could not start: ' + ((s && s.note) || 'no session'));
|
|
257
|
+
append(ctx.runtimeDir, k.key + '.jsonl', { k: 'ask', id: a.id, at: nowMs, question });
|
|
258
|
+
append(ctx.runtimeDir, asksFile, { event: 'asked', id: a.id, key: k.key, question, state: 'working', session: s.id, at: nowMs });
|
|
259
|
+
return { status: 200, body: { ok: true, id: a.id, state: 'working', session: s.id } };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function askState(q, ctx, now = typeof ctx.now === 'function' ? ctx.now() : Date.now()) {
|
|
263
|
+
const a = asksOf(ctx.runtimeDir).get(String((q && q.id) || ''));
|
|
264
|
+
if (!a) return fail(404, 'no such question');
|
|
265
|
+
const out = { ok: true, id: a.id, state: a.state, session: a.session || null };
|
|
266
|
+
if (a.state === 'failed') out.error = a.error;
|
|
267
|
+
if (a.state === 'working' && now - Number(a.at || 0) > headless.timeoutMs() + 120000) { out.state = 'failed'; out.error = 'The run ended without an answer. Ask again.'; }
|
|
268
|
+
return { status: 200, body: out };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
prefixes: ['/api/meeting-focus'],
|
|
275
|
+
read, topic, ask, askState, settleAsk, settleRun, findEvent, keyOf,
|
|
276
|
+
handle: async (req, res, url, ctx) => {
|
|
277
|
+
const p = url.pathname;
|
|
278
|
+
const wsId = url.searchParams.get('ws');
|
|
279
|
+
const w = ctx.getWs ? ctx.getWs(wsId) : null;
|
|
280
|
+
const base = { runtimeDir: ctx.runtimeDir, spawnSession: ctx.spawnSession, w, getWs: ctx.getWs, json: ctx.json };
|
|
281
|
+
if (req.method === 'GET' && p === '/api/meeting-focus') return respond(ctx, res, await read(Object.fromEntries(url.searchParams), base));
|
|
282
|
+
if (req.method === 'GET' && p === '/api/meeting-focus/ask') return respond(ctx, res, askState(Object.fromEntries(url.searchParams), base));
|
|
283
|
+
const fn = req.method === 'POST' && { '/api/meeting-focus/topic': topic, '/api/meeting-focus/ask': ask }[p];
|
|
284
|
+
if (!fn) return false;
|
|
285
|
+
const got = await ctx.readJson(req);
|
|
286
|
+
if (!got.ok) return respond(ctx, res, fail(400, 'invalid JSON body'));
|
|
287
|
+
const body = got.body || {};
|
|
288
|
+
const bw = body.ws && ctx.getWs ? ctx.getWs(body.ws) : w;
|
|
289
|
+
return respond(ctx, res, await fn(req, body, { ...base, w: bw }));
|
|
290
|
+
},
|
|
291
|
+
};
|
|
@@ -9,10 +9,16 @@
|
|
|
9
9
|
* returns the key. `getKey()` is for the server's validation path only; no route returns its value.
|
|
10
10
|
* `TYPESAFE_API_KEY` in the environment is read as a fallback, ONCE, at server start (`adoptEnv`), which
|
|
11
11
|
* then deletes the variable from the process environment. Nothing the server spawns afterwards can see it.
|
|
12
|
+
*
|
|
13
|
+
* LOCAL FALLBACK (D95). The same file keeps `local: { backend, port }`: the System One server the PM runs on this
|
|
14
|
+
* machine (Winnow or Laya). Every read hands it to lib/typesafe.js (`configureLocal`). With no key saved, `getKey`
|
|
15
|
+
* returns `typesafe.LOCAL_KEY` so the bridges' key gate opens and their questions go to loopback; `status` says
|
|
16
|
+
* `source: 'local'`. With a key saved, the local server is only asked when TypeSafe fails.
|
|
12
17
|
*/
|
|
13
18
|
const fs = require('fs');
|
|
14
19
|
const path = require('path');
|
|
15
|
-
const
|
|
20
|
+
const typesafe = require('./typesafe');
|
|
21
|
+
const { validKey } = typesafe;
|
|
16
22
|
|
|
17
23
|
const FILE = 'typesafe.json';
|
|
18
24
|
const ENV_NAME = 'TYPESAFE_API_KEY';
|
|
@@ -23,13 +29,20 @@ function file(stateDir) { return path.join(stateDir, FILE); }
|
|
|
23
29
|
function read(stateDir) {
|
|
24
30
|
try {
|
|
25
31
|
const st = fs.lstatSync(file(stateDir));
|
|
26
|
-
if (!st.isFile()) return { key: null, repos: {} };
|
|
32
|
+
if (!st.isFile()) return { key: null, repos: {}, local: localFrom(null) };
|
|
27
33
|
const o = JSON.parse(fs.readFileSync(file(stateDir), 'utf8'));
|
|
28
34
|
return {
|
|
29
35
|
key: typeof o.key === 'string' ? o.key : null,
|
|
30
36
|
repos: o && typeof o.repos === 'object' && o.repos && !Array.isArray(o.repos) ? o.repos : {},
|
|
37
|
+
local: localFrom(o && o.local),
|
|
31
38
|
};
|
|
32
|
-
} catch { return { key: null, repos: {} }; }
|
|
39
|
+
} catch { return { key: null, repos: {}, local: localFrom(null) }; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* The saved local server, checked, and handed to the TypeSafe module so the process follows the file. */
|
|
43
|
+
function localFrom(l) {
|
|
44
|
+
const t = typesafe.configureLocal(l && typeof l === 'object' ? { backend: l.backend, port: l.port } : null);
|
|
45
|
+
return t ? { backend: t.backend, port: t.port } : null;
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
function write(stateDir, data) {
|
|
@@ -59,30 +72,50 @@ function adoptEnv(env) {
|
|
|
59
72
|
}
|
|
60
73
|
function envKey(env) { return adopted || (env && validKey(env[ENV_NAME]) ? env[ENV_NAME] : null); }
|
|
61
74
|
|
|
62
|
-
/** The key for the server's own use, or null. File first, then the environment. */
|
|
75
|
+
/** The key for the server's own use, or null. File first, then the environment, then the local server's handle. */
|
|
63
76
|
function getKey(stateDir, env = null) {
|
|
64
|
-
const
|
|
65
|
-
if (validKey(
|
|
66
|
-
return envKey(env);
|
|
77
|
+
const cur = read(stateDir);
|
|
78
|
+
if (validKey(cur.key)) return cur.key;
|
|
79
|
+
return envKey(env) || (cur.local ? typesafe.LOCAL_KEY : null);
|
|
67
80
|
}
|
|
68
81
|
|
|
69
|
-
/** What the browser may know: present or not, and from where. Never the value, never its length.
|
|
82
|
+
/** What the browser may know: present or not, and from where. Never the value, never its length.
|
|
83
|
+
* `local` is the fallback server (backend and port; neither is a secret). */
|
|
70
84
|
function status(stateDir, env = null) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { present:
|
|
85
|
+
const cur = read(stateDir);
|
|
86
|
+
const local = cur.local ? { backend: cur.local.backend, name: typesafe.BACKENDS[cur.local.backend].name, port: cur.local.port } : null;
|
|
87
|
+
if (validKey(cur.key)) return { present: true, source: 'settings', local };
|
|
88
|
+
if (envKey(env)) return { present: true, source: 'environment', local };
|
|
89
|
+
if (local) return { present: true, source: 'local', local };
|
|
90
|
+
return { present: false, source: null, local: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Save or clear the local fallback server. `backend` is 'winnow' or 'laya'; `port` defaults to that server's own. */
|
|
94
|
+
function setLocal(stateDir, { backend, port } = {}) {
|
|
95
|
+
const cur = read(stateDir);
|
|
96
|
+
if (backend == null || backend === '' || backend === 'off') {
|
|
97
|
+
const out = write(stateDir, { key: cur.key, repos: cur.repos, local: null });
|
|
98
|
+
if (out.ok) typesafe.configureLocal(null);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
if (!typesafe.validBackend(backend)) return { ok: false, error: 'choose Winnow or Laya' };
|
|
102
|
+
const p = port == null || port === '' ? typesafe.BACKENDS[backend].port : Number(port);
|
|
103
|
+
if (!typesafe.validPort(p)) return { ok: false, error: 'the port must be a whole number from 1024 to 65535' };
|
|
104
|
+
const out = write(stateDir, { key: cur.key, repos: cur.repos, local: { backend, port: p } });
|
|
105
|
+
if (out.ok) typesafe.configureLocal({ backend, port: p });
|
|
106
|
+
return out;
|
|
74
107
|
}
|
|
75
108
|
|
|
76
109
|
function setKey(stateDir, key) {
|
|
77
110
|
const k = typeof key === 'string' ? key.trim() : '';
|
|
78
111
|
if (!validKey(k)) return { ok: false, error: 'that does not look like a key — it must be 8 to 512 characters with no spaces' };
|
|
79
112
|
const cur = read(stateDir);
|
|
80
|
-
return write(stateDir, { key: k, repos: cur.repos });
|
|
113
|
+
return write(stateDir, { key: k, repos: cur.repos, local: cur.local });
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
function clearKey(stateDir) {
|
|
84
117
|
const cur = read(stateDir);
|
|
85
|
-
return write(stateDir, { key: null, repos: cur.repos });
|
|
118
|
+
return write(stateDir, { key: null, repos: cur.repos, local: cur.local });
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
/** Consent and exclusions for one repository root. Consent defaults to off. */
|
|
@@ -115,7 +148,7 @@ function setRepo(stateDir, root, { consent, exclude, watch, jev, categorize, rou
|
|
|
115
148
|
? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
|
|
116
149
|
: prev.exclude;
|
|
117
150
|
const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev, categorize: typeof categorize === 'boolean' ? categorize : prev.categorize, route: typeof route === 'boolean' ? route : prev.route, status_read: typeof statusRead === 'boolean' ? statusRead : prev.status_read } };
|
|
118
|
-
return write(stateDir, { key: cur.key, repos });
|
|
151
|
+
return write(stateDir, { key: cur.key, repos, local: cur.local });
|
|
119
152
|
}
|
|
120
153
|
|
|
121
154
|
/** Does `rel` fall under an exclusion? An entry is a path prefix, or a `*` pattern matched on the path. */
|
|
@@ -130,4 +163,4 @@ function isExcluded(exclude, rel) {
|
|
|
130
163
|
});
|
|
131
164
|
}
|
|
132
165
|
|
|
133
|
-
module.exports = { adoptEnv, status, getKey, setKey, clearKey, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
|
|
166
|
+
module.exports = { adoptEnv, status, getKey, setKey, clearKey, setLocal, getRepo, setRepo, isExcluded, ENV_NAME, FILE };
|
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* Noul "high means yes"; the single `noul` number is P(yes). There is no separate confidence.
|
|
18
18
|
* Score levels 0..N-1; `score` is the probability-weighted mean; `confidence` is reported.
|
|
19
19
|
* Choice one option plus a distribution.
|
|
20
|
+
*
|
|
21
|
+
* LOCAL FALLBACK (D95). When the PM has no TypeSafe key, or Jev cannot answer, the same questions may go to a
|
|
22
|
+
* System One server the PM runs on this machine: Winnow (winnow-inference, port 8091) or Laya (laya-serve, port
|
|
23
|
+
* 8000). Both speak Jev's `POST /v1/systemone`. The host is LOOPBACK, fixed in source like the TypeSafe host; only
|
|
24
|
+
* the port is a setting. A local request carries no key and no model name, and nothing leaves the machine.
|
|
25
|
+
* - `configureLocal({ backend, port })` turns the fallback on for this process; `configureLocal(null)` turns it off.
|
|
26
|
+
* lib/typesafe-settings.js calls it from what the PM saved.
|
|
27
|
+
* - `LOCAL_KEY` is the handle the key store returns when only the local server is set. It is not a secret and
|
|
28
|
+
* it is never sent to TypeSafe: `ask` routes it to loopback before the TypeSafe branch.
|
|
29
|
+
* - With a real key, a TypeSafe failure (unreachable, 401, 429, 5xx) is asked again locally when the fallback is
|
|
30
|
+
* on. The result says `fallback: true` and names the local backend.
|
|
20
31
|
*/
|
|
21
32
|
const https = require('https');
|
|
22
33
|
const http = require('http');
|
|
@@ -28,6 +39,14 @@ const MAX_STATE_CHARS = 60000; // a conservative cap; the documentation st
|
|
|
28
39
|
const MAX_RESPONSE_BYTES = 1 << 20; // 1 MiB — an answer set is small; anything larger is not one
|
|
29
40
|
const MAX_QUESTIONS = 16;
|
|
30
41
|
|
|
42
|
+
const LOCAL_HOST = '127.0.0.1'; // loopback only — the local fallback never reaches another machine (D95)
|
|
43
|
+
const LOCAL_KEY = 'local-systemone'; // the key store's handle for "no TypeSafe key, the local server answers"
|
|
44
|
+
const BACKENDS = {
|
|
45
|
+
winnow: { name: 'Winnow', port: 8091, start: 'python3 scripts/serve.py (in a winnow-inference checkout)' },
|
|
46
|
+
laya: { name: 'Laya', port: 8000, start: 'pip install "laya[serve]" && laya-serve' },
|
|
47
|
+
};
|
|
48
|
+
const LOCAL_ASK = { timeoutMs: 15000, retries: 1, backoffMs: 400 }; // a floor: a laptop model can be slower than the hosted one
|
|
49
|
+
|
|
31
50
|
/* A seam for tests, gated like the sink's. It lets a suite point this module at a loopback stub; it
|
|
32
51
|
* is unreachable in a production process, so no setting can redirect the key to another host. */
|
|
33
52
|
let endpointForTests = null;
|
|
@@ -42,6 +61,19 @@ function endpoint() {
|
|
|
42
61
|
return endpointForTests || { protocol: 'https:', hostname: HOST, port: 443 };
|
|
43
62
|
}
|
|
44
63
|
|
|
64
|
+
function validPort(p) { const n = Number(p); return Number.isInteger(n) && n >= 1024 && n <= 65535; }
|
|
65
|
+
function validBackend(b) { return typeof b === 'string' && Object.prototype.hasOwnProperty.call(BACKENDS, b); }
|
|
66
|
+
|
|
67
|
+
/* The local fallback for this process, or null. Set from the PM's saved choice; never from a request body. */
|
|
68
|
+
let local = null;
|
|
69
|
+
function configureLocal(cfg) {
|
|
70
|
+
if (!cfg || !validBackend(cfg.backend)) { local = null; return null; }
|
|
71
|
+
const port = cfg.port == null || cfg.port === '' ? BACKENDS[cfg.backend].port : Number(cfg.port);
|
|
72
|
+
local = validPort(port) ? { backend: cfg.backend, port } : null;
|
|
73
|
+
return local;
|
|
74
|
+
}
|
|
75
|
+
function localTarget() { return local ? { ...local, name: BACKENDS[local.backend].name } : null; }
|
|
76
|
+
|
|
45
77
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
46
78
|
const retryable = (status) => status === 429 || status === 529 || (status >= 500 && status < 600);
|
|
47
79
|
|
|
@@ -49,8 +81,7 @@ function validKey(key) {
|
|
|
49
81
|
return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
|
|
50
82
|
}
|
|
51
83
|
|
|
52
|
-
function once({ key, body, timeoutMs }) {
|
|
53
|
-
const ep = endpoint();
|
|
84
|
+
function once({ key, body, timeoutMs, ep = endpoint() }) {
|
|
54
85
|
const mod = ep.protocol === 'http:' ? http : https;
|
|
55
86
|
const payload = Buffer.from(body, 'utf8');
|
|
56
87
|
return new Promise((resolve) => {
|
|
@@ -60,7 +91,7 @@ function once({ key, body, timeoutMs }) {
|
|
|
60
91
|
protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
|
|
61
92
|
headers: {
|
|
62
93
|
'Content-Type': 'application/json', 'Content-Length': payload.length,
|
|
63
|
-
Authorization: 'Bearer ' + key, 'User-Agent': 'pmos-workspace',
|
|
94
|
+
...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace',
|
|
64
95
|
},
|
|
65
96
|
timeout: timeoutMs,
|
|
66
97
|
}, (res) => {
|
|
@@ -104,16 +135,10 @@ function once({ key, body, timeoutMs }) {
|
|
|
104
135
|
* @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
|
|
105
136
|
* `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
|
|
106
137
|
*/
|
|
107
|
-
async function
|
|
108
|
-
if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
|
|
109
|
-
if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
|
|
110
|
-
if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
|
|
111
|
-
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
112
|
-
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
113
|
-
const body = JSON.stringify({ state, model, questions });
|
|
138
|
+
async function send({ key, body, ep, timeoutMs, retries, backoffMs }) {
|
|
114
139
|
let last = null;
|
|
115
140
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
116
|
-
last = await once({ key, body, timeoutMs });
|
|
141
|
+
last = await once({ key, body, timeoutMs, ep });
|
|
117
142
|
if (last.ok) return { ...last, requested: true };
|
|
118
143
|
if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
|
|
119
144
|
await sleep(backoffMs * Math.pow(2, attempt));
|
|
@@ -121,4 +146,40 @@ async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 3
|
|
|
121
146
|
return { ok: false, requested: true, status: last.status, reason: last.reason };
|
|
122
147
|
}
|
|
123
148
|
|
|
124
|
-
|
|
149
|
+
/* A local server may leave out an answer's `type`; the bridges read it, so it is filled from the question. */
|
|
150
|
+
function withTypes(answers, questions) {
|
|
151
|
+
const out = {};
|
|
152
|
+
for (const [n, a] of Object.entries(answers || {})) {
|
|
153
|
+
out[n] = a && typeof a === 'object' && a.type == null && questions[n] ? { ...a, type: questions[n].type } : a;
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Ask the PM's local System One server. No key and no model name go with it: each server answers with its own model. */
|
|
159
|
+
async function askLocal({ state, questions, timeoutMs, retries, backoffMs }) {
|
|
160
|
+
const t = localTarget();
|
|
161
|
+
if (!t) return { ok: false, requested: false, reason: 'no local model is set' };
|
|
162
|
+
const r = await send({ key: null, body: JSON.stringify({ state, questions }), ep: { protocol: 'http:', hostname: LOCAL_HOST, port: t.port },
|
|
163
|
+
timeoutMs: Math.max(timeoutMs || 0, LOCAL_ASK.timeoutMs), retries: Math.min(retries == null ? LOCAL_ASK.retries : retries, LOCAL_ASK.retries), backoffMs: backoffMs || LOCAL_ASK.backoffMs });
|
|
164
|
+
if (!r.ok) return { ...r, local: t.backend, reason: `${t.name} on this machine: ` + r.reason.replace(/^the service/, 'the server') };
|
|
165
|
+
return { ...r, answers: withTypes(r.answers, questions), model: r.model || t.backend, local: t.backend };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* When the hosted answer fails this way, the local server is asked instead (D95). A malformed request (422) is not. */
|
|
169
|
+
const failsOver = (r) => r.requested && (r.status === 0 || r.status === 401 || retryable(r.status));
|
|
170
|
+
|
|
171
|
+
async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
|
|
172
|
+
if (!validKey(key)) return { ok: false, requested: false, reason: 'no TypeSafe key is set' };
|
|
173
|
+
if (typeof state !== 'string' || !state.trim()) return { ok: false, requested: false, reason: 'there is no text to judge' };
|
|
174
|
+
if (state.length > MAX_STATE_CHARS) return { ok: false, requested: false, reason: `the text is longer than ${MAX_STATE_CHARS} characters` };
|
|
175
|
+
const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
|
|
176
|
+
if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
|
|
177
|
+
// The local handle never reaches TypeSafe: it is routed to loopback here, before the hosted branch.
|
|
178
|
+
if (key === LOCAL_KEY) return askLocal({ state, questions, timeoutMs, retries, backoffMs });
|
|
179
|
+
const r = await send({ key, body: JSON.stringify({ state, model, questions }), ep: endpoint(), timeoutMs, retries, backoffMs });
|
|
180
|
+
if (r.ok || !local || !failsOver(r)) return r;
|
|
181
|
+
const l = await askLocal({ state, questions, timeoutMs, retries, backoffMs });
|
|
182
|
+
return l.ok ? { ...l, fallback: true, hosted_reason: r.reason } : { ...r, reason: r.reason + '; ' + l.reason };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { ask, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.165.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.165.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -568,6 +568,11 @@ function detail(ui) {
|
|
|
568
568
|
el('p', { class: 'dim cal3-note' }, 'Select a meeting to see what came out of it or what to prepare. Select a dated marker to see its date and open its home.')]);
|
|
569
569
|
}
|
|
570
570
|
|
|
571
|
+
/** The Meeting focus page (initiative c3-meeting-focus): one calm full-screen page for a meeting that is today. It loads when pressed. */
|
|
572
|
+
const focusBtn = (e, date) => button({ label: [c3ic('target', 'sm'), 'Focus'], variant: 'primary', size: 'sm',
|
|
573
|
+
attrs: { 'data-cal3': 'focus', title: 'Open the meeting focus page: the clock, the topics, the material and PMOS beside the conversation' },
|
|
574
|
+
onClick: () => { import('./c3-focus.js').then((m) => m.openFocus({ event: e, date })).catch(() => toast({ text: 'The focus page could not load.', tone: 'error' })); } });
|
|
575
|
+
|
|
571
576
|
function meetingDetail(ui, hit) {
|
|
572
577
|
const { M, h } = ui; const e = hit.e;
|
|
573
578
|
const s = h.timeOf(e.at); const f = s === null ? null : h.endH(e);
|
|
@@ -595,7 +600,8 @@ function meetingDetail(ui, hit) {
|
|
|
595
600
|
const iniStatus = b && b.initiative && b.initiative.id === iniId ? b.initiative.status : (((ui.d.gantt || []).find((g) => g.id === iniId) || {}).status || null);
|
|
596
601
|
const sources = b && b.sources && b.sources.length ? 'Put together from ' + listWords(b.sources) + '. Each line opens its source. ' : '';
|
|
597
602
|
return [
|
|
598
|
-
el('div', { class: 'cal3-dt' }, el('h3', null, c3ic('cal'), 'Meeting'), el('span', { class: 'tag' + (past ? '' : ' cal3-up') }, past ? 'past' : 'upcoming'),
|
|
603
|
+
el('div', { class: 'cal3-dt' }, el('h3', null, c3ic('cal'), 'Meeting'), el('span', { class: 'tag' + (past ? '' : ' cal3-up') }, past ? 'past' : 'upcoming'),
|
|
604
|
+
!past && s !== null && hit.date === ui.d.today ? focusBtn(e, hit.date) : null, closeBtn(ui)),
|
|
599
605
|
el('b', { class: 'cal3-title' }, e.title || e.id),
|
|
600
606
|
el('div', { class: 'dim mono cal3-when' }, h.dayLabel(hit.date) + (s === null ? ', all day' : ' · ' + h.hhmm(s) + ' to ' + h.hhmm(f))),
|
|
601
607
|
el('div', { class: 'chips2', 'data-cal3': 'meeting-chips' }, homeChip('Calendar'), ...peopleOf(e).map((n) => el('span', { class: 'tag', 'data-cal3': 'person' }, n)),
|