create-pmos 0.203.0 → 0.205.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,195 @@
1
+ /* popup.js — the PMOS button's popup (popup.html): "Add this page to a report design".
2
+ * PMOS draws the popup: the page is one frame, ${address}/c3.html?capture=1&pop=1&…, which lists the reports and has the Add button. The extension never posts to PMOS
3
+ * itself, because every PMOS write needs a press on a PMOS page (a nonce and an Origin check). So when the frame asks (postMessage { pmosCapture: 'request' }), this
4
+ * page takes the open tab and posts it back to the frame ({ pmosCapture: 'data', … }), and the frame uploads it with its own press. Both ways, the message must
5
+ * come from the other window and from the PMOS origin; the extension posts to the PMOS origin only.
6
+ * Google Slides and Google Docs → the PDF, fetched here with the user's own Google cookies (host permission for https://docs.google.com/*)
7
+ * any other page → pictures of the whole page, scrolled top to bottom: at most 8, JPEG, 650 ms apart (Chrome's capture rate limit), scroll put back after
8
+ * When nothing answers at the address, this page says so and offers Try again. Nothing else is fetched. */
9
+ (function () {
10
+ 'use strict';
11
+ var A = window.PMOS_ADDRESS;
12
+ var C = window.PMOS_CAPTURE;
13
+ var CHECK_MS = 4000; // how long to wait for PMOS to answer
14
+ var READY_MS = 8000; // how long to wait for the frame to say it is drawn
15
+ var SETTLE_MS = 300; // after a scroll, before the picture
16
+ var msg = document.getElementById('msg');
17
+ var frame = null;
18
+ var frameOrigin = '';
19
+ var readyTimer = null;
20
+ var busy = false;
21
+ var current = null; // { tab, kind, address }
22
+
23
+ function readAddress(done) {
24
+ var fallback = A.DEFAULT_ADDRESS;
25
+ try {
26
+ var o = {}; o[A.KEY] = fallback;
27
+ chrome.storage.sync.get(o, function (v) { done(A.normalizeAddress(v && v[A.KEY]) || fallback); });
28
+ } catch (e) { done(fallback); }
29
+ }
30
+ function getTab(done) {
31
+ try { chrome.tabs.query({ active: true, currentWindow: true }, function (t) { done((t && t[0]) || null); }); } catch (e) { done(null); }
32
+ }
33
+ /** Resolves when something answers at the address (an opaque no-cors reply is enough), rejects when nothing listens. */
34
+ function answers(addr) {
35
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null;
36
+ var t = ctl ? setTimeout(function () { ctl.abort(); }, CHECK_MS) : null;
37
+ return fetch(addr + '/c3.html', { mode: 'no-cors', cache: 'no-store', signal: ctl ? ctl.signal : undefined })
38
+ .then(function (r) { if (t) clearTimeout(t); return r; }, function (e) { if (t) clearTimeout(t); throw e; });
39
+ }
40
+ var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
41
+
42
+ /* ── the popup's own states ── */
43
+ function head(host) {
44
+ var h = document.createElement('div'); h.className = 'head';
45
+ var b = document.createElement('b'); b.textContent = 'PMOS'; h.appendChild(b);
46
+ var s = document.createElement('span'); s.textContent = 'Add this page to a report design'; h.appendChild(s);
47
+ host.appendChild(h);
48
+ }
49
+ function show(text, withRetry, extra) {
50
+ if (frame) { frame.remove(); frame = null; }
51
+ clearTimeout(readyTimer);
52
+ msg.textContent = '';
53
+ msg.className = 'msg';
54
+ if (!msg.parentNode) document.body.appendChild(msg);
55
+ head(msg);
56
+ var p = document.createElement('p'); p.textContent = text; msg.appendChild(p);
57
+ if (extra) msg.appendChild(extra);
58
+ if (withRetry) {
59
+ var b = document.createElement('button'); b.type = 'button'; b.textContent = 'Try again'; b.setAttribute('data-act', 'retry');
60
+ b.addEventListener('click', function () { start(); });
61
+ msg.appendChild(b);
62
+ }
63
+ }
64
+ function showDown(addr) {
65
+ var a = document.createElement('a'); a.href = '#'; a.textContent = 'Set the address';
66
+ a.addEventListener('click', function (e) { e.preventDefault(); try { chrome.runtime.openOptionsPage(); } catch (x) { /* no options page */ } });
67
+ show('PMOS is not answering at ' + addr + '. Start PMOS, then try again.', true, a);
68
+ }
69
+
70
+ /* ── the frame ── */
71
+ function post(m) {
72
+ if (!frame || !frame.contentWindow || !C.validOutgoing(m)) return false;
73
+ frame.contentWindow.postMessage(m, frameOrigin);
74
+ return true;
75
+ }
76
+ function showFrame(tab, kind, addr, screens) {
77
+ var q = new URLSearchParams({ capture: '1', pop: '1', pkind: kind, ptitle: C.sampleName(tab.title, tab.url), purl: tab.url || '' });
78
+ if (screens) q.set('pscreens', String(screens));
79
+ frame = document.createElement('iframe');
80
+ frame.title = 'PMOS: add this page to a report design';
81
+ frame.src = addr + '/c3.html?' + q.toString();
82
+ frameOrigin = new URL(addr).origin;
83
+ if (msg.parentNode) msg.remove();
84
+ document.body.appendChild(frame);
85
+ // an older PMOS answers but has no capture page: it never says ready
86
+ readyTimer = setTimeout(function () { showDown(addr); }, READY_MS);
87
+ }
88
+
89
+ /* ── what is taken ── */
90
+ var toBase64 = function (buf) {
91
+ var bytes = new Uint8Array(buf); var s = '';
92
+ for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
93
+ return btoa(s);
94
+ };
95
+ var run = function (tabId, func, args) {
96
+ return chrome.scripting.executeScript({ target: { tabId: tabId }, func: func, args: args || [] }).then(function (r) { return r && r[0] ? r[0].result : undefined; });
97
+ };
98
+ var measure = function () { var d = document.documentElement; var b = document.body; return { h: Math.max(d ? d.scrollHeight : 0, b ? b.scrollHeight : 0), vh: window.innerHeight, y: window.scrollY }; };
99
+ var scrollTo = function (y) { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); return window.scrollY; };
100
+
101
+ function takePdf(tab, kind) {
102
+ var url = C.exportUrl(tab.url);
103
+ post({ pmosCapture: 'progress', text: 'Getting the PDF from Google', step: 1, of: 3 });
104
+ return fetch(url, { credentials: 'include', cache: 'no-store', redirect: 'follow' }).then(function (r) {
105
+ if (!r.ok) throw new Error('Google did not give the PDF (' + r.status + '). Open the file in Chrome while signed in to Google, then try again.');
106
+ return r.arrayBuffer();
107
+ }, function () { throw new Error('Chrome could not reach Google to get the PDF. Check the connection, then try again.'); }).then(function (buf) {
108
+ var bytes = new Uint8Array(buf);
109
+ if (!(bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46)) throw new Error('Google did not give a PDF. Sign in to Google in this Chrome, make sure you can open the file, then try again.');
110
+ if (bytes.length > C.MAX_BYTES) throw new Error('The PDF is over 20 MB. PMOS keeps a sample up to 20 MB. Add a smaller copy.');
111
+ post({ pmosCapture: 'progress', text: 'Reading the PDF', step: 2, of: 3 });
112
+ var pages = C.pdfPageCount(bytes);
113
+ return { pmosCapture: 'data', kind: 'pdf', name: C.sampleName(tab.title, tab.url), url: tab.url, pages: pages || undefined, data: toBase64(buf) };
114
+ });
115
+ }
116
+
117
+ function takePictures(tab) {
118
+ var tabId = tab.id; var windowId = tab.windowId;
119
+ var start = null; var pics = []; var bytes = 0; var last = 0;
120
+ return run(tabId, measure).then(function (m) {
121
+ if (!m || !m.vh) throw new Error('Chrome does not let PMOS read this page.');
122
+ start = m.y || 0;
123
+ var plan = C.scrollPlan(m.h, m.vh, C.MAX_PICTURES);
124
+ var n = plan.offsets.length;
125
+ var chain = Promise.resolve();
126
+ plan.offsets.forEach(function (y, i) {
127
+ chain = chain.then(function () {
128
+ post({ pmosCapture: 'progress', text: 'Picture ' + (i + 1) + ' of ' + n + (i ? ', scrolling down' : ''), step: i + 1, of: n + 1 });
129
+ return run(tabId, scrollTo, [y]);
130
+ }).then(function () {
131
+ var wait = Math.max(SETTLE_MS, C.MIN_GAP_MS - (Date.now() - last));
132
+ return sleep(wait);
133
+ }).then(function () {
134
+ last = Date.now();
135
+ return chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 });
136
+ }).then(function (dataUrl) {
137
+ var b64 = String(dataUrl || '').replace(/^data:[^,]*,/, '');
138
+ if (!b64) throw new Error('Chrome did not give a picture of the page.');
139
+ bytes += C.base64Bytes(b64);
140
+ if (bytes > C.MAX_BYTES) throw new Error('The pictures are over 20 MB. PMOS keeps a sample up to 20 MB. Open a shorter page, or add it as a PDF.');
141
+ pics.push(b64);
142
+ });
143
+ });
144
+ return chain;
145
+ }).then(function () {
146
+ return { pmosCapture: 'data', kind: 'pictures', name: C.sampleName(tab.title, tab.url), url: tab.url, pictures: pics };
147
+ }).then(function (out) { return restore(tabId, start).then(function () { return out; }); },
148
+ function (e) { return restore(tabId, start).then(function () { throw e; }); });
149
+ }
150
+ function restore(tabId, y) { return y == null ? Promise.resolve() : run(tabId, scrollTo, [y]).catch(function () { /* the page went away */ }); }
151
+
152
+ function capture() {
153
+ if (busy || !current) return;
154
+ busy = true;
155
+ var tab = current.tab;
156
+ var job = C.exportUrl(tab.url) ? takePdf(tab, current.kind) : takePictures(tab);
157
+ job.then(function (out) {
158
+ post({ pmosCapture: 'progress', text: 'Sending it to PMOS', step: out.kind === 'pdf' ? 3 : (out.pictures.length + 1), of: out.kind === 'pdf' ? 3 : (out.pictures.length + 1) });
159
+ if (!post(out)) throw new Error('The page could not be sent to PMOS.');
160
+ }).catch(function (e) {
161
+ post({ pmosCapture: 'error', error: String((e && e.message) || 'The page could not be taken.') });
162
+ }).then(function () { busy = false; });
163
+ }
164
+
165
+ window.addEventListener('message', function (e) {
166
+ var m = C.fromFrame(e, frame && frame.contentWindow, frameOrigin);
167
+ if (!m) return;
168
+ if (m.pmosCapture === 'ready') { clearTimeout(readyTimer); return; }
169
+ if (m.pmosCapture === 'resize') { if (frame) frame.style.height = m.height + 'px'; return; }
170
+ if (m.pmosCapture === 'close') { window.close(); return; }
171
+ if (m.pmosCapture === 'open') {
172
+ try { chrome.tabs.create({ url: frameOrigin + '/c3.html?studio=' + encodeURIComponent(m.report) }); } catch (x) { /* no tabs */ }
173
+ window.close();
174
+ return;
175
+ }
176
+ if (m.pmosCapture === 'request') capture();
177
+ });
178
+
179
+ function start() {
180
+ busy = false;
181
+ readAddress(function (addr) {
182
+ getTab(function (tab) {
183
+ var kind = tab ? C.classifyUrl(tab.url) : 'blocked';
184
+ if (!tab || kind === 'blocked') { show('Chrome does not let PMOS read this page. Open a web page, such as a Google Slides deck or any site, then press the PMOS button.', false); return; }
185
+ current = { tab: tab, kind: kind, address: addr };
186
+ var go = function (screens) {
187
+ answers(addr).then(function () { showFrame(tab, C.pageKind(tab.url), addr, screens); }, function () { showDown(addr); });
188
+ };
189
+ if (kind !== 'other') { go(0); return; }
190
+ run(tab.id, measure).then(function (m) { go(m && m.vh ? C.scrollPlan(m.h, m.vh, 1000).total : 0); }, function () { go(0); });
191
+ });
192
+ });
193
+ }
194
+ start();
195
+ })();
@@ -22,7 +22,7 @@ Use one of these two buttons:
22
22
 
