create-pmos 0.162.0 → 0.164.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.
@@ -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
+ };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.162.0",
3
+ "version": "0.164.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.162.0",
9
+ "version": "0.164.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.162.0",
3
+ "version": "0.164.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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'), closeBtn(ui)),
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)),
@@ -0,0 +1,133 @@
1
+ /* pm/c3-demo-focus.js — DEMO answers for the Meeting focus page (initiative c3-meeting-focus) on /c3.html?demo=1.
2
+ * c3-demo.js passes GET /api/meeting-focus, GET /api/meeting-focus/ask and the two POSTs (topic, ask) here. The content is the approved
3
+ * mockup's (version 7): the Partner API rate-limit review at 18:24 in, with its six topics (one added from the talk), the gist and its four
4
+ * context chips, the room's last lines, three PMOS notes and two it is holding, the captured items and a Google Slides deck.
5
+ * Whatever meeting the page asks for, the demo answers with this one. A correction (set, add, park) and a question change the answer
6
+ * for the rest of the page's life; nothing is saved and nothing reaches the server. Real mode never loads this file. */
7
+
8
+ const DAY = '2026-10-08';
9
+ const at = (hm) => Date.parse(DAY + 'T' + hm + ':00');
10
+ const START = at('11:00');
11
+ const NOW = at('11:18') + 24000;
12
+ const min = (ms) => Math.round(((ms - START) / 60000) * 10) / 10;
13
+
14
+ const EVENT = {
15
+ id: 'e2@' + DAY, title: 'Partner API rate-limit review', at: DAY + 'T11:00:00', end: DAY + 'T12:00:00',
16
+ attendees: ['Ana Putri', 'Chen Wei', 'Lina Kusuma', 'You'], initiative: 'IDPF-07',
17
+ purpose: 'Choose how the Partner API protects itself from bursts: soft or hard limits.',
18
+ links: [
19
+ { label: 'Rate limits for the Partner API', url: 'docs.google.com/presentation/d/1RateLimitsPartnerApiDemo/edit' },
20
+ { label: 'Partner API rate limits: options paper', url: 'pmos-demo.atlassian.net/wiki/spaces/IDPF/pages/7/Partner+API+rate+limits+options+paper' },
21
+ ],
22
+ };
23
+ const BRIEF = {
24
+ initiative: { id: 'IDPF-07', title: 'Partner API rate limits', status: 'on track' },
25
+ gist: ['For Partner API rate limits, which is on track.', 'The decision to choose the rate-limit approach is due today.'],
26
+ matters: [{ tone: 'bad', tag: 'Due today', text: 'Choose the rate-limit approach before the 11:00 review', meta: 'Partner API working group', ref: { type: 'decision', id: 'd2' } }],
27
+ last: { id: 'n-demo-last', title: 'Partner API rate-limit review', at: '2026-10-08T11:00:00', private: false, overview: 'On 8 Oct Chen promised the p99 numbers per partner.',
28
+ decisions: [], actions: [{ text: 'Send per-partner numbers to Lina', owner: 'Chen Wei', due: 'Tue 13 Oct' }] },
29
+ earlier: 1, purpose: EVENT.purpose, prep: 'Your one-pager is still open.', links: EVENT.links, refs: [], sources: ['the calendar', 'the IDPF-07 plan'],
30
+ };
31
+
32
+ /* the topics, in the order the page shows them: id, text, where it came from, when it came up and who raised it */
33
+ const BASE = [
34
+ { id: 'context-and-goal', text: 'Context and goal', src: 'steps' },
35
+ { id: 'options-soft-vs-hard-limits', text: 'Options: soft vs hard limits', src: 'steps' },
36
+ { id: 'a-temporary-higher-tier-during-the-grace-window', text: 'A temporary higher tier during the grace window', src: 'added', at: at('11:15'), by: 'Lina' },
37
+ { id: 'partner-impact-top-20-traffic', text: 'Partner impact: top 20 traffic', src: 'steps' },
38
+ { id: 'partner-comms-and-timing', text: 'Partner comms and timing', src: 'steps' },
39
+ { id: 'decide-and-next-steps', text: 'Decide and next steps', src: 'steps' },
40
+ ];
41
+ const ids = BASE.map((t) => t.id);
42
+ /* the stretches of talk: topic id, from, to (null while open), in epoch ms */
43
+ const fresh = () => ({
44
+ topics: BASE.map((t) => ({ ...t })), parked: new Set(), current: ids[1],
45
+ runs: [[ids[0], at('11:00'), at('11:07')], [ids[1], at('11:07'), at('11:15')], [ids[2], at('11:15'), at('11:17')], [ids[1], at('11:17'), null]],
46
+ asks: [], extra: [], seq: 0,
47
+ });
48
+ let S = fresh();
49
+
50
+ const NOTES = [
51
+ { id: 'n0', at: at('11:15'), cat: 'Topics', heard: '"Could they buy a higher tier just for the grace window?" Lina, 11:15', body: 'Added to Topics: A temporary higher tier during the grace window. The room came back to your options at 11:17.',
52
+ why: 'A new thread came up. I added it to Topics so it is not lost.', src: 'what was said since 11:15', show: 'now' },
53
+ { id: 'n1', at: at('11:16'), cat: 'Fact check', heard: '"Are we even allowed to throttle them?" Ana, 11:16', body: 'Yes. The partner agreement v3, section 4.2, allows throttling with 30 days written notice.',
54
+ why: 'You answered from memory. This is the clause, in case anyone asks for it.', src: 'Partner agreement v3 (Drive)', show: 'now',
55
+ acts: [{ label: 'Open section 4.2', url: 'drive.google.com/file/d/1PartnerAgreementV3Demo/view' }] },
56
+ { id: 'n2', at: at('11:18'), cat: 'Answer', heard: '"How many partners would hit a hard limit today?" Lina, 11:17', body: '3 of the top 20 partners went over 1,000 req/s last week: Tokopay (2,400 peak), Kirimin (1,600) and Bayarku (1,100).',
57
+ why: 'Lina asked the room and nobody had the number. You will want it before you reply.', src: "Chen's traffic analysis, page 3", show: 'now',
58
+ acts: [{ label: 'Open the analysis', ref: { type: 'report', id: 'p6' } }] },
59
+ { id: 'n3', at: at('11:18'), cat: 'Decision', heard: '', body: 'Ana and Chen lean to soft limits now, then hard limits after 30 days. That is your recommendation on slide 7.',
60
+ why: '', src: 'what was said since 11:12', show: 'hold', when: 'when the room gets to the decision' },
61
+ { id: 'n4', at: at('11:18'), cat: 'Time', heard: '', body: "41 min left and 3 topics have not come up: partner impact, partner comms, and the decision. Lina's question already pulled in Chen's numbers, so partner impact can be short.",
62
+ why: '', src: 'the topics and the time left', show: 'hold', when: 'if 2 topics are left with under 15 min' },
63
+ ];
64
+ const CHIPS = [
65
+ { label: 'Tokopay, Kirimin, Bayarku', sub: 'partners over the limit',
66
+ detail: 'Tokopay peaked at 2,400 req/s (Standard plan, account owner Lina Kusuma). Kirimin peaked at 1,600 req/s (Standard, Lina Kusuma). Bayarku peaked at 1,100 req/s (Plus, Eko Prasetyo).', from: "Chen's traffic analysis, page 3, and the partner list in the CRM" },
67
+ { label: 'Partner agreement 4.2', sub: 'allows throttling with notice',
68
+ detail: '4.2 Fair use. The Provider may limit request rates that exceed the published tier, after giving the Partner at least thirty (30) days written notice.', from: 'Drive: Legal / Partners / Partner agreement v3.pdf, page 6' },
69
+ { label: 'Option B', sub: 'slide 5 of your deck', url: 'docs.google.com/presentation/d/1RateLimitsPartnerApiDemo/edit#slide=id.p5' },
70
+ { label: 'Rate-limit decision', sub: 'due today', ref: { type: 'decision', id: 'd2' } },
71
+ ];
72
+ const TAIL = [
73
+ ['You', '11:14', 'So Option B: hard limits, with a 30 day grace window.'], ['Lina', '11:15', 'Could they buy a higher tier just for the grace window?'],
74
+ ['Ana', '11:16', 'Are we even allowed to throttle them?'], ['You', '11:16', 'Yes, with notice. That is the grace window on this slide.'],
75
+ ['Lina', '11:17', 'How many partners would hit a hard limit today?'], ['Chen', '11:18', 'Three, I think. I can pull the exact numbers.'],
76
+ ].map(([who, hm, text]) => ({ who, at: DAY + 'T' + hm + ':00', t: at(hm) - START, text, source: who === 'You' ? 'mic' : 'meeting' }));
77
+
78
+ function view() {
79
+ const spent = (id) => S.runs.filter((r) => r[0] === id).reduce((s, r) => s + ((r[2] == null ? NOW : r[2]) - r[1]), 0) / 60000;
80
+ const topics = S.topics.map((t) => ({ id: t.id, text: t.text, src: t.src, ...(t.at ? { at: t.at } : {}), ...(t.by ? { by: t.by } : {}),
81
+ state: S.current === t.id ? 'now' : S.parked.has(t.id) ? 'parked' : spent(t.id) > 0 ? 'discussed' : 'notyet', minutes: Math.round(spent(t.id)) }));
82
+ const notes = NOTES.map((n) => ({ ...n })).concat(S.extra);
83
+ return {
84
+ ok: true, now_ms: NOW, date: DAY, event: EVENT, brief: BRIEF,
85
+ note: { id: 'n-demo-focus', title: EVENT.title, state: 'recording', recorded_from: DAY + 'T11:00:00', lines: 42, sessions: [{ from: DAY + 'T11:00:00', to: null }] },
86
+ topics, current: S.current, runs: S.runs.map((r) => ({ topic: r[0], from_min: min(r[1]), to_min: r[2] == null ? null : min(r[2]) })),
87
+ tail: TAIL, captured: { decisions: [], actions: [{ text: 'Send per-partner numbers to Lina', owner: 'Chen Wei', due: 'Tue 13 Oct' }], questions: ['Can a partner buy a temporary higher tier during the grace window?'].map((text) => ({ text })) },
88
+ gist: 'The room is weighing hard limits against the 3 partners who would break today. Ana and Chen lean to soft limits first; Lina worries about telling partners.',
89
+ chips: CHIPS, notes, asks: S.asks.map((a) => ({ ...a })),
90
+ companion: { on: true, running: false, started: false, reason: 'too soon', last_run_at: NOW - 20000, error: null },
91
+ };
92
+ }
93
+
94
+ /** GET /api/meeting-focus and GET /api/meeting-focus/ask?id= */
95
+ export function focusGet(path, url) {
96
+ if (path === '/api/meeting-focus/ask') {
97
+ const q = new URL(url, location.origin).searchParams;
98
+ const a = S.asks.find((x) => x.id === q.get('id'));
99
+ return a ? { ok: true, id: a.id, state: 'answered' } : { ok: false, status: 404, error: 'no such question' };
100
+ }
101
+ return view();
102
+ }
103
+
104
+ /** POST /api/meeting-focus/topic and POST /api/meeting-focus/ask: they change the demo's answer and nothing else. */
105
+ export function focusPost(path, body) {
106
+ let b = {};
107
+ try { b = body ? JSON.parse(body) : {}; } catch { b = {}; }
108
+ const text = String(b.topic || '').replace(/\s+/g, ' ').trim().slice(0, 120);
109
+ if (path === '/api/meeting-focus/ask') {
110
+ const q = String(b.question || '').replace(/\s+/g, ' ').trim().slice(0, 1000);
111
+ if (!q) return { ok: false, error: 'Type your question.' };
112
+ const id = 'fa-demo' + (++S.seq);
113
+ S.asks.push({ id, at: NOW, question: q, state: 'answered' });
114
+ S.extra.push({ id: 'na' + S.seq, at: NOW, cat: 'Answer', heard: q, body: 'IDPF-07 has 3 open risks. The newest is partner comms, flagged by Lina on Thu 8 Oct.', why: 'You asked.', src: 'IDPF-07 risks', show: 'now', ask: id });
115
+ return { ok: true, id, state: 'working' };
116
+ }
117
+ if (path !== '/api/meeting-focus/topic') return { ok: true };
118
+ if (!text) return { ok: false, error: 'Name the topic.' };
119
+ const key = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
120
+ let row = S.topics.find((t) => t.id === key || t.text.toLowerCase() === text.toLowerCase());
121
+ if (!row) { row = { id: key, text, src: 'pm', at: NOW, by: 'you' }; S.topics.push(row); }
122
+ const closeOpen = () => { const r = S.runs.find((x) => x[2] == null); if (r) r[2] = NOW; };
123
+ if (b.action === 'set') { closeOpen(); S.parked.delete(row.id); S.current = row.id; S.runs.push([row.id, NOW, null]); }
124
+ else if (b.action === 'park') {
125
+ S.parked.add(row.id);
126
+ if (S.current === row.id) {
127
+ closeOpen(); S.current = null;
128
+ const next = S.topics.find((t) => t.id !== row.id && !S.parked.has(t.id) && !S.runs.some((r) => r[0] === t.id));
129
+ if (next) { S.current = next.id; S.runs.push([next.id, NOW, null]); }
130
+ }
131
+ }
132
+ return { ok: true };
133
+ }
@@ -11,6 +11,7 @@
11
11
  import { el } from './dom.js';