23
23
  1. Select **Deck** or **Document** at the top of the studio.
24
24
  2. Write what the report is for and who reads it. For example: "This goes to the progress session every other Thursday. The decisions must stand out."
25
- 3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS cannot open a link, so a link sample tells you which file to add. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
25
+ 3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, add a page from Chrome (see below), or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
26
26
  4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready.
27
27
  5. Read the version in the preview. Write what to change and press **Make the next version** again. Each turn makes a new version: D1, D2, D3 and so on.
28
28
 
@@ -30,6 +30,20 @@ To set the width of the studio, use **Half**, **3/4** or **Whole** at the top ri
30
30
 
31
31
  If a turn fails, the studio shows why. Nothing changes. Try the turn again.
32
32
 
33
+
34
+ ### Add a page from Chrome
35
+
36
+ You can add the page you are looking at in Chrome, on any website, as a sample. This needs the PMOS extension in Chrome. Run `pmos extension` in a terminal for the steps to load it.
37
+
38
+ 1. In Chrome, open the page, for example a Google Slides deck, a Google Doc or a Confluence page.
39
+ 2. Press the **PMOS** button in the Chrome toolbar. The popup says "Add this page to a report design".
40
+ 3. Choose the report in the **Report** list. It starts on the report whose design studio you opened last.
41
+ 4. Press **Add as a sample**. Keep the popup open while it saves. When it says "Added to" and the name of the report, press **Open the studio**.
42
+
43
+ A Google Slides deck or a Google Doc comes to PMOS as a PDF, so PMOS can follow its look. The sample reads "From Chrome, PDF, 12 slides", or "pages" for a document. Chrome asks Google for the PDF with your own Google sign-in. Any other page comes as pictures of the whole page, scrolled top to bottom, at most 8 pictures. The sample reads "From Chrome, 3 page pictures". PMOS reads every picture, and follows its look as it does for a PDF. A sample is at most 20 MB. If the page is bigger, the popup says so: open a shorter page, or add a smaller copy.
44
+
45
+ The sample shows in a design studio that is already open, in a few seconds. If the popup says "PMOS is not answering", start PMOS and press **Try again**. Nothing leaves your computer: the popup sends the page only to your own PMOS.
46
+
33
47
  ### Point at a part
34
48
 
35
49
  Keep **Point and say** on. Click a part of the preview and write what to change there. A numbered marker shows the spot. Your next turn sends the markers with your words.
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- /* extension-cli.js — `pmos extension`: where the Chrome extension for Google Meet is, and how to load it (initiative c3-meet-extension).
2
+ /* extension-cli.js — `pmos extension`: where the PMOS Chrome extension is (the Meet panel and the button that adds a page to a report design), and how to load it (initiatives c3-meet-extension, report-design-studio).
3
3
  *
4
4
  * pmos extension print the absolute path of workspace/extension and the steps to load it in Chrome
5
5
  *
@@ -10,20 +10,22 @@ const path = require('path');
10
10
 
11
11
  const COMMANDS = { extension: [] };
12
12
  const FOLDER = path.resolve(__dirname, '..', 'extension');
13
- const USAGE = ['usage: pmos extension — the Chrome extension that shows the meeting companion over a Google Meet call'];
13
+ const USAGE = ['usage: pmos extension — the Chrome extension: the meeting companion over a Google Meet call, and a button that adds a page to a report design'];
14
14
 
15
15
  /** The lines `pmos extension` prints for a folder. Pure. */
16
16
  function lines(folder, exists = true) {
17
17
  const out = [];
18
18
  if (!exists) { out.push(`✗ the extension folder is not here: ${folder}`, ' Reinstall the workspace with `pmos upgrade`.'); return out; }
19
- out.push('PMOS for Google Meet (Chrome)', '', ` folder ${folder}`, '',
19
+ out.push('PMOS for Chrome', '', ` folder ${folder}`, '',
20
20
  'Load it in three steps:',
21
21
  ' 1. In Chrome, open chrome://extensions',
22
22
  ' 2. Turn on Developer mode (top right)',
23
23
  ' 3. Press Load unpacked, then pick the folder above',
24
24
  '',
25
25
  'PMOS is looked for at http://localhost:7433. If pmos printed another port, open the extension\'s Options and set that address.',
26
- 'Then open a Google Meet call: the panel shows at the bottom right.');
26
+ 'Then open a Google Meet call: the panel shows at the bottom right.',
27
+ 'To add a page to a report design, open any page and press the PMOS button in the toolbar. Choose the report, then Add as a sample.',
28
+ 'After an update, press the reload arrow on the extension\'s card in chrome://extensions.');
27
29
  return out;
28
30
  }