12
12
  import * as D from './c3-demo-data.js';
13
13
  import { installInit } from './c3-demo-init.js';
14
+ import { focusGet, focusPost } from './c3-demo-focus.js'; // Meeting focus: the mockup's Partner API review (GET /api/meeting-focus, its ask poll, and the topic and ask POSTs)
14
15
  import { installBoards } from './c3-demo-boards.js'; // Initiatives boards: answers every /api/boards GET and POST (installed outermost, in installDemo)
15
16
  import './c3-demo-cal-ix.js'; // Calendar and the ribbon: sets window.__c3calix (calendar-views.js reads it in demo only)
16
17
  import './c3-demo-cal3.js'; // Calendar Quarter and Year: answers GET /api/c3-strategy (chained under this file's fetch wrapper)
@@ -51,6 +52,7 @@ function getRoute(path, url) {
51
52
  switch (path) {
52
53
  case '/api/briefing': return D.briefing(url);
53
54
  case '/api/calendar-views': return withCalendarPage(D.calendarViews(url));
55
+ case '/api/meeting-focus': case '/api/meeting-focus/ask': return focusGet(path, url);
54
56
  case '/api/pm-items': return D.pmItems();
55
57
  case '/api/runs': return D.runs();
56
58
  case '/api/releases': return D.releases();
@@ -74,6 +76,7 @@ function getRoute(path, url) {
74
76
  function postRoute(path, body) {
75
77
  let b = {};
76
78
  try { b = body ? JSON.parse(body) : {}; } catch { b = {}; }
79
+ if (path.startsWith('/api/meeting-focus/')) return focusPost(path, body);
77
80
  if (path === '/api/pm-items/add') return { ok: true, item: { id: 'item-demo-new' + (++added), title: String(b.line || '').trim() || 'New item', due: null, priority: null, waiting_on: null, job: null, steps: [], created_at: D.NOW_ISO + 'Z' } };
78
81
  if (path === '/api/pm-items/remove') return { ok: true, undo_ms: 8000 };
79
82
  if (path === '/api/calendar-links') return (window.__c3links = (window.__c3links || []).concat([b])), { ok: true, link: b.kind === 'none' ? null : { kind: b.kind, ws: b.ws || null, board: b.board || null, initiative: b.initiative || null, title: b.title || null, by: 'pm:wawan' } }; // nothing is saved