29
31
 
@@ -3,7 +3,7 @@
3
3
  * (initiative workspace-headless-jobs; PM decision 2026-10-11: keep Jev, no direct Claude API, background
4
4
  * jobs that need no human run headless).
5
5
  *
6
- * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | null (null: open a pane, as before)
6
+ * kindFor({ agent, origin, skill, env }) → 'notes' | 'skill' | 'ask' | 'drive' | 'scan-web' | 'scan-repo' | 'scan-research-web' | 'scan-research-repo' | 'report-setup' | 'report-find' | 'core-doc' | 'report-design' | 'report-sample-fetch' | null (null: open a pane, as before)
7
7
  * argsFor(kind, { modelArgs, addDirs, writePaths }) → the argv after `claude` (writePaths: the one render file a report-design job may write)
8
8
  * parseResult(stdout) → { ok, text, isError, turns, durationMs, costUsd, denied[] }
9
9
  * streamLines(line) → { log: [lines], result: object|null } one stream-json event, for the live log
@@ -80,7 +80,9 @@ const REPORT_FIND_ORIGINS = Object.freeze(['report-find:']);
80
80
  const CORE_DOC_ORIGINS = Object.freeze(['core-doc:']);
81
81
  // report-design-studio: a studio turn, a cycle render or a re-render to match (origin `report-design:<cadence>`).
82
82
  const REPORT_DESIGN_ORIGINS = Object.freeze(['report-design:']);
83
- const WRITE_PATH_KINDS = Object.freeze(['report-design']); // kinds whose one writable file the caller names (argsFor writePaths)
83
+ // report-design-studio: reading ONE linked Google or Confluence file through the PM's own connectors (origin `report-sample-fetch:<cadence>:<sampleId>`).
84
+ const REPORT_SAMPLE_FETCH_ORIGINS = Object.freeze(['report-sample-fetch:']);
85
+ const WRITE_PATH_KINDS = Object.freeze(['report-design', 'report-sample-fetch']); // kinds whose one writable file the caller names (argsFor writePaths)
84
86
 
85
87
  // What each kind may use. Read-only search is always allowed. The notes passes write their JSON (and rename the .tmp
86
88
  // file) in their notes folder, which --add-dir opens to them; the skills also run `pmos` verbs and repository scripts.
@@ -110,6 +112,8 @@ const TOOLS = Object.freeze({
110
112
  'core-doc': Object.freeze(['Read', 'Glob', 'Grep', 'Bash(pmos coredoc write *)']),
111
113
  // The design run: read and search only. Its one write, the render file, is added per run by argsFor (writePaths). No Bash, no web, nothing that sends.
112
114
  'report-design': Object.freeze(['Read', 'Glob', 'Grep']),
115
+ // The link fetch run: Read, and its one write (the text copy of the linked file) is added per run by argsFor. No Bash, no web, no connector tool named.
116
+ 'report-sample-fetch': Object.freeze(['Read']),
113
117
  skill: Object.freeze(['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash(pmos *)', 'Bash(node *)',
114
118
  'Bash(git log *)', 'Bash(git status *)', 'Bash(git diff *)', 'Bash(ls *)', 'Bash(date *)']),
115
119
  });
@@ -134,6 +138,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
134
138
  if (REPORT_FIND_ORIGINS.some((p) => o.startsWith(p))) return 'report-find';
135
139
  if (CORE_DOC_ORIGINS.some((p) => o.startsWith(p))) return 'core-doc';
136
140
  if (REPORT_DESIGN_ORIGINS.some((p) => o.startsWith(p))) return 'report-design';
141
+ if (REPORT_SAMPLE_FETCH_ORIGINS.some((p) => o.startsWith(p))) return 'report-sample-fetch';
137
142
  if (skill && HEADLESS_SKILLS.includes(String(skill))) return 'skill';
138
143
  return null;
139
144
  }
@@ -141,7 +146,7 @@ function kindFor({ agent, origin, skill, env } = {}) {
141
146
  function argsFor(kind, { modelArgs = [], addDirs = [], writePaths = [] } = {}) {
142
147
  if (!TOOLS[kind]) throw new Error('unknown headless kind: ' + kind);
143
148
  const tools = TOOLS[kind].slice();
144
- // A report-design run may write its render file and nothing else: one absolute path, no wildcard, no comma.
149
+ // A report-design run may write its render file (a report-sample-fetch run, its text copy) and nothing else: one absolute path, no wildcard, no comma.
145
150
  if (WRITE_PATH_KINDS.includes(kind)) {
146
151
  for (const p of Array.isArray(writePaths) ? writePaths : []) {
147
152
  const s = String(p || '');
@@ -260,4 +265,4 @@ function logLines(r, exitCode) {
260
265
  return out;
261
266
  }
262
267
 
263
- module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
268
+ module.exports = { HEADLESS_SKILLS, HEADLESS_ORIGINS, ASK_ORIGINS, DRIVE_ORIGINS, SCAN_WEB_ORIGINS, SCAN_REPO_ORIGINS, SCAN_RESEARCH_WEB_ORIGINS, SCAN_RESEARCH_REPO_ORIGINS, WEB_KINDS, REPORT_SETUP_ORIGINS, REPORT_FIND_ORIGINS, CORE_DOC_ORIGINS, REPORT_DESIGN_ORIGINS, REPORT_SAMPLE_FETCH_ORIGINS, TOOLS, DEFAULT_TIMEOUT_MS, kindFor, argsFor, timeoutMs, parseResult, streamLines, outcomeOf, logLines, switchedOff };
@@ -19,6 +19,7 @@
19
19
  * THE LINES of design.jsonl. Every line is { ev, …, at, actor }. `actor` is the guard's actor for a press, or { by: 'pmos', via: 'job' } for a job:
20
20
  * describe { text, format } the PM's plain words, and Deck or Document
21
21
  * sample { id, name, kind, source, ext, readable } a sample was added (the file is samples/<id>.<ext>, its record samples/<id>.json)
22
+ * fetched { id, ok, via?, title?, reason? } a link sample was read through the PM's connectors (ok), or could not be (reason); the words are samples/<id>.txt
22
23
  * took { id, took, readable } what a job took from a sample (or that it could not read it)
23
24
  * turn { text, points, base, job } a refine turn started a job; `base` is the version it builds on
24
25
  * version { n, format, turn, answer, from } a job (or an adopted cycle) landed D<n>
@@ -44,9 +45,10 @@ const zlib = require('zlib');
44
45
  const RD = require('./report-draft.js');
45
46
  const issues = require('./issues.js');
46
47
 
47
- const EVENTS = Object.freeze(['describe', 'sample', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
48
+ const EVENTS = Object.freeze(['describe', 'sample', 'fetched', 'took', 'turn', 'version', 'accept', 'revert', 'error', 'cycle-decision']);
48
49
  const FORMATS = Object.freeze(['deck', 'doc']);
49
- const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'link', 'pmos-doc']);
50
+ const SAMPLE_KINDS = Object.freeze(['pdf', 'pptx', 'docx', 'image', 'pictures', 'link', 'pmos-doc']);
51
+ const MAX_PICTURES = 12;
50
52
  const MAX_SAMPLE_BYTES = 20 * 1024 * 1024;
51
53
  const MAX_THUMB_BYTES = 256 * 1024;
52
54
  const MAX_TEXT = 4000;
@@ -117,8 +119,12 @@ function fold(events) {
117
119
  if (FORMATS.includes(l.format)) st.format = l.format;
118
120
  st.log.push({ at: l.at, who: 'you', kind: 'describe', text: str(l.text) });
119
121
  } else if (l.ev === 'sample') {
120
- st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at });
122
+ st.samples.set(l.id, { id: l.id, name: str(l.name), kind: l.kind, source: str(l.source), ext: str(l.ext), took: null, readable: l.readable !== false, at: l.at,
123
+ ...(l.via === 'chrome' ? { via: 'chrome' } : {}), ...(Number.isInteger(l.count) ? { count: l.count } : {}), ...(Number.isInteger(l.pages) ? { pages: l.pages } : {}) });
121
124
  st.log.push({ at: l.at, who: 'you', kind: 'sample', text: str(l.name), sample: l.id });
125
+ } else if (l.ev === 'fetched') {
126
+ const s = st.samples.get(l.id);
127
+ if (s) { s.readable = l.ok === true; s.fetched = l.ok === true; s.fetch_reason = l.ok === true ? null : str(l.reason) || 'no reason'; }
122
128
  } else if (l.ev === 'took') {
123
129
  const s = st.samples.get(l.id);
124
130
  if (s) { if (typeof l.took === 'string' && l.took) s.took = l.took; if (typeof l.readable === 'boolean') s.readable = l.readable; }
@@ -204,7 +210,7 @@ function addSample(root, cadence, input, { actor, now } = {}) {
204
210
  let u; try { u = new URL(link); } catch { return refuse(400, 'link must be a full address, such as https://…'); }
205
211
  if (u.protocol !== 'https:' && u.protocol !== 'http:') return refuse(400, 'link must be an http or https address');
206
212
  if (link.length > 2000) return refuse(400, 'link is too long');
207
- // the job reads files only (Read, Glob, Grep), so a link is kept by its address and the PM is told how to add a copy PMOS can read
213
+ // the job reads files only (Read, Glob, Grep), so a link is kept by its address; a Google or Confluence link is then read by the fetch run (recordFetched)
208
214
  rec = { id, name: oneLine(input.name || linkName(u), 120), kind: 'link', source: link, ext: '', readable: false, thumb: null, text_file: null };
209
215
  } else if (typeof input.doc === 'string' && input.doc.trim()) {
210
216
  const rel = input.doc.trim().replace(/\\/g, '/');
@@ -216,6 +222,32 @@ function addSample(root, cadence, input, { actor, now } = {}) {
216
222
  writeAtomic(path.join(p.samples, `${id}.md`), text);
217
223
  const fm = /^---\r?\n[\s\S]*?\btitle:\s*["']?([^\n"']+)/.exec(text);
218
224
  rec = { id, name: oneLine(input.name || (fm ? fm[1] : rel.split('/').slice(-2).join(' ')), 120), kind: 'pmos-doc', source: rel, ext: 'md', readable: true, thumb: null, text_file: null };
225
+ } else if (str(input.kind) === 'pictures' || Array.isArray(input.pictures)) {
226
+ // pictures of a whole page, scrolled top to bottom, from the Chrome extension (the page frames PMOS; see extension/popup.js)
227
+ const list = Array.isArray(input.pictures) ? input.pictures : null;
228
+ if (str(input.kind) !== 'pictures' || !list || !list.length) return refuse(400, 'pictures needs kind "pictures" and a list of pictures (base64 JPEG or PNG)');
229
+ if (list.length > MAX_PICTURES) return refuse(400, `at most ${MAX_PICTURES} pictures in one sample`);
230
+ const bufs = [];
231
+ let total = 0;
232
+ for (const [i, raw] of list.entries()) {
233
+ if (typeof raw !== 'string' || !raw) return refuse(400, `picture ${i + 1} is empty`);
234
+ const b64 = raw.replace(/^data:[^,]*,/, '');
235
+ total += Math.floor(b64.length * 3 / 4);
236
+ if (total > MAX_SAMPLE_BYTES + 4 * list.length) return refuse(413, 'a sample is at most 20 MB');
237
+ const buf = Buffer.from(b64, 'base64');
238
+ const s = buf.length ? sniff(buf) : null;
239
+ if (!s || s.kind !== 'image') return refuse(415, `picture ${i + 1} must be a PNG or a JPG`);
240
+ bufs.push({ buf, ext: s.ext });
241
+ }
242
+ if (bufs.reduce((n, b) => n + b.buf.length, 0) > MAX_SAMPLE_BYTES) return refuse(413, 'a sample is at most 20 MB');
243
+ fs.mkdirSync(p.samples, { recursive: true });
244
+ const files = bufs.map((b, i) => { const f = `${id}-${i + 1}.${b.ext}`; writeAtomic(path.join(p.samples, f), b.buf); return f; });
245
+ const first = bufs[0];
246
+ const thumb = first.buf.length <= MAX_THUMB_BYTES ? `data:image/${first.ext === 'jpg' ? 'jpeg' : 'png'};base64,${first.buf.toString('base64')}` : null;
247
+ const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
248
+ const u = parseUrl(src);
249
+ const source = u && /^https?:$/.test(u.protocol) && src.length <= 2000 ? src : 'upload';
250
+ rec = { id, name: oneLine(input.name || 'Page pictures', 120), kind: 'pictures', source, ext: first.ext, count: files.length, files, via: 'chrome', bytes: bufs.reduce((n, b) => n + b.buf.length, 0), readable: true, thumb, text_file: null };
219
251
  } else {
220
252
  const kind = str(input.kind);
221
253
  if (!['pdf', 'pptx', 'docx', 'image'].includes(kind)) return refuse(400, 'kind must be pdf, pptx, docx or image (or send a link, or a report doc)');
@@ -240,27 +272,78 @@ function addSample(root, cadence, input, { actor, now } = {}) {
240
272
  try { thumb = ooxmlThumb(buf); } catch { thumb = null; }
241
273
  }
242
274
  rec = { id, name: oneLine(input.name || `${kind} sample`, 120), kind, source: 'upload', ext, bytes: buf.length, readable, thumb, text_file: textFile };
275
+ if (kind === 'pdf' && input.from === 'chrome') {
276
+ // a PDF the Chrome extension fetched: where it came from and how many pages it has
277
+ const src = typeof input.source_url === 'string' ? input.source_url.trim() : '';
278
+ const u = parseUrl(src);
279
+ if (u && /^https?:$/.test(u.protocol) && src.length <= 2000) rec.source = src;
280
+ rec.via = 'chrome';
281
+ const n = Number(input.pages);
282
+ if (Number.isInteger(n) && n > 0 && n < 5000) rec.pages = n;
283
+ }
243
284
  }
244
285
  writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify({ ...rec, took: null, added_at: iso(now) }, null, 2) + '\n');
245
- append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable }, { actor, now });
286
+ append(root, cadence, { ev: 'sample', id, name: rec.name, kind: rec.kind, source: rec.source, ext: rec.ext, readable: rec.readable,
287
+ ...(rec.via ? { via: rec.via } : {}), ...(rec.count ? { count: rec.count } : {}), ...(rec.pages ? { pages: rec.pages } : {}) }, { actor, now });
246
288
  return { ok: true, sample: sampleOut(rec) };
247
289
  }
248
- function sampleOut(s) {
290
+ function sampleOut(s, { fetching } = {}) {
249
291
  const out = { id: s.id, name: s.name, kind: s.kind, source: s.source, took: s.took || null, thumb: typeof s.thumb === 'string' && s.thumb.startsWith('data:image/') ? s.thumb : null, readable: s.readable !== false };
250
- if (s.kind === 'link') out.hint = linkHint(s.source);
292
+ if (s.via === 'chrome') {
293
+ out.via = 'chrome';
294
+ out.line = chromeLine(s);
295
+ if (s.count) out.count = s.count;
296
+ if (s.pages) out.pages = s.pages;
297
+ }
298
+ if (s.kind === 'link') {
299
+ if (fetching) { out.fetching = true; out.hint = 'PMOS is reading this link with your connectors.'; }
300
+ else out.hint = linkHint(s.source, { fetched: !!(s.text_file && s.fetched), reason: s.fetch_reason });
301
+ }
251
302
  return out;
252
303
  }
304
+ /** The kind line of a sample the Chrome extension added: 'From Chrome, PDF, 12 slides' | 'From Chrome, PDF, 3 pages' | 'From Chrome, 3 page pictures'. */
305
+ function chromeLine(s) {
306
+ if (s.kind === 'pictures') { const n = s.count || 0; return n ? `From Chrome, ${n} page ${n === 1 ? 'picture' : 'pictures'}` : 'From Chrome, page pictures'; }
307
+ const g = googleOf(parseUrl(s.source));
308
+ const n = s.pages || 0;
309
+ return n ? `From Chrome, PDF, ${n} ${g && g[2] === 'Slides' ? (n === 1 ? 'slide' : 'slides') : (n === 1 ? 'page' : 'pages')}` : 'From Chrome, PDF';
310
+ }
253
311
  /* Google files: name them for what they are, and say which download PMOS can read */
254
312
  // a PDF, because the job sees its pages; from a .pptx or .docx it gets the words only, not the look
255
313
  const GOOGLE = [[/^\/presentation\//, 'Google Slides deck', 'Slides'], [/^\/document\//, 'Google Docs document', 'Docs'], [/^\/spreadsheets\//, 'Google Sheets sheet', 'Sheets']];
256
314
  const googleOf = (u) => (u && /(^|\.)docs\.google\.com$/i.test(u.hostname) ? GOOGLE.find((g) => g[0].test(u.pathname)) || null : null);
257
315
  const parseUrl = (v) => { try { return new URL(String(v || '')); } catch { return null; } };
258
316
  function linkName(u) { const g = googleOf(u); return g ? g[1] : u.hostname + u.pathname; }
259
- /** What the PM sees under a link sample: PMOS cannot open web pages, and how to add a copy it can read. */
260
- function linkHint(source) {
317
+ /** What the PM sees under a link sample: what PMOS read through the connectors, or why it did not, and how to add a copy that shows the look. */
318
+ function linkHint(source, { fetched = false, reason = null } = {}) {
261
319
  const g = googleOf(parseUrl(source));
262
- if (g) return `PMOS cannot open ${g[2]} from a link, because it needs your Google sign-in. In ${g[2]}, choose File, Download, PDF document (.pdf), then add that file here. PMOS sees the pages of a PDF, so it can follow their look.`;
263
- return 'PMOS cannot open web pages. Save the page as a PDF or take a screenshot, then add that file here.';
320
+ const pdfStep = g ? ` In ${g[2]}, choose File, Download, PDF document (.pdf), then add that file here.` : ' Save the page as a PDF or take a screenshot, then add that file here.';
321
+ if (fetched) return `PMOS read the words and structure of this link. To match its look, also add it as a PDF or a screenshot.${g ? pdfStep : ''}`;
322
+ if (g) return `${reason ? 'Your connectors could not read it. ' : ''}PMOS cannot open ${g[2]} from a link, because it needs your Google sign-in. In ${g[2]}, choose File, Download, PDF document (.pdf), then add that file here. PMOS sees the pages of a PDF, so it can follow their look.`;
323
+ return `${reason ? 'Your connectors could not read it. ' : ''}PMOS cannot open web pages. Save the page as a PDF or take a screenshot, then add that file here.`;
324
+ }
325
+ /** 'Google Slides deck' | 'Google Docs document' | 'Google Sheets sheet' for a Google link, else null. */
326
+ function googleKind(source) { const g = googleOf(parseUrl(source)); return g ? g[1] : null; }
327
+ /** True for a link the fetch run can try: a Google Slides, Docs or Sheets file, or a Confluence page. */
328
+ function fetchableLink(source) {
329
+ const u = parseUrl(source);
330
+ if (!u || !/^https?:$/.test(u.protocol)) return false;
331
+ if (googleOf(u)) return true;
332
+ return /(^|\.)atlassian\.net$/i.test(u.hostname) && /^\/wiki\//.test(u.pathname);
333
+ }
334
+ /** A fetch run read the link: samples/<id>.txt holds its words (ok), or the link could not be read (reason). Appends a 'fetched' event. */
335
+ function recordFetched(root, cadence, id, { ok = true, title, via, reason, now } = {}) {
336
+ if (!SAMPLE_ID_RE.test(String(id))) return false;
337
+ const p = paths(root, cadence);
338
+ const rec = sampleRecord(root, cadence, id);
339
+ if (!rec || rec.kind !== 'link') return false;
340
+ const good = ok === true;
341
+ const next = { ...rec, readable: good, text_file: good ? `${id}.txt` : null, fetch_reason: good ? null : oneLine(reason || 'no reason', 300),
342
+ ...(good ? { fetched_at: iso(now), via: oneLine(via || '', 120) || null } : {}) };
343
+ if (good && typeof title === 'string' && title.trim()) next.title = oneLine(title, 160);
344
+ writeAtomic(path.join(p.samples, `${id}.json`), JSON.stringify(next, null, 2) + '\n');
345
+ append(root, cadence, { ev: 'fetched', id, ok: good, ...(good ? { via: next.via, title: next.title || null } : { reason: next.fetch_reason }) }, { now });
346
+ return true;
264
347
  }
265
348
  /** Record what a job took from a sample (also kept in samples/<id>.json). */
266
349
  function recordTook(root, cadence, id, took, readable, { now } = {}) {
@@ -274,21 +357,28 @@ function recordTook(root, cadence, id, took, readable, { now } = {}) {
274
357
  append(root, cadence, { ev: 'took', id, took: t, ...(rd === undefined ? {} : { readable: rd }) }, { now });
275
358
  return true;
276
359
  }
277
- /** The file the job reads for a sample: the pdf or image itself, the extracted words of a .pptx/.docx, the copy of a report doc; null for a link. */
360
+ /** The file the job reads for a sample: the pdf or image itself, the extracted words of a .pptx/.docx, the copy of a report doc, the text copy of a fetched link; null for any other link. */
278
361
  function sampleReadPath(root, cadence, s) {
279
362
  const p = paths(root, cadence);
280
- if (s.kind === 'link') return null;
363
+ if (s.kind === 'pictures') {
364
+ // every picture of the page, in order (samples/<id>-1.jpg, <id>-2.jpg, …); null when none is there
365
+ let names = [];
366
+ try { names = fs.readdirSync(p.samples).filter((f) => f.startsWith(`${s.id}-`) && /^\d+\.(jpg|png)$/.test(f.slice(s.id.length + 1))); } catch { names = []; }
367
+ names.sort((a, b) => parseInt(a.slice(s.id.length + 1), 10) - parseInt(b.slice(s.id.length + 1), 10));
368
+ return names.length ? names.map((f) => path.join(p.samples, f)) : null;
369
+ }
370
+ if (s.kind === 'link') { const f = path.join(p.samples, `${s.id}.txt`); return s.fetched && fs.existsSync(f) ? f : null; }
281
371
  if (s.kind === 'pptx' || s.kind === 'docx') { const f = path.join(p.samples, `${s.id}.txt`); return fs.existsSync(f) ? f : null; }
282
372
  const f = path.join(p.samples, `${s.id}.${s.ext}`);
283
373
  return fs.existsSync(f) ? f : null;
284
374
  }
285
375
 
286
376
  // ── the GET answer ────────────────────────────────────────────────────────────────────────────────────────
287
- function designAnswer(root, cadence, { running } = {}) {
377
+ function designAnswer(root, cadence, { running, fetching } = {}) {
288
378
  const st = stateOf(root, cadence);
289
379
  const samples = [...st.samples.values()].map((s) => {
290
380
  const rec = sampleRecord(root, cadence, s.id) || {};
291
- return sampleOut({ ...s, thumb: rec.thumb || null, took: s.took || rec.took || null });
381
+ return sampleOut({ ...s, thumb: rec.thumb || null, took: s.took || rec.took || null, text_file: rec.text_file || null }, { fetching: !!(fetching && fetching.has(s.id)) });
292
382
  });
293
383
  const versions = [...st.versions.values()].sort((a, b) => a.n - b.n).map((v) => {
294
384
  const m = versionMeta(root, cadence, v.n) || {};
@@ -320,7 +410,7 @@ function writeBrief(root, cadence, setup, { now } = {}) {
320
410
  '## Description', '', st.description ? st.description : 'No description was given.', '', '## Samples', ''];
321
411
  const samples = [...st.samples.values()];
322
412
  if (!samples.length) lines.push('No samples.');
323
- for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
413
+ for (const s of samples) lines.push(`- ${s.name} (${s.kind}${s.kind === 'link' || s.kind === 'pmos-doc' ? ', ' + s.source : ''}${s.via === 'chrome' ? ', from Chrome' : ''}): ${s.took ? 'PMOS took ' + s.took : (s.readable ? 'nothing taken yet' : 'could not be read')}`);
324
414
  lines.push('', '## Reference outline', '');
325
415
  for (const o of (Array.isArray(meta.outline) ? meta.outline : [])) lines.push(`- ${o.id} (${o.kind || 'slide'}): ${o.title || ''}`);
326
416
  const sec = (h, list) => { lines.push('', `## ${h}`, ''); if (!list || !list.length) lines.push('None written yet.'); else for (const x of list) lines.push(`- ${x}`); };
@@ -749,7 +839,7 @@ function currentCycle(root, cadence) {
749
839
  module.exports = {
750
840
  EVENTS, FORMATS, SAMPLE_KINDS, MAX_SAMPLE_BYTES, CHOICES, PIN_OPS, CADENCE_RE, SLOT_RE, SLIDE_ID_RE, PMOS_ACTOR,
751
841
  paths, append, readEvents, fold, stateOf, designAnswer, versionMeta, versionHtml, writeVersion, rulesFor, cleanRules,
752
- addSample, sampleOut, sampleRecord, recordTook, sampleReadPath, writeBrief,
842
+ addSample, sampleOut, sampleRecord, recordTook, recordFetched, fetchableLink, googleKind, sampleReadPath, writeBrief,
753
843
  tokenize, sanitize, cleanCss, outlineOf, formatOf, diffOutline,
754
844
  cyclePaths, readCycle, writeCycle, decideCycle, isIssuedSlot, docSha, currentCycle, previousCycle,
755
845
  notesOf, setSpeaker, pinOp, ooxmlText, ooxmlThumb, unzipSome,