From 1fcfe039a1f2bc84b3220a9e8f758e44ffe4b5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Fischer?= Date: Thu, 5 Nov 2026 13:39:56 +0100 Subject: [PATCH] sanbox --- README.md | 78 +- jcode.py | 738 ++++++++++++++++-- settings.json | 12 + skills/.DS_Store | Bin 0 -> 10244 bytes skills/browser-test/SKILL.md | 120 +++ skills/browser-test/actions-example.js | 31 + skills/browser-test/shot.js | 227 ++++++ skills/code-review/SKILL.md | 90 +++ skills/code-review/checklist.md | 64 ++ skills/code-review/review_prep.py | 243 ++++++ skills/html-file-formats/SKILL.md | 66 ++ skills/html-file-formats/formats.md | 170 ++++ skills/save-load-json/SKILL.md | 83 ++ skills/save-load-json/check_roundtrip.py | 76 ++ skills/save-load-json/js.md | 116 +++ skills/save-load-json/python.md | 109 +++ skills/scientific-deep-research/SKILL.md | 91 +++ skills/scientific-deep-research/papers.py | 366 +++++++++ skills/single-file-webapp/SKILL.md | 154 ++++ skills/single-file-webapp/check_html.py | 85 ++ skills/single-file-webapp/patterns.md | 164 ++++ skills/speech-synthesis/SKILL.md | 84 ++ skills/speech-synthesis/tts.py | 83 ++ .../super-intelligence-write-skill/SKILL.md | 112 +++ skills/threejs-csg/SKILL.md | 35 + skills/threejs-csg/csg.md | 42 + skills/threejs-csg/loaders.md | 82 ++ skills/threejs-csg/template.html | 67 ++ skills/write-my-paper/SKILL.md | 170 ++++ skills/write-my-paper/check_paper.py | 290 +++++++ skills/write-my-paper/results.py | 536 +++++++++++++ skills/write-my-paper/scan_experiments.py | 298 +++++++ skills/write-my-paper/template/main.tex | 65 ++ skills/write-my-paper/writing-guide.md | 107 +++ 34 files changed, 4965 insertions(+), 89 deletions(-) create mode 100644 skills/.DS_Store create mode 100644 skills/browser-test/SKILL.md create mode 100644 skills/browser-test/actions-example.js create mode 100644 skills/browser-test/shot.js create mode 100644 skills/code-review/SKILL.md create mode 100644 skills/code-review/checklist.md create mode 100644 skills/code-review/review_prep.py create mode 100644 skills/html-file-formats/SKILL.md create mode 100644 skills/html-file-formats/formats.md create mode 100644 skills/save-load-json/SKILL.md create mode 100644 skills/save-load-json/check_roundtrip.py create mode 100644 skills/save-load-json/js.md create mode 100644 skills/save-load-json/python.md create mode 100644 skills/scientific-deep-research/SKILL.md create mode 100644 skills/scientific-deep-research/papers.py create mode 100644 skills/single-file-webapp/SKILL.md create mode 100644 skills/single-file-webapp/check_html.py create mode 100644 skills/single-file-webapp/patterns.md create mode 100644 skills/speech-synthesis/SKILL.md create mode 100644 skills/speech-synthesis/tts.py create mode 100644 skills/super-intelligence-write-skill/SKILL.md create mode 100644 skills/threejs-csg/SKILL.md create mode 100644 skills/threejs-csg/csg.md create mode 100644 skills/threejs-csg/loaders.md create mode 100644 skills/threejs-csg/template.html create mode 100644 skills/write-my-paper/SKILL.md create mode 100644 skills/write-my-paper/check_paper.py create mode 100644 skills/write-my-paper/results.py create mode 100644 skills/write-my-paper/scan_experiments.py create mode 100644 skills/write-my-paper/template/main.tex create mode 100644 skills/write-my-paper/writing-guide.md diff --git a/README.md b/README.md index 6a4bfac..73f7912 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,24 @@ jcode connects to a local **LM Studio** or **Ollama** server (or, via `settings. ## Features - **Autonomous test-fix loop** — the agent writes a test for every change, runs it, analyzes failures, fixes the code, and repeats until green (up to `MAX_STEPS`, default 200). -- **Sandboxed** — all file operations are confined to `./code`; path-escape attempts (`../`, absolute paths) are blocked. Destructive shell commands (`rm -rf /`, `sudo`, `dd`, `mkfs`, shutdown, fork bombs, `curl | sh`, …) are refused. +- **Sandboxed** — the file tools read and write only inside `./code`; the `skills/` folder next to it is readable but read-only. Every path is resolved first (`..`, `~` and symlinks included) and then checked, so `../settings.json`, `/etc/passwd` or a symlink pointing outside are refused with a clear error, and an absolute path into `./code` is used as is instead of being re-rooted. `search_files` rejects globs with `..` and skips symlinks that leave the project. `run_command` is not path-checked (a shell can reach anything your user can); it starts in `./code`. Destructive shell commands (`rm -rf /`, `sudo`, `dd`, `mkfs`, shutdown, fork bombs, `curl | sh`, …) are refused. - **Zero-config local backends** — auto-detects LM Studio (port 1234) first, then Ollama (port 11434). - **Cloud providers via `settings.json`** — switch to OpenAI, Anthropic, or Gemini at runtime with `/backend `; all are used through their OpenAI-compatible endpoints, so the single `openai` client dependency covers everything. - **Project guidelines via `AGENTS.md`** — project-specific rules (coding style, per-language test conventions) are loaded from `code/AGENTS.md` and injected as binding instructions. +- **Skills** — reusable instruction packs in `skills//SKILL.md` (Claude-Code-compatible YAML frontmatter + Markdown, with links to further files in the skill folder). The catalog goes into the system prompt; the model loads a skill on demand with `load_skill`, or you preload one with `/ task`. Five skills are included: `code-review` (diff-scoped review with verified findings), `save-load-json` (state persistence with a completeness check), `browser-test` (headless Puppeteer harness), `single-file-webapp` (patterns + static checker) and `threejs-csg` (three.js with boolean operations, loaders and exporters). - **Vision** — the agent can look at images, screenshots and PDF pages (see the `view_image`/`take_screenshot`/`view_pdf_page` tools below) to visually check its own work, e.g. a generated plot or a running GUI. -- **Session-wide context management** — token usage is tracked (server-reported or estimated); when the context nears its limit, older history is compacted into an LLM-written summary so long sessions keep working. -- **Adjustable reasoning effort** — `/reasoning high|medium|low|max|none` controls the model's thinking effort at runtime (backend-dependent; `none` turns it off). -- **Robust against flaky servers** — visible retries with backoff; if the backend goes down, the session is preserved and the task can simply be resubmitted. -- **Transparent** — every request, tool call, and result is logged to `logFile.md`; file changes are shown as colored unified diffs in the terminal. +- **Session-wide context management** — token usage is tracked (server-reported or estimated); when the context nears its limit, older history is compacted into an LLM-written summary so long sessions keep working. The threshold is configurable in `settings.json` (`autoCompactWindow`, Claude-Code-compatible). +- **Adjustable reasoning effort** — defaults to `low`; `/reasoning high|medium|low|max|none` changes the model's thinking effort at runtime (backend-dependent; `none` turns it off). +- **Robust against flaky servers** — visible retries with backoff for connection errors, timeouts and 5xx; if the backend goes down, the session is preserved and the task can simply be resubmitted. A 4xx rejection is not retried: if the request contained images, they are stripped and the task continues text-only (models without vision); if the context is too long, the history is compacted and the request repeated once; otherwise the server's message is shown. +- **Loop guard** — an identical tool call repeated `MAX_REPEATS` times in a row first triggers a forced reflection step (expected / observed / hypotheses / one experiment); if the model loops again, the task is stopped with the session intact. A no-op `edit_file` (old = new) is rejected, so it can't feed a loop. +- **Transparent** — every request, tool call, and result is logged to `logFile.md`; file changes are shown as colored unified diffs in the terminal. The stats line after each response shows time, generated tokens, tok/s and the prompt size the server actually received (`context N`) — compare it with the context length configured in LM Studio/Ollama: if the server's window is smaller than `autoCompactWindow`, the server silently truncates the oldest messages (including the system prompt and skills) long before jcode compacts. - **Tools available to the model:** - - `list_files`, `read_file` (optionally with line numbers), `search_files` (regex grep over the sandbox, hits as `file:line: content`) + - `list_files` (skips `node_modules`, `.git`, caches and build folders — shown as one line with their entry count — and caps the output at 300 entries), `read_file` (optionally with line numbers and a `start_line`/`end_line` range, so large files aren't re-read whole), `search_files` (regex grep over the sandbox, hits as `file:line: content`) - `write_file`, `edit_file` (search/replace), `insert_lines`, `delete_lines` - - `run_command` (blocks known-destructive patterns; `pip`/`pip3` calls are redirected to jcode's own Python interpreter) + - `run_command` (runs in bash wherever it is installed — on Debian/Ubuntu `/bin/sh` is dash, which lacks `pipefail`, `[[ ]]`, `source`, `{a,b}`, `echo -e` and misparses `&>`; pipes report the exit code of the first failing command; blocks known-destructive patterns; `pip`/`pip3` calls are redirected to jcode's own Python interpreter; background processes must redirect their output — `cmd > x.log 2>&1 &` — or the call blocks until the timeout; on timeout the whole process tree is killed and the output collected so far is returned) - `web_search` (DuckDuckGo) and `fetch_url` (fetches a page and extracts its text) - `view_image` (png/jpg/gif/webp/bmp/svg), `take_screenshot` (whole screen, e.g. of a GUI app under test), `view_pdf_page` (renders one PDF page) — images are delivered to the model as an inline image right after the tool result + - `load_skill` (returns a skill's `SKILL.md` or one of its linked files) ## Requirements @@ -67,8 +70,10 @@ All generated files land in `./code`, which is created automatically. | `/models` | List models available on the current backend | | `/model ` | Switch model | | `/backend ` | Switch to `lm-studio`, `ollama`, or a provider from `settings.json` (e.g. `openai`, `anthropic`, `gemini`) | -| `/reasoning ` | Set reasoning effort: `high`, `medium`, `low`, `max`, or `none` (off); no argument resets to the backend default | +| `/reasoning ` | Set reasoning effort: `high`, `medium`, `low` (startup default), `max`, or `none` (off); no argument or `default` resets to the backend default | | `/max-turns N` | Set the step limit per task | +| `/skills` | List the skills found in `skills/` | +| `/ ` | Run a task with that skill's instructions preloaded (e.g. `/threejs-csg Make a plate with four holes`) | | `Ctrl+C` | Abort the current input or running task (session is preserved) | | `Ctrl+D`, or `Ctrl+C` on an empty prompt | Quit | @@ -76,10 +81,12 @@ All generated files land in `./code`, which is created automatically. ### settings.json (optional) -Placed next to `jcode.py`. Defines cloud providers and, optionally, the backend to start on. API keys are read from environment variables — don't put literal keys in the file. +Placed next to `jcode.py`. Defines cloud providers, optionally the backend to start on, and the context compaction threshold. API keys are read from environment variables — don't put literal keys in the file. ```json { + "autoCompactEnabled": true, + "autoCompactWindow": "256k", "default_provider": "", "providers": { "openai": { @@ -101,6 +108,7 @@ Placed next to `jcode.py`. Defines cloud providers and, optionally, the backend } ``` +- `autoCompactEnabled` / `autoCompactWindow` (same keys and notation as Claude Code): history compaction kicks in once the context exceeds `autoCompactWindow` tokens (`"256k"`, `"1m"` or a plain number; default 250 000). `false` disables compaction entirely. `/status` shows the active threshold. - `default_provider`: name of a provider to activate at startup; leave `""` to use the auto-detected local backend. - Each provider needs `base_url` (an OpenAI-compatible endpoint), `api_key_env` (environment variable holding the key), and optionally `model`. - If the file is missing or malformed, jcode prints a warning and runs purely locally. @@ -115,20 +123,68 @@ Task> /backend anthropic If `code/AGENTS.md` exists, it is loaded at session start and treated as binding project guidelines — e.g. "simplicity first", surgical changes only, and the per-language verification conventions (how to compile/test C, C++, Java, PHP, Python, HTML, CSS). Keeping language-specific rules here rather than in the system prompt keeps every request lean and lets you adapt conventions per project without touching code. +### Skills (optional) + +A skill is a folder `skills//` next to `jcode.py` containing a `SKILL.md` — the same layout and frontmatter Claude Code uses, so skills can be shared between both: + +``` +skills/ +├── browser-test/ # shot.js, actions-example.js +├── code-review/ # review_prep.py, checklist.md +├── save-load-json/ # js.md, python.md, check_roundtrip.py +├── single-file-webapp/ # patterns.md, check_html.py +└── threejs-csg/ + ├── SKILL.md # frontmatter (name, description) + instructions + ├── csg.md # linked from SKILL.md as ./csg.md + ├── template.html # linked from SKILL.md as ./template.html + └── loaders.md +``` + +```markdown +--- +name: threejs-csg +description: > + Build 3D scenes with three.js and apply boolean operations (subtract, union, + intersect) to meshes. Use for any three.js, WebGL or CSG task. +--- +# Instructions ... see [csg.md](./csg.md) and [template.html](./template.html) +``` + +- `name` and `description` are read from the frontmatter (`name` falls back to the folder name). The frontmatter parser is deliberately minimal: `key: value` lines, plus indented continuation lines for `>`/`|` block scalars — no PyYAML needed. +- At session start, the names and descriptions of all skills are added to the system prompt, and the model can call `load_skill(name)` at any time; the body of `SKILL.md` (frontmatter stripped) is returned together with a list of the other files in the folder. +- **Automatic selection**: at the start of every task, jcode asks the model in one tool-less request which skills match the task and injects their `SKILL.md` into the task message. Small models reliably ignore a "call load_skill first" instruction in the system prompt but reliably answer a direct question — one cheap request instead of a run that hallucinates an API. `/ task` skips the selection and uses that skill. +- Relative links in the Markdown (`./forms.md`, `[x](forms.md)`) refer to files in the skill folder; the model reads them with `load_skill(name, "forms.md")` (or `read_file`/`list_files` with `skills//`, `/` or the absolute path — a garbled prefix in front of `/skills//` is tolerated, since models mangle long paths; the skills folder is readable but read-only: `run_command` refuses `sed -i`, redirects, `open(..., 'w')` etc. on it, so the model copies a file into the project before changing it). Scripts can be run via their absolute path, which is included in the tool result. +- `/ task` injects the skill text directly into the task so no extra tool call is needed; `/skills` lists what was found. + +Included skills: + +| Skill | Contents | Why | +|---|---|---| +| `browser-test` | `shot.js`: a Puppeteer harness that opens an HTML file via `file://` (no server), records JS exceptions / `console.error` / failed requests, runs an `actions.js` (click, shift-click, set input, key, eval, **upload** through the real file dialog, **download** through the real export button) and writes numbered screenshots; exit code 1 on any error. Probes WebGL and retries with SwiftShader flags (`--check` shows what works), traces every step and has its own watchdog, so a hang is reported with the step it stuck in instead of a silent timeout. Rules: test the control the user uses, not a stand-in; keep tests, fixtures and `node_modules` in the project. Needs `npm install puppeteer` in the project (or `CHROME_PATH` + `puppeteer-core`). | Without it the agent spends dozens of steps starting web servers and re-inventing a screenshot script, and finds runtime errors late. | +| `single-file-webapp` | Rules for one-file ES-module apps (no inline `onclick` with module scripts, one state object + `render()`, JSON serialization with nested groups, Blob download / FileReader import, `window.__app` test hook), `patterns.md` with snippets, `check_html.py` (syntax-checks every script block with `node --check`, validates the import map, flags inline handlers and missing element ids). | Targets the most common bug class in generated single-file apps: module scope vs. inline handlers, and exporters that silently miss nested data. | +| `code-review` | `review_prep.py` (stdlib only): scopes the review to the git diff (or given files), prints per file the changed line ranges, an outline of functions **with line ranges**, compiler/linter output restricted to bug-relevant rules (ruff/pyflakes, `node --check` incl. `", text, re.S | re.I), 1): + if "src=" in attrs or "importmap" in attrs or not body.strip(): + continue + line0 = text[:text.find(body)].count("\n") + with tempfile.NamedTemporaryFile("w", suffix=".mjs" if "module" in attrs else ".js", + delete=False, encoding="utf-8") as f: + f.write(body) + rc, msg = run(["node", "--check", f.name]) + os.unlink(f.name) + if rc: + m = re.search(r":(\d+)\n", msg) + where = f"line ~{line0 + int(m.group(1))}" if m else f"script {i}" + out.append(f"node --check {where}: " + " | ".join(node_msg(msg.replace(f.name, " +``` +Classic scripts (global objects `Papa`, `XLSX`, `mammoth`, `PDFLib`, `jspdf`, `JSZip`) - put +them BEFORE your module script: +```html + + + + + + +``` + +## Images (png / jpg / webp) - no library +```js +// load: file -> -> pixels +pickFile('image/*', false, (buf, name) => { + const img = new Image(); + img.onload = () => { + const c = Object.assign(document.createElement('canvas'), { width: img.width, height: img.height }); + const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); + const { data } = ctx.getImageData(0, 0, c.width, c.height); // RGBA bytes, row-major + console.log(name, img.width, 'x', img.height, 'first pixel', [...data.slice(0, 4)]); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(new Blob([buf])); +}); +// save: canvas -> file +canvas.toBlob(blob => download('out.png', blob), 'image/png'); +canvas.toBlob(blob => download('out.jpg', blob), 'image/jpeg', 0.9); // quality 0..1, no alpha +``` + +## SVG - no library (three.js for extrusion) +```js +pickFile('.svg', true, text => { + const svg = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement; + if (svg.querySelector('parsererror')) throw new Error('invalid SVG'); + const vb = svg.getAttribute('viewBox')?.split(/\s+|,/).map(Number); // [x, y, w, h] + const paths = [...svg.querySelectorAll('path')].map(p => p.getAttribute('d')); + // rasterize: draw into a canvas at any size + const img = new Image(); img.onload = () => ctx.drawImage(img, 0, 0, 512, 512); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(text); +}); +// save: build the markup (keep viewBox so it scales) - or serialize a DOM you built +const svgText = ``; +download('shape.svg', svgText, 'image/svg+xml'); +// 3D: SVGLoader + ExtrudeGeometry -> threejs-csg skill, loaders.md +``` + +## STL -> threejs-csg skill, loaders.md (`STLLoader.parse(arrayBuffer)`, `STLExporter`) + +## JSON -> save-load-json skill + +## YAML (js-yaml 5.5.0, ESM) +```js +import { load as yamlLoad, dump as yamlDump } from 'js-yaml'; +pickFile('.yaml,.yml', true, text => { const cfg = yamlLoad(text); /* plain objects/arrays */ }); +download('config.yaml', yamlDump(cfg, { noRefs: true, lineWidth: 100 }), 'text/yaml'); +``` +`load` never executes code (safe for untrusted files). Multi-document files: `loadAll(text)`. + +## CSV / TSV (papaparse 5.7.0) +```js +pickFile('.csv,.tsv,.txt', true, text => { + const { data, errors, meta } = Papa.parse(text, { header: true, dynamicTyping: true, skipEmptyLines: true }); + // data: [{col: value, ...}], meta.delimiter: ',' ';' or '\t' (auto), errors: [{row, message}] +}); +download('table.csv', Papa.unparse(rows), 'text/csv'); // rows: array of objects or arrays +// Excel-friendly: '' + Papa.unparse(rows, { delimiter: ';' }) (BOM + semicolon for German Excel) +``` +Latin-1 files: `new TextDecoder('windows-1252').decode(arrayBuffer)` before parsing. + +## XLSX / XLS (SheetJS xlsx 0.18.5) +```js +pickFile('.xlsx,.xls,.ods', false, buf => { + const wb = XLSX.read(buf, { type: 'array', cellDates: true }); + for (const name of wb.SheetNames) { + const ws = wb.Sheets[name]; + const rows = XLSX.utils.sheet_to_json(ws); // [{Header: value}], first row = headers + const grid = XLSX.utils.sheet_to_json(ws, { header: 1 }); // [[cell, cell, ...]] raw grid + const csv = XLSX.utils.sheet_to_csv(ws); + const cell = ws['B2']; // {v: value, t: 'n'|'s'|'d'|'b', f: 'A2*2' (formula), w: formatted text} + } +}); +// save +const wb = XLSX.utils.book_new(); +XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), 'Data'); // or aoa_to_sheet([[...], [...]]) +const bytes = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }); // 'xls' for legacy Excel, 'csv', 'ods' +download('data.xlsx', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })); +``` +Formulas on write: `ws['C1'] = { t: 'n', f: 'A1+B1' }`. Column widths: `ws['!cols'] = [{ wch: 20 }]`. + +## DOCX - read with mammoth 1.13.0, write with docx 9.13.0 +```js +// read: text and simple HTML (headings, lists, tables, bold/italic, images as data URIs) +pickFile('.docx', false, async buf => { + const { value: html, messages } = await mammoth.convertToHtml({ arrayBuffer: buf }); + const { value: text } = await mammoth.extractRawText({ arrayBuffer: buf }); + // tables: parse the html with DOMParser and read
+}); +// write +import { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, ImageRun } from 'docx'; +const doc = new Document({ sections: [{ children: [ + new Paragraph({ text: 'Report', heading: HeadingLevel.HEADING_1 }), + new Paragraph({ children: [new TextRun('Normal '), new TextRun({ text: 'bold', bold: true })] }), + new Table({ rows: [new TableRow({ children: [new TableCell({ children: [new Paragraph('cell')] })] })] }), + // new Paragraph({ children: [new ImageRun({ type: 'png', data: pngArrayBuffer, transformation: { width: 300, height: 200 } })] }), +]}]}); +download('report.docx', await Packer.toBlob(doc)); +``` +`.doc` (Word 97-2003): no browser parser. Convert with LibreOffice via run_command +(`soffice --headless --convert-to docx file.doc`) or ask for a .docx export. + +## PDF - read with pdf.js 6.4.299, write with pdf-lib 1.17.1 or jsPDF 4.2.1 +```js +import * as pdfjs from 'pdfjs'; +pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.4.299/build/pdf.worker.min.mjs'; +pickFile('.pdf', false, async buf => { + const pdf = await pdfjs.getDocument({ data: new Uint8Array(buf) }).promise; // pass a COPY: pdf.js empties the buffer it gets + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const text = (await page.getTextContent()).items.map(it => it.str).join(' '); // '' for scanned pages + const vp = page.getViewport({ scale: 1.5 }); // render to a canvas + const c = Object.assign(document.createElement('canvas'), { width: vp.width, height: vp.height }); + await page.render({ canvasContext: c.getContext('2d'), viewport: vp }).promise; + } +}); +// write (pdf-lib): exact placement, merge, fill forms, modify existing files +const { PDFDocument, StandardFonts, rgb } = PDFLib; +const out = await PDFDocument.create(); +const page = out.addPage([595, 842]); // A4 points +const font = await out.embedFont(StandardFonts.Helvetica); +page.drawText('Invoice 4711', { x: 50, y: 790, size: 14, font, color: rgb(0, 0, 0) }); +const png = await out.embedPng(pngBytes); page.drawImage(png, { x: 50, y: 500, width: 200, height: 150 }); +download('out.pdf', new Blob([await out.save()], { type: 'application/pdf' })); +// merge +const merged = await PDFDocument.create(); +for (const bytes of [a, b]) { const src = await PDFDocument.load(bytes); (await merged.copyPages(src, src.getPageIndices())).forEach(p => merged.addPage(p)); } +// write (jsPDF): quick text/HTML-ish reports +const doc = new jspdf.jsPDF(); doc.text('Hello', 20, 20); doc.addImage(canvas, 'PNG', 20, 30, 100, 60); +download('report.pdf', doc.output('blob')); +``` +Scanned PDFs: `getTextContent()` is empty - render the page and run OCR +(tesseract.js 5: `Tesseract.recognize(canvas, 'eng')`), or say the file has no text layer. + +## ZIP (jszip 3.10.2) - bundle a project with assets +```js +const zip = new JSZip(); +zip.file('scene.json', JSON.stringify(toData(), null, 2)); +zip.folder('textures').file('wood.png', pngBlob); +download('project.zip', await zip.generateAsync({ type: 'blob' })); +pickFile('.zip', false, async buf => { + const z = await JSZip.loadAsync(buf); + const scene = JSON.parse(await z.file('scene.json').async('string')); + const tex = await z.file('textures/wood.png')?.async('blob'); +}); +``` diff --git a/skills/save-load-json/SKILL.md b/skills/save-load-json/SKILL.md new file mode 100644 index 0000000..57a0ae5 --- /dev/null +++ b/skills/save-load-json/SKILL.md @@ -0,0 +1,83 @@ +--- +name: save-load-json +description: > + Save and load an application's complete state as JSON (browser apps with + download/upload, Python/Node programs with files): one state object, a + serializer that saves EVERYTHING except an explicit runtime-only list, a + loader that rebuilds through the normal constructors, a version field, and a + round-trip check that fails when any field is lost. Use whenever a task says + save/load, export/import, persist, project file, settings file, or when + save/load "loses" data. +--- + +# Save / load with JSON + +The bug this skill prevents: a hand-written `toJSON` that copies a few fields +(`id`, `type`) and silently drops the rest (`position`, `children`, ...). The +user saves, loads, and the scene is wrong. The fix is a rule, not care: + +> **Save everything by default.** Serialize the whole state object and +> subtract an explicit `RUNTIME` list (meshes, DOM nodes, caches, selection). +> A forgotten field is then saved, not lost. + +## Design (do this before writing the app) +1. **One state object** holds all data the user would expect to get back: + objects, their parameters and transforms, nesting, settings (grid size, + units, colors), view (camera) if restoring it is useful. +2. **Data vs runtime.** Each object is a plain record (`{id, type, params, + position, rotation, scale, color, hole, children}`); runtime things + (`mesh`, `element`, `texture`, listeners) live in a side map + `runtime.get(id)` or in fields named in `RUNTIME`. Never store a live object + inside the data record if you can avoid it. +3. **One constructor path.** `fromRecord(rec)` creates a runtime object from a + data record. The "add shape" button, paste, duplicate AND load all call + `fromRecord` - so load can't behave differently from add. +4. **Format:** `{ "version": 1, "app": "", "state": {...} }`. Bump + `version` when the shape changes; `load` migrates older versions + (`migrate(data)`) instead of failing. Numbers as numbers (not strings), + arrays for vectors (`[x, y, z]`), ISO strings for dates, no `NaN`/`Infinity` + (JSON has none - clamp or null them). +5. **Round trip is the test:** `deepEqual(toData(fromData(toData(state))), + toData(state))`, and the field-completeness check below. Run both before + wiring any button. + +## Browser app +- `toData()` returns plain data; `download('x.json', JSON.stringify(toData(), null, 2))` + is the ENTIRE save handler. `pickFile('.json', true, text => load(JSON.parse(text)))` + is the entire load handler. No second serializer in a handler, ever. +- Test the real buttons with the browser-test skill (`h.download('#save')`, + `h.upload('#load', 'fixtures/scene.json')`) and compare the file's fields to + the live state - that catches the handler calling the wrong function. +- Snippets: [js.md](./js.md) (`toData`/`load` with RUNTIME subtraction, + migrate, Set/Map/typed arrays, the completeness check, fixture generation). + +## Python / Node programs +- Python: dataclasses + `dataclasses.asdict` for the data side, a `RUNTIME` + tuple of field names to drop, `json.dump(..., indent=2, ensure_ascii=False)`, + write to a temp file and `os.replace` so a crash never leaves a half file. + Snippets and the round-trip test: [python.md](./python.md). +- Node: same rules; `JSON.stringify(data, null, 2)` + `fs.writeFileSync(tmp)` + + `fs.renameSync`. + +## Completeness check (run it, don't eyeball it) +`check_roundtrip.py` compares a saved JSON file against a list of the fields +the live state has (dumped from the app via the test hook or a one-liner) and +prints every field present in the state but missing from the file: +``` +python /check_roundtrip.py saved.json state_fields.json +``` +Details and how to produce `state_fields.json` in [js.md](./js.md#completeness) +and [python.md](./python.md#completeness). + +## Pitfalls +- `JSON.stringify(state)` with live objects: cyclic structure error, or megabytes + of mesh data - that's the signal to split data from runtime (rule 2). +- Loading replaces the whole state; clear selection/undo history or they point + at ids that no longer exist. +- Nested groups/children: recursion in both directions, and ids must stay + unique after load (`nextId = max(ids) + 1`). +- Floats: don't round on save; round only for display. +- Binary data (images, STL buffers): base64 in the JSON only if small; otherwise + a zip or separate files, with file names in the JSON. +- Don't let a load failure wipe the current state: parse and validate first, + then swap. diff --git a/skills/save-load-json/check_roundtrip.py b/skills/save-load-json/check_roundtrip.py new file mode 100644 index 0000000..357da24 --- /dev/null +++ b/skills/save-load-json/check_roundtrip.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""check_roundtrip.py - is every field of the live state in the saved file? + + python check_roundtrip.py saved.json state_fields.json [--runtime selection,_cache] + +saved.json the file the app wrote (any JSON; a top-level "state" key is unwrapped) +state_fields.json JSON array of field paths of the LIVE state, e.g. + ["objects[].id", "objects[].position[]", "settings.grid", "selection"] + (how to produce it: js.md / python.md, section "Completeness") +--runtime comma-separated paths that are intentionally not saved + +Exit 1 if a state field is missing from the file (and not in --runtime), with the list. +Also lists file fields that the state does not have (stale keys from old versions). +""" +import json, sys + + +def paths(obj, prefix=""): + if isinstance(obj, dict): + if not obj: + yield prefix + for k, v in obj.items(): + yield from paths(v, f"{prefix}.{k}" if prefix else k) + elif isinstance(obj, list): + if not obj: + yield prefix # empty array: can't know the element shape + for v in obj: + yield from paths(v, prefix + "[]") + else: + yield prefix + + +def normalize(p): + """'objects[].children[].id' and 'objects[].id' describe the same record type when + children hold the same records; compare on the path with repeated '[]' segments kept.""" + return p + + +def main(argv): + runtime = set() + if "--runtime" in argv: + i = argv.index("--runtime"); runtime = set(argv[i + 1].split(",")); del argv[i:i + 2] + if len(argv) != 2: + print(__doc__); return 2 + saved = json.load(open(argv[0], encoding="utf-8")) + if isinstance(saved, dict) and "state" in saved and isinstance(saved["state"], dict): + saved = saved["state"] + want = set(json.load(open(argv[1], encoding="utf-8"))) + have = set(paths(saved)) + # a field counts as present if the file has it at that path or any ancestor is an empty container + def present(p): + if p in have: + return True + parts = p.replace("[]", ".[]").split(".") + for n in range(len(parts) - 1, 0, -1): + anc = ".".join(parts[:n]).replace(".[]", "[]") + if anc in have and any(h == anc for h in have): # ancestor exists as a leaf = empty list/dict + return True + return False + missing = sorted(p for p in want if not present(p) and p not in runtime + and not any(p == r or p.startswith(r + ".") or p.startswith(r + "[]") for r in runtime)) + extra = sorted(h for h in have if h not in want) + if missing: + print(f"MISSING in the file ({len(missing)}): fields the live state has but the save does not") + for p in missing: + print(" -", p) + if extra: + print(f"only in the file ({len(extra)}): not in the live state (old keys? wrong source?)") + for p in extra: + print(" -", p) + print("ok - every state field is in the file" if not missing else f"{len(missing)} field(s) lost on save") + return 1 if missing else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/skills/save-load-json/js.md b/skills/save-load-json/js.md new file mode 100644 index 0000000..e0f9c06 --- /dev/null +++ b/skills/save-load-json/js.md @@ -0,0 +1,116 @@ +# Save / load in a browser app (ES module) + +## State layout +```js +const state = { + version: 1, + settings: { grid: 1, units: 'mm' }, + objects: [], // data records only: {id, type, params, position, rotation, scale, color, hole, children} + nextId: 1, + selection: new Set(), // runtime: not saved +}; +const runtime = new Map(); // id -> { mesh, ... } (never inside the records) +const RUNTIME = ['selection']; // top-level fields that are NOT saved +``` + +## toData / load - save everything except RUNTIME +```js +function toData() { + const data = { ...state }; + for (const k of RUNTIME) delete data[k]; + assertPlain(data); // on the LIVE data: meshes, cycles, class instances -> error now, not a broken file + return { version: state.version, app: 'myapp', state: structuredClone(data) }; +} +function assertPlain(v, path = 'state', seen = new Set()) { + if (v === null || typeof v !== 'object') { if (typeof v === 'number' && !Number.isFinite(v)) throw new Error(`${path} is ${v} - JSON has no NaN/Infinity`); return; } + if (seen.has(v)) throw new Error(`${path} is cyclic - a runtime object leaked into the data (add it to RUNTIME or the runtime map)`); + seen.add(v); + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) + throw new Error(`${path} is a ${v.constructor?.name ?? 'class instance'} - store plain data, keep the object in the runtime map`); + for (const k of Object.keys(v)) assertPlain(v[k], `${path}.${k}`, seen); +} + +function fromRecord(rec) { // the ONE constructor path (add, paste, load) + const obj = { children: [], ...rec }; // defaults first, record wins + runtime.set(obj.id, { mesh: buildMesh(obj) }); + obj.children.forEach(fromRecord); + return obj; +} + +function load(data) { + data = migrate(data); // old versions -> current + validate(data); // throws -> current state untouched + clearRuntime(); + Object.assign(state, data.state); // replaces objects, settings, nextId, ... + state.selection = new Set(); // re-create runtime fields + state.objects = state.objects.map(fromRecord); + state.nextId = Math.max(state.nextId ?? 1, ...allIds(state.objects).map(i => i + 1)); // never reuse an id + render(); +} +const allIds = objs => objs.flatMap(o => [o.id, ...allIds(o.children ?? [])]); + +function migrate(data) { + if (data.version === undefined) data = { version: 0, state: data }; // very old: bare state + if (data.version < 1) { data.state.objects.forEach(o => { o.scale ??= [1, 1, 1]; }); data.version = 1; } + return data; +} +function validate(data) { + if (!data?.state || !Array.isArray(data.state.objects)) throw new Error('not a myapp file'); +} +``` +`assertPlain` is what turns "the JSON is silently wrong" into an error at save +time: a three.js mesh, a cyclic parent link or a `Set` in the data record fails +loudly with the path. `Map`/`Set`/typed arrays survive `structuredClone` but +`JSON.stringify` turns them into `{}` - convert those explicitly: +```js +// in toData(): data.tags = [...state.tags]; // Set -> array +// in load(): state.tags = new Set(data.state.tags); +// Float32Array -> Array.from(a); Date -> d.toISOString(); back: new Date(s) +``` + +## Buttons - nothing but a call +```js +$('save').addEventListener('click', () => download('scene.json', JSON.stringify(toData(), null, 2), 'application/json')); +$('load').addEventListener('click', () => pickFile('.json', true, text => { + try { load(JSON.parse(text)); } catch (e) { alert('Load failed: ' + e.message); } +})); +``` +(`download`/`pickFile` are in the single-file-webapp skill, patterns.md.) + +## Round-trip test (browser-test skill, through the REAL buttons) +```js +module.exports = async (h) => { + await h.eval(() => { __app.add('box', { size: 2 }); __app.add('cylinder', { r: 0.5, h: 3 }); }); + await h.eval(() => { const o = __app.state.objects[1]; o.position = [3, 0, 1]; o.hole = true; }); + const before = await h.eval(() => JSON.stringify(__app.toData())); + const f = await h.download('#save'); // real export path + if (f.text !== JSON.stringify(JSON.parse(before), null, 2)) throw new Error('save button wrote something else than toData()'); + require('fs').writeFileSync('fixtures/scene.json', f.text); + await h.eval(() => __app.reset()); + await h.upload('#load', 'fixtures/scene.json'); // real import path + const after = await h.eval(() => JSON.stringify(__app.toData())); + if (after !== before) throw new Error('round trip differs:\n' + before + '\n' + after); +}; +``` +Expose `toData`, `add`, `reset` on `window.__app`. + +## Completeness +Dump the field paths of the live state (runtime fields included) once: +```js +const fields = await h.eval(() => { + const out = new Set(); + (function walk(v, p) { + if (Array.isArray(v)) { v.forEach(x => walk(x, p + '[]')); return; } + if (v && typeof v === 'object' && !(v instanceof Set) && !(v instanceof Map)) { + for (const k of Object.keys(v)) walk(v[k], p ? p + '.' + k : k); return; + } + out.add(p); + })(__app.state, ''); + return [...out]; +}); +require('fs').writeFileSync('state_fields.json', JSON.stringify(fields, null, 2)); +``` +Then `python /check_roundtrip.py fixtures/scene.json state_fields.json` +lists every state field that the file lacks. Each one is either a RUNTIME +field (add it to the list in the app, so the omission is explicit) or a bug. diff --git a/skills/save-load-json/python.md b/skills/save-load-json/python.md new file mode 100644 index 0000000..cfa5435 --- /dev/null +++ b/skills/save-load-json/python.md @@ -0,0 +1,109 @@ +# Save / load in Python + +## Data classes, one `RUNTIME` list +```python +from dataclasses import dataclass, field, asdict, fields +import json, os, tempfile +from pathlib import Path + +VERSION = 1 + +@dataclass +class Item: + id: int + type: str + params: dict = field(default_factory=dict) + position: list = field(default_factory=lambda: [0.0, 0.0, 0.0]) + children: list = field(default_factory=list) # nested Items + +@dataclass +class State: + items: list = field(default_factory=list) + settings: dict = field(default_factory=lambda: {"grid": 1.0}) + next_id: int = 1 + selection: set = field(default_factory=set) # runtime only + _cache: dict = field(default_factory=dict) # runtime only + +RUNTIME = ("selection", "_cache") # the ONLY fields not saved +``` + +## to_data / load +```python +def to_data(state: State) -> dict: + data = asdict(state) # everything, recursively + for k in RUNTIME: + data.pop(k, None) + return {"version": VERSION, "app": "myapp", "state": data} + +def from_record(rec: dict) -> Item: # the ONE constructor path + rec = {**rec, "children": [from_record(c) for c in rec.get("children", [])]} + return Item(**rec) + +def load(data: dict) -> State: + data = migrate(data) + st = data["state"] + state = State(items=[from_record(r) for r in st["items"]], + settings=st.get("settings", {}), next_id=st.get("next_id", 1)) + state.next_id = max(state.next_id, max(_ids(state.items), default=0) + 1) # never reuse an id + return state + +def _ids(items): + for it in items: + yield it.id + yield from _ids(it.children) + +def migrate(data: dict) -> dict: + if "version" not in data: + data = {"version": 0, "state": data} + if data["version"] < 1: + for r in data["state"]["items"]: + r.setdefault("position", [0.0, 0.0, 0.0]) + data["version"] = 1 + return data +``` + +## Files: atomic write, explicit encoding +```python +def save_file(state: State, path: Path) -> None: + tmp = tempfile.NamedTemporaryFile("w", dir=path.parent, suffix=".tmp", delete=False, encoding="utf-8") + with tmp: + json.dump(to_data(state), tmp, indent=2, ensure_ascii=False, allow_nan=False) + os.replace(tmp.name, path) # never a half-written file + +def load_file(path: Path) -> State: + return load(json.loads(path.read_text(encoding="utf-8"))) +``` +`allow_nan=False` makes `NaN`/`inf` an error at save time instead of a file no +other parser can read. For `Path`, `datetime`, `set`, `Decimal` use +`json.dump(..., default=_enc)` with `_enc` returning `str(o)`, `o.isoformat()`, +`sorted(o)`, `float(o)`, and convert back in `from_record`. + +## Round-trip test (pytest) +```python +def test_roundtrip(tmp_path): + s = State(items=[Item(1, "box", {"size": 2}, [1, 2, 3], [Item(2, "hole", {"r": 0.5})])], next_id=3) + s.selection.add(1) + save_file(s, tmp_path / "a.json") + s2 = load_file(tmp_path / "a.json") + assert to_data(s2) == to_data(s) # data equal + assert s2.selection == set() # runtime rebuilt, not loaded + assert s2.next_id == 3 +``` + +## Completeness +```python +def field_paths(obj, prefix=""): + """All leaf paths of a live state, dataclasses included.""" + if hasattr(obj, "__dataclass_fields__"): + obj = {f.name: getattr(obj, f.name) for f in fields(obj)} + if isinstance(obj, dict): + for k, v in obj.items(): + yield from field_paths(v, f"{prefix}.{k}" if prefix else k) + elif isinstance(obj, (list, tuple, set)): + for v in obj: + yield from field_paths(v, prefix + "[]") + else: + yield prefix +json.dump(sorted(set(field_paths(state))), open("state_fields.json", "w"), indent=2) +``` +then `python /check_roundtrip.py a.json state_fields.json`. diff --git a/skills/scientific-deep-research/SKILL.md b/skills/scientific-deep-research/SKILL.md new file mode 100644 index 0000000..753f67a --- /dev/null +++ b/skills/scientific-deep-research/SKILL.md @@ -0,0 +1,91 @@ +--- +name: scientific-deep-research +description: > + Literature research on a scientific question: find relevant papers (arXiv, + OpenAlex, Semantic Scholar), prefer the peer-reviewed version of arXiv + preprints (Crossref, dblp), download the PDFs, extract and read the text, + and write research.md with structured per-paper summaries, a synthesis and + a references.bib in BibTeX. Use for "survey the literature", "find papers + on", "state of the art", "related work", or any task that needs citations. +--- + +# Scientific deep research + +Output: `research.md` (summaries + synthesis), `references.bib`, and the PDFs +in `papers/`. Every claim in research.md is tied to a paper you actually +opened; every BibTeX entry comes from a resolver, never from memory. + +Tool: [papers.py](./papers.py) (stdlib only; `pip install pymupdf` for +`text`). Copy it into the project: `cp /papers.py .`. Be polite to +the APIs: one call per second, `CONTACT_EMAIL=you@uni.de` in the environment. + +## Procedure +1. **Frame the question** in one sentence plus 3-6 search phrases (synonyms, + method names, the application). Write them at the top of research.md. +2. **Search:** `python papers.py search "" --max 25 [--since 2018]` + for each phrase. Merged, deduplicated candidates land in `candidates.json`, + ranked by citations (recent papers have few - don't drop them for that). +3. **Screen** by title + abstract (`candidates.json`): keep 8-15 papers that + answer the question, including 2-3 recent ones and the most-cited classic. + Note why each was kept in one line. Follow references of the best paper if + coverage is thin (`search` with its key terms). +4. **Resolve the citable version:** `python papers.py resolve ` + for every kept paper. For an arXiv preprint it looks for the journal or + conference version (Semantic Scholar -> Crossref -> dblp) and prints which + to cite. Cite the published version when one exists; otherwise cite the + preprint and mark it `(preprint)` in research.md. +5. **BibTeX:** `python papers.py bibtex ` -> append + to `references.bib`. Keys are ``; use exactly + these keys in the text as `[@key]`. One entry per paper, no duplicates. +6. **Download and read:** `python papers.py download --out papers/` + (arXiv PDF, or the open-access copy OpenAlex knows; otherwise say "no OA + PDF" and work from the abstract, marked as such). Then + `python papers.py text papers/x.pdf` and read the .txt with `read_file` + in ranges: abstract, introduction, method, results tables, conclusion. + Don't read reference lists. +7. **Summarize each paper** with the template below - numbers from the + results, not adjectives. 120-200 words each. +8. **Synthesize:** what the field agrees on, where results conflict, which + method fits the question, open problems. Reference papers by key. +9. **Verify before finishing:** every `[@key]` in research.md exists in + references.bib and vice versa (`grep -o '@\w*' research.md | sort -u` + against `grep -o '^@\w*{\K[^,]*' references.bib`); every summary names + the version read (published / preprint / abstract only). + +## research.md template +```markdown +# +Date, search phrases, databases, number screened / kept. + +## Summary of findings (10-15 lines) +...with [@key] references... + +## Papers +### [@key] +Authors, venue + year (or "arXiv preprint"), DOI/arXiv, read: full text | abstract only +- **Problem:** ... +- **Method:** ... +- **Data / setup:** ... +- **Results:** the numbers (metric, value, baseline, dataset) +- **Limitations:** stated by the authors or evident +- **Relevance to the question:** ... + +## Synthesis +Agreement, contradictions, best-fit method, open questions. + +## Not covered +Papers without accessible PDF, areas not searched. + +## References +See references.bib (keys above). +``` + +## Rules +- No citation without a resolved record. If a resolver finds nothing, the + paper is "unverified" and goes into "Not covered", not into the text. +- Never "improve" numbers from memory; quote what the PDF says and the page. +- Preprint vs. published: results can differ between versions - say which + one you read. +- Keep the PDFs and `candidates.json`; the user may want to continue. +- 10 well-read papers beat 40 abstracts. Stop searching when new queries + return papers you already have. diff --git a/skills/scientific-deep-research/papers.py b/skills/scientific-deep-research/papers.py new file mode 100644 index 0000000..902ac74 --- /dev/null +++ b/skills/scientific-deep-research/papers.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""papers.py - find, resolve, download and cite scientific papers (stdlib only). + + python papers.py search "boolean operations on triangle meshes" --max 25 [--since 2018] + python papers.py resolve 1706.03762 # arXiv id, DOI or "title": find the peer-reviewed version + python papers.py bibtex 10.1145/3306346.3322959 # DOI / arXiv id / dblp key -> BibTeX entry + python papers.py download 1706.03762 --out papers/ # PDF (arXiv or open-access copy) + python papers.py text papers/vaswani2017attention.pdf [--pages 12] # PDF -> .txt (pymupdf) + +search merges arXiv, OpenAlex and Semantic Scholar; prints a ranked table and writes candidates.json. +resolve prints, for one paper, the best version: journal/conference (DOI, venue, year) if one + exists, otherwise the arXiv preprint - plus the BibTeX key you should use. +bibtex prints a cleaned BibTeX entry with a stable key <lastname><year><firstword>. +All network calls have a 20 s timeout and identify themselves; be polite (<= 1 request/s). +Sources: export.arxiv.org (Atom), api.openalex.org, api.semanticscholar.org, api.crossref.org, +dblp.org, doi.org (content negotiation). Set CONTACT_EMAIL for the Crossref/OpenAlex polite pools. +""" +import json, os, re, sys, time, unicodedata, urllib.parse, urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +UA = f"papers.py/1.0 (mailto:{os.environ.get('CONTACT_EMAIL', 'research@example.org')})" +ARXIV_RE = re.compile(r"(?:arxiv[:/]|abs/|pdf/)?(\d{4}\.\d{4,5})(?:v\d+)?", re.I) +DOI_RE = re.compile(r"\b(10\.\d{4,9}/[^\s\"<>]+)", re.I) + + +# ----------------------------------------------------------------- helpers +def get(url, accept="application/json", retries=2): + req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": accept}) + for attempt in range(retries + 1): + try: + with urllib.request.urlopen(req, timeout=20) as r: + return r.read().decode("utf-8", "replace") + except Exception as e: # noqa: BLE001 + if attempt == retries: + print(f" ! {url[:80]}...: {e}", file=sys.stderr) + return None + time.sleep(2 * (attempt + 1)) + + +def norm_title(t): + t = unicodedata.normalize("NFKD", t or "").encode("ascii", "ignore").decode() + return re.sub(r"[^a-z0-9]+", " ", t.lower()).strip() + + +def bib_key(authors, year, title): + last = (authors[0].split()[-1] if authors else "anon") + last = re.sub(r"[^a-z]", "", unicodedata.normalize("NFKD", last).encode("ascii", "ignore").decode().lower()) or "anon" + stop = {"a", "an", "the", "on", "of", "for", "and", "to", "in", "with", "towards", "toward"} + first = next((w for w in re.findall(r"[a-z0-9]+", (title or "").lower()) if w not in stop), "paper") + return f"{last}{year or 'nd'}{first}" + + +def is_arxiv_doi(doi): + return bool(doi) and doi.lower().startswith("10.48550/arxiv") + + +# ----------------------------------------------------------------- parsers (pure: testable offline) +def parse_arxiv(atom): + ns = {"a": "http://www.w3.org/2005/Atom", "x": "http://arxiv.org/schemas/atom"} + out = [] + for e in ET.fromstring(atom).findall("a:entry", ns): + aid = ARXIV_RE.search(e.findtext("a:id", "", ns)) + out.append({ + "arxiv": aid.group(1) if aid else None, + "title": " ".join(e.findtext("a:title", "", ns).split()), + "authors": [a.findtext("a:name", "", ns) for a in e.findall("a:author", ns)], + "year": (e.findtext("a:published", "", ns) or "")[:4], + "abstract": " ".join(e.findtext("a:summary", "", ns).split()), + "doi": (e.findtext("x:doi", None, ns) or None), + "venue": (e.findtext("x:journal_ref", None, ns) or None), # authors' own note, e.g. "NeurIPS 2017" + "pdf": f"https://arxiv.org/pdf/{aid.group(1)}" if aid else None, + "source": "arxiv", + }) + return out + + +def parse_openalex(js): + out = [] + for w in json.loads(js).get("results", []): + ids = w.get("ids", {}) or {} + doi = (w.get("doi") or "").replace("https://doi.org/", "") or None + loc = w.get("primary_location") or {} + src = loc.get("source") or {} + best = w.get("best_oa_location") or {} + # abstract comes as an inverted index + inv = w.get("abstract_inverted_index") or {} + words = sorted((p, t) for t, ps in inv.items() for p in ps) + out.append({ + "arxiv": next((m.group(1) for u in (best.get("pdf_url"), best.get("landing_page_url")) + if u and "arxiv.org" in u and (m := ARXIV_RE.search(u))), None), + "title": w.get("title") or "", + "authors": [a.get("author", {}).get("display_name", "") for a in w.get("authorships", [])], + "year": str(w.get("publication_year") or ""), + "abstract": " ".join(t for _, t in words), + "doi": doi, + "venue": src.get("display_name"), + "type": w.get("type"), # article, preprint, book-chapter, ... + "citations": w.get("cited_by_count", 0), + "pdf": best.get("pdf_url"), + "source": "openalex", + }) + return out + + +def parse_s2(js): + data = json.loads(js) + items = data.get("data", [data]) if isinstance(data, dict) else data + out = [] + for p in items: + if not p: + continue + ext = p.get("externalIds") or {} + pv = p.get("publicationVenue") or {} + out.append({ + "arxiv": ext.get("ArXiv"), + "title": p.get("title") or "", + "authors": [a.get("name", "") for a in p.get("authors", [])], + "year": str(p.get("year") or ""), + "abstract": p.get("abstract") or "", + "doi": ext.get("DOI"), + "venue": p.get("venue") or pv.get("name"), + "venue_type": pv.get("type"), # journal / conference + "types": p.get("publicationTypes") or [], # JournalArticle, Conference, ... + "citations": p.get("citationCount", 0), + "pdf": (p.get("openAccessPdf") or {}).get("url"), + "source": "s2", + }) + return out + + +def parse_crossref(js, want_title): + """Best Crossref item whose title matches (journal/proceedings only), else None.""" + want = norm_title(want_title) + for it in json.loads(js).get("message", {}).get("items", []): + title = (it.get("title") or [""])[0] + if norm_title(title) != want or it.get("type") not in ("journal-article", "proceedings-article", "book-chapter"): + continue + date = (it.get("published-print") or it.get("published-online") or it.get("issued") or {}).get("date-parts", [[None]])[0][0] + return { + "doi": it.get("DOI"), "title": title, "year": str(date or ""), + "venue": (it.get("container-title") or [None])[0] or (it.get("event") or {}).get("name"), + "type": it.get("type"), + "authors": [f"{a.get('given', '')} {a.get('family', '')}".strip() for a in it.get("author", [])], + "source": "crossref", + } + return None + + +def parse_dblp(js, want_title): + """dblp hit for the title, preferring the conference/journal record over the CoRR (arXiv) one.""" + want = norm_title(want_title) + hits = [h.get("info", {}) for h in json.loads(js).get("result", {}).get("hits", {}).get("hit", [])] + hits = [h for h in hits if norm_title(h.get("title", "").rstrip(".")) == want] + hits.sort(key=lambda h: (h.get("venue") == "CoRR", h.get("type") not in ("Conference and Workshop Papers", "Journal Articles"))) + if not hits: + return None + h = hits[0] + return {"key": h.get("key"), "venue": h.get("venue"), "year": h.get("year"), "type": h.get("type"), + "doi": h.get("doi"), "bib": f"https://dblp.org/rec/{h.get('key')}.bib", "source": "dblp"} + + +def merge(cands): + """Deduplicate by DOI / arXiv id / normalized title; keep the most informative record.""" + seen, out = {}, [] + for c in cands: + keys = [k for k in (c.get("doi") and c["doi"].lower(), c.get("arxiv"), norm_title(c["title"])) if k] + hit = next((seen[k] for k in keys if k in seen), None) + if hit is None: + hit = dict(c); out.append(hit) + else: + for k, v in c.items(): + if v and not hit.get(k): + hit[k] = v + hit["citations"] = max(hit.get("citations") or 0, c.get("citations") or 0) + for k in keys: + seen[k] = hit + return out + + +def arxiv_bibtex(rec): + key = bib_key(rec["authors"], rec["year"], rec["title"]) + return (f"@misc{{{key},\n title = {{{rec['title']}}},\n author = {{{' and '.join(rec['authors'])}}},\n" + f" year = {{{rec['year']}}},\n eprint = {{{rec['arxiv']}}},\n archivePrefix = {{arXiv}},\n" + f" note = {{arXiv preprint}},\n url = {{https://arxiv.org/abs/{rec['arxiv']}}}\n}}") + + +def rekey_bibtex(entry, key): + """Replace the entry key (doi.org / dblp keys are ugly) and tidy whitespace.""" + entry = re.sub(r"^\s*@(\w+)\s*\{\s*[^,]*,", lambda m: f"@{m.group(1).lower()}{{{key},", entry.strip(), count=1) + return re.sub(r"\n\s*\n", "\n", entry) + + +# ----------------------------------------------------------------- network commands +def search(query, max_n=25, since=None): + q = urllib.parse.quote(query) + cands = [] + atom = get(f"https://export.arxiv.org/api/query?search_query=all:{q}&max_results={max_n}&sortBy=relevance", "application/atom+xml") + if atom: + cands += parse_arxiv(atom) + oa = get(f"https://api.openalex.org/works?search={q}&per-page={max_n}&mailto={urllib.parse.quote(UA.split('mailto:')[1][:-1])}") + if oa: + cands += parse_openalex(oa) + time.sleep(1) + s2 = get(f"https://api.semanticscholar.org/graph/v1/paper/search?query={q}&limit={max_n}" + "&fields=title,authors,year,abstract,venue,publicationVenue,externalIds,citationCount,openAccessPdf,publicationTypes") + if s2: + cands += parse_s2(s2) + merged = [c for c in merge(cands) if not since or (c.get("year") or "0") >= str(since)] + merged.sort(key=lambda c: -(c.get("citations") or 0)) + for c in merged: + c["key"] = bib_key(c.get("authors", []), c.get("year"), c.get("title")) + Path("candidates.json").write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding="utf-8") + print(f"{len(merged)} candidates (candidates.json). cites year venue key / title") + for c in merged: + venue = (c.get("venue") or ("arXiv" if c.get("arxiv") else "?"))[:26] + print(f"{c.get('citations') or 0:6d} {c.get('year') or ' '} {venue:26s} {c['key']} {c['title'][:70]}") + return merged + + +def resolve(ident): + """Find the peer-reviewed version of a paper given an arXiv id, DOI or title.""" + arxiv = ARXIV_RE.fullmatch(ident.strip()) or (ARXIV_RE.search(ident) if "arxiv" in ident.lower() else None) + doi = DOI_RE.search(ident) + rec = None + if arxiv or (doi and not is_arxiv_doi(doi.group(1))): + pid = f"arXiv:{arxiv.group(1)}" if arxiv else doi.group(1) + js = get(f"https://api.semanticscholar.org/graph/v1/paper/{pid}?fields=title,authors,year,venue,publicationVenue,externalIds,citationCount,openAccessPdf,publicationTypes") + rec = parse_s2(js)[0] if js and not js.startswith('{"error"') else None + title = rec["title"] if rec else ident.strip() + result = {"query": ident, "title": title, "preprint": None, "published": None} + if rec and rec.get("arxiv"): + result["preprint"] = {"arxiv": rec["arxiv"], "url": f"https://arxiv.org/abs/{rec['arxiv']}", "pdf": f"https://arxiv.org/pdf/{rec['arxiv']}"} + # 1. Semantic Scholar already knows a non-arXiv DOI + venue? + if rec and rec.get("doi") and not is_arxiv_doi(rec["doi"]) and rec.get("venue"): + result["published"] = {"doi": rec["doi"], "venue": rec["venue"], "year": rec["year"], "via": "semanticscholar", + "type": rec.get("venue_type") or ",".join(rec.get("types", []))} + time.sleep(1) + # 2. Crossref by exact title (journal/proceedings only) + if not result["published"]: + cr = get(f"https://api.crossref.org/works?query.bibliographic={urllib.parse.quote(title)}&rows=5&mailto={urllib.parse.quote(UA.split('mailto:')[1][:-1])}") + hit = parse_crossref(cr, title) if cr else None + if hit: + result["published"] = {"doi": hit["doi"], "venue": hit["venue"], "year": hit["year"], "via": "crossref", "type": hit["type"]} + # 3. dblp (CS): venue + a clean .bib + db = get(f"https://dblp.org/search/publ/api?q={urllib.parse.quote(title)}&format=json&h=10") + hit = parse_dblp(db, title) if db else None + if hit and hit["venue"] != "CoRR": + pub = result["published"] or {} + pub.update({k: v for k, v in {"venue": hit["venue"], "year": hit["year"], "doi": pub.get("doi") or hit.get("doi"), + "dblp": hit["key"], "bib": hit["bib"], "type": hit["type"]}.items() if v}) + pub["via"] = pub.get("via") or "dblp" + result["published"] = pub + authors = rec["authors"] if rec else [] + result["key"] = bib_key(authors, (result["published"] or {}).get("year") or (rec or {}).get("year"), title) + print(json.dumps(result, indent=2, ensure_ascii=False)) + if result["published"]: + print(f"\n-> cite the published version: {result['published'].get('venue')} ({result['published'].get('year')}), " + f"DOI {result['published'].get('doi')}; bibtex: python papers.py bibtex {result['published'].get('doi') or result['published'].get('dblp')}") + elif result["preprint"]: + print(f"\n-> no peer-reviewed version found: cite as arXiv preprint {result['preprint']['arxiv']} and say so in research.md") + return result + + +def bibtex(ident): + ident = ident.strip() + if ident.startswith("conf/") or ident.startswith("journals/"): # dblp key + entry = get(f"https://dblp.org/rec/{ident}.bib", "text/plain") + rec = None + elif (m := DOI_RE.search(ident)) and not is_arxiv_doi(m.group(1)): + entry = get(f"https://doi.org/{m.group(1)}", "application/x-bibtex") + rec = None + else: + aid = ARXIV_RE.search(ident) + if not aid: + print("give a DOI, an arXiv id or a dblp key", file=sys.stderr); return None + atom = get(f"https://export.arxiv.org/api/query?id_list={aid.group(1)}", "application/atom+xml") + rec = parse_arxiv(atom)[0] if atom else None + entry = arxiv_bibtex(rec) if rec else None + if not entry: + print("no BibTeX found", file=sys.stderr); return None + m_auth = re.search(r"author\s*=\s*[{\"](.+?)[}\"]\s*,?\s*\n", entry, re.S | re.I) + m_year = re.search(r"year\s*=\s*[{\"]?(\d{4})", entry, re.I) + m_title = re.search(r"title\s*=\s*[{\"](.+?)[}\"]\s*,?\s*\n", entry, re.S | re.I) + authors = [a.strip() for a in (m_auth.group(1) if m_auth else "").replace("\n", " ").split(" and ")] + authors = [(" ".join(reversed(a.split(", "))) if ", " in a else a) for a in authors] + key = bib_key(authors, m_year.group(1) if m_year else "", re.sub(r"[{}]", "", m_title.group(1)) if m_title else "") + entry = rekey_bibtex(entry, key) + print(entry) + return entry + + +def download(ident, out="papers"): + Path(out).mkdir(parents=True, exist_ok=True) + aid = ARXIV_RE.search(ident) + url, name = None, None + if ident.startswith("http"): + url, name = ident, re.sub(r"[^\w.-]", "_", ident.rsplit("/", 1)[-1]) or "paper.pdf" + elif aid: + url, name = f"https://arxiv.org/pdf/{aid.group(1)}", f"arxiv_{aid.group(1).replace('.', '_')}.pdf" + elif (m := DOI_RE.search(ident)): + oa = get(f"https://api.openalex.org/works/https://doi.org/{m.group(1)}") + best = (json.loads(oa).get("best_oa_location") or {}) if oa else {} + url, name = best.get("pdf_url"), re.sub(r"[^\w.-]", "_", m.group(1)) + ".pdf" + if not url: + print("no open-access PDF known for this DOI (try the arXiv version, or the publisher page via the library)"); return None + if not url: + print("give an arXiv id, a DOI or a URL", file=sys.stderr); return None + req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "application/pdf"}) + try: + with urllib.request.urlopen(req, timeout=60) as r: + data = r.read() + except Exception as e: # noqa: BLE001 + print(f"download failed: {e}", file=sys.stderr); return None + if not data.startswith(b"%PDF"): + print(f"not a PDF (got {data[:40]!r}) - probably a landing page or a captcha"); return None + path = Path(out) / name + path.write_bytes(data) + print(f"saved {path} ({len(data) // 1024} KB)") + return path + + +def text(pdf, pages=None): + try: + import pymupdf as fitz + except ImportError: + try: + import fitz + except ImportError: + print("pip install pymupdf", file=sys.stderr); return None + doc = fitz.open(pdf) + n = min(doc.page_count, pages or doc.page_count) + parts = [f"\n\n===== page {i + 1} =====\n" + doc[i].get_text() for i in range(n)] + out = Path(pdf).with_suffix(".txt") + out.write_text("".join(parts), encoding="utf-8") + print(f"{out}: {n}/{doc.page_count} pages, {sum(len(p) for p in parts)} chars") + return out + + +def main(argv): + if not argv or argv[0] in ("-h", "--help"): + print(__doc__); return 0 + cmd, args = argv[0], argv[1:] + opt = {a.split("=")[0]: True for a in args if a.startswith("--")} + def val(flag, default=None): + if flag in args: + i = args.index(flag); return args[i + 1] + return default + pos = [a for i, a in enumerate(args) if not a.startswith("--") and (i == 0 or not args[i - 1].startswith("--"))] + if cmd == "search": + search(" ".join(pos), int(val("--max", 25)), val("--since")) + elif cmd == "resolve": + resolve(" ".join(pos)) + elif cmd == "bibtex": + bibtex(" ".join(pos)) + elif cmd == "download": + download(pos[0], val("--out", "papers")) + elif cmd == "text": + text(pos[0], int(val("--pages")) if val("--pages") else None) + else: + print(__doc__); return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/skills/single-file-webapp/SKILL.md b/skills/single-file-webapp/SKILL.md new file mode 100644 index 0000000..c401f94 --- /dev/null +++ b/skills/single-file-webapp/SKILL.md @@ -0,0 +1,154 @@ +--- +name: single-file-webapp +description: > + Patterns for browser apps delivered as ONE self-contained HTML file (ES modules + from a CDN import map, UI wiring, state, save/load JSON, file import/export, + undo, test hooks, compact mobile-friendly layout) plus a static checker. Use for + any "build a tool/editor/game in a single HTML file" task, before writing the first line. +--- + +# Single-file web app + +## Structure +```html +<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>… + + + + + + +``` +Pin CDN versions (`@0.160.0`, never `@latest`). Keep everything in the file; no +build step, no npm for the app itself. + +## The five rules that prevent the usual bugs +1. **No inline handlers with a module script.** `onclick="save()"` cannot see + functions declared inside `", html, re.S | re.I) + has_module = any('type="module"' in a or "type='module'" in a for a, _ in scripts) + + for i, (attrs, body) in enumerate(scripts, 1): + if "importmap" in attrs: + try: + im = json.loads(body) + if not isinstance(im.get("imports"), dict): + findings.append(f"script {i}: importmap has no 'imports' object") + except json.JSONDecodeError as e: + findings.append(f"script {i}: importmap is not valid JSON ({e})") + continue + if "src=" in attrs or not body.strip(): + continue + suffix = ".mjs" if "module" in attrs else ".js" + with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as f: + f.write(body); tmp = f.name + r = subprocess.run(["node", "--check", tmp], capture_output=True, text=True) + if r.returncode: + msg = r.stderr.strip().splitlines() + findings.append(f"script {i}: syntax error - " + " | ".join(l for l in msg if l.strip())[:300]) + + if has_module: + for m in re.finditer(r"\son(\w+)\s*=\s*[\"']([^\"']+)[\"']", html): + findings.append(f"inline on{m.group(1)}=\"{m.group(2)[:40]}\" with a module script " + "- handler can't reach module scope; use addEventListener") + + ids = set(re.findall(r"\bid\s*=\s*[\"']([^\"']+)[\"']", html)) + used = set(re.findall(r"getElementById\(\s*[\"']([^\"']+)[\"']", html)) + used |= set(re.findall(r"querySelector(?:All)?\(\s*[\"']#([A-Za-z_][\w-]*)[\"']", html)) + for u in sorted(used - ids): + findings.append(f"element id '{u}' is referenced in JS but does not exist in the HTML") + + # --- mobile basics ------------------------------------------------------- + if not re.search(r"]+name=[\"']viewport[\"'][^>]*width=device-width", html, re.I): + findings.append('missing ' + "- the page renders as a tiny desktop page on phones") + for m in re.finditer(r"\b(alert|confirm|prompt)\s*\(", "\n".join(b for a, b in scripts if "src=" not in a)): + findings.append(f"{m.group(1)}() blocks and looks bad on phones - use a toast / small popup instead") + css = "\n".join(re.findall(r"]*>(.*?)", html, re.S | re.I)) + for m in re.finditer(r"(?:^|})\s*([^{}]*\b(?:input|select|textarea)\b[^{}]*)\{([^}]*)\}", css): + fs = re.search(r"font-size\s*:\s*(\d+(?:\.\d+)?)px", m.group(2)) + if fs and float(fs.group(1)) < 16: + findings.append(f"font-size {fs.group(1)}px on '{m.group(1).strip()[:40]}' - below 16px iOS zooms in on focus; " + "keep 16px and make fields compact with padding/width") + for m in re.finditer(r"(?:^|})\s*([^{}]*\b(?:input|select)\b[^{}]*)\{([^}]*)\}", css): + if re.search(r"(?=100px on '{m.group(1).strip()[:40]}' - size fields to their " + "content with ch units (e.g. width:5ch) or max-width") + for m in re.finditer(r"(?:^|})\s*([^{}]*(?:popup|modal|dialog|sheet|overlay)[^{}]*)\{([^}]*)\}", css, re.I): + body = m.group(2) + if re.search(r"position\s*:\s*(fixed|absolute)", body) and not ( + re.search(r"max-height", body) and re.search(r"overflow(-y)?\s*:\s*(auto|scroll)", body)): + findings.append(f"popup-like '{m.group(1).strip()[:40]}' is positioned but has no max-height + overflow:auto " + "- it can cover the screen and cannot scroll") + + for f_ in findings: + print("✗", f_) + print("ok" if not findings else f"{len(findings)} finding(s)") + return 1 if findings else 0 + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__); sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/skills/single-file-webapp/patterns.md b/skills/single-file-webapp/patterns.md new file mode 100644 index 0000000..477444d --- /dev/null +++ b/skills/single-file-webapp/patterns.md @@ -0,0 +1,164 @@ +# Snippets for single-file web apps + +## Layout (menu on top, panels left/right, main area fills) +```css +html,body{margin:0;height:100%;font:14px system-ui} +body{display:grid;grid-template-rows:40px 1fr;grid-template-columns:200px 1fr 240px; + grid-template-areas:"menu menu menu" "left main right"} +#menu{grid-area:menu} #left{grid-area:left;overflow:auto} #main{grid-area:main;position:relative} +#right{grid-area:right;overflow:auto} canvas{display:block} +``` +Size a canvas/renderer from `#main.clientWidth/clientHeight`, and again on `resize`. + +## Mobile layout (compact toolbar, side panels as bottom sheet, small popups) +```html + + +
+ +
+``` +```css +:root{--gap:4px;--tap:40px} +*{box-sizing:border-box} +html,body{margin:0;height:100%;overflow:hidden;font:14px/1.25 system-ui;touch-action:manipulation} +body{height:100dvh;display:grid;grid-template-rows:auto 1fr;grid-template-columns:1fr; + grid-template-areas:"menu" "main"} +/* one-row toolbar: icon buttons, scrolls sideways instead of wrapping */ +#menu{grid-area:menu;display:flex;gap:var(--gap);padding:calc(var(--gap)) var(--gap); + padding-top:max(var(--gap),env(safe-area-inset-top));overflow-x:auto;white-space:nowrap;align-items:center} +button{min-width:var(--tap);min-height:var(--tap);padding:0 8px;font-size:18px;line-height:1} +#status{margin-left:auto;font-size:12px;opacity:.7} +#main{grid-area:main;position:relative;min-height:0} canvas{display:block;touch-action:none} + +/* compact fields: width fits the content, text >=16px (no iOS zoom), small padding */ +input,select{font-size:16px;padding:2px 4px;min-height:32px} +.n4{width:5ch} .n6{width:7ch} .name{width:12ch} .hex{width:8ch} +.row{display:flex;flex-wrap:wrap;gap:var(--gap) 8px;align-items:center;margin:2px 0} +.row label{display:flex;align-items:center;gap:2px} /* tiny label left of the field */ + +/* side panels: drawer / bottom sheet on phones, fixed columns on wide screens */ +#left,#right{background:Canvas;overflow:auto;padding:var(--gap)} +@media (max-width:700px){ + #left,#right{position:fixed;left:0;right:0;bottom:0;max-height:40dvh;z-index:5; + border-top:1px solid #8886;transform:translateY(100%);transition:transform .15s} + #left.open,#right.open{transform:none} /* toggled by a ☰ / ⚙ button; main area stays visible above */ +} +@media (min-width:701px){ + body{grid-template-columns:200px 1fr 240px;grid-template-areas:"menu menu menu" "left main right"} + #left{grid-area:left} #right{grid-area:right} +} + +/* popups: small, anchored, scrollable inside, never full-screen */ +.popup{position:fixed;z-index:10;max-width:min(92vw,360px);max-height:50dvh;overflow:auto; + background:Canvas;color:CanvasText;border:1px solid #8886;border-radius:6px;padding:6px; + box-shadow:0 4px 16px #0004} +.popup[hidden]{display:none} +.toast{position:fixed;left:50%;bottom:8px;transform:translateX(-50%);max-width:90vw; + background:#222;color:#fff;padding:4px 10px;border-radius:12px;font-size:13px;z-index:20;pointer-events:none} +``` +```js +// popup anchored below its button, kept inside the viewport; closes on outside tap / Escape +function openPopup(pop, anchor) { + document.querySelectorAll('.popup').forEach(p => p.hidden = true); // only one open + pop.hidden = false; + const a = anchor.getBoundingClientRect(), w = pop.offsetWidth, h = pop.offsetHeight; + pop.style.left = Math.max(4, Math.min(a.left, innerWidth - w - 4)) + 'px'; + pop.style.top = (a.bottom + 4 + h > innerHeight ? Math.max(4, a.top - h - 4) : a.bottom + 4) + 'px'; +} +document.addEventListener('pointerdown', e => { + if (!e.target.closest('.popup, #menu button')) document.querySelectorAll('.popup').forEach(p => p.hidden = true); +}); +document.addEventListener('keydown', e => { if (e.key === 'Escape') document.querySelectorAll('.popup').forEach(p => p.hidden = true); }); +function toast(msg, ms = 2500) { // instead of alert() + const t = Object.assign(document.createElement('div'), { className: 'toast', textContent: msg }); + document.body.append(t); setTimeout(() => t.remove(), ms); +} +``` +Canvas: `canvas.style.touchAction = 'none'` and handle `pointerdown/move/up` +(+ two-pointer pinch) so dragging does not scroll the page. Resize on `resize` +and `orientationchange`. + +## UI wiring +```js +const $ = id => document.getElementById(id); +$('save').addEventListener('click', () => download('scene.json', JSON.stringify(toJSON(), null, 2))); +$('posX').addEventListener('input', e => { if (sel) { sel.position.x = +e.target.value; render(); } }); +document.addEventListener('keydown', e => { + if (e.target.tagName === 'INPUT') return; // don't steal keys from inputs + if (e.key === 'Delete') removeSelected(); + if ((e.ctrlKey || e.metaKey) && e.key === 'z') undo(); +}); +``` + +## Download / file import +```js +function download(name, data, type = 'text/plain') { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([data], { type })); + a.download = name; a.click(); URL.revokeObjectURL(a.href); +} +function pickFile(accept, asText, cb) { // asText=false -> ArrayBuffer (binary STL) + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = accept; + inp.onchange = () => { const r = new FileReader(); + r.onload = () => cb(r.result, inp.files[0].name); + asText ? r.readAsText(inp.files[0]) : r.readAsArrayBuffer(inp.files[0]); }; + inp.click(); +} +``` + +## Serialization with nested groups +```js +function objToJSON(o) { + return { id: o.id, type: o.type, params: { ...o.params }, hole: !!o.hole, color: o.color, + position: [...o.position], rotation: [...o.rotation], + children: o.type === 'group' ? o.children.map(objToJSON) : undefined }; +} +const toJSON = () => ({ version: 1, grid: state.grid, objects: state.objects.map(objToJSON) }); +const fromJSON = d => { state.objects = d.objects.map(objFromJSON); state.grid = d.grid ?? 1; state.selection.clear(); render(); }; +``` +`objFromJSON` is the only place that creates runtime objects from data - the +"add shape" buttons should call it too (`objFromJSON({type:'box', params:{...}})`), +so there is exactly one constructor path. + +## Undo / redo via snapshots +```js +const history = [], future = []; +function commit() { history.push(JSON.stringify(toJSON())); future.length = 0; if (history.length > 50) history.shift(); } +function undo() { if (!history.length) return; future.push(JSON.stringify(toJSON())); fromJSON(JSON.parse(history.pop())); } +``` +Call `commit()` BEFORE each mutation. + +## Selection with shift-click (bounding boxes drawn by render) +```js +function pick(ev) { /* hit test -> object or null */ } +canvas.addEventListener('pointerdown', ev => { + const o = pick(ev); + if (!ev.shiftKey) state.selection.clear(); + if (o) state.selection.has(o) ? state.selection.delete(o) : state.selection.add(o); + render(); updatePanel(); +}); +``` + +## Test hook (last lines of the module) +```js +window.__app = { + state, toJSON, fromJSON, toSTL, + add: (type, params = {}) => { const o = objFromJSON({ type, params }); state.objects.push(o); render(); return o.id; }, + select: ids => { state.selection = new Set(ids); render(); updatePanel(); }, + project: id => { // three.js: world position -> page pixels (for click tests) + const o = state.objects.find(x => x.id === id); + const v = new THREE.Vector3(...o.position).project(camera); + const r = renderer.domElement.getBoundingClientRect(); + return { x: r.left + (v.x + 1) / 2 * r.width, y: r.top + (1 - v.y) / 2 * r.height }; + }, +}; +``` diff --git a/skills/speech-synthesis/SKILL.md b/skills/speech-synthesis/SKILL.md new file mode 100644 index 0000000..1e5e347 --- /dev/null +++ b/skills/speech-synthesis/SKILL.md @@ -0,0 +1,84 @@ +--- +name: speech-synthesis +description: > + Text-to-speech with Kokoro-82M (open weights, runs on CPU): synthesize WAV + files with a female or male voice, choose British/American English and other + languages, blend voices, set the speed, batch long texts, and play or embed + the result. Use for any TTS, voice-over, audiobook, speaking assistant or + "read this aloud" task. +--- + +# Speech synthesis with Kokoro + +Kokoro-82M is a small, fast TTS model (Apache 2.0; ~330 MB download once). +Use the ready-made script [tts.py](./tts.py) - copy it into the project +(`cp /tts.py .`) or import `synthesize()` from it. + +## Setup +``` +pip install kokoro soundfile # torch comes with it; first run downloads the weights +apt install espeak-ng / brew install espeak-ng # only for non-English languages +``` +Python 3.10-3.12 recommended. CPU is fine: ~1 s of audio per 0.1-0.3 s compute. +A GPU is used automatically if torch sees one. + +## Female and male voice - the two calls that matter +```python +from kokoro import KPipeline +import soundfile as sf, numpy as np + +pipeline = KPipeline(lang_code='a') # 'a' American English, 'b' British English + +# feminine voice +audio = np.concatenate([a for _, _, a in pipeline("Good morning! The report is ready.", voice='af_heart')]) +sf.write('female.wav', audio, 24000) + +# masculine voice +audio = np.concatenate([a for _, _, a in pipeline("Good morning! The report is ready.", voice='am_michael')]) +sf.write('male.wav', audio, 24000) +``` +`pipeline(text, voice=..., speed=1.0)` is a generator: one `(graphemes, +phonemes, audio)` item per chunk (chunks split at newlines by default). Audio +is a float32 array at **24 kHz**. Voices: `af_*`/`am_*` American female/male, +`bf_*`/`bm_*` British. Good defaults: `af_heart` (F), `am_michael` (M), +`bf_emma` (F, British), `bm_george` (M, British). `python tts.py --list-voices` +prints all. + +## Script +``` +python tts.py "Hello there." --out hello.wav # af_heart (female) +python tts.py "Hello there." --voice am_michael --out hello_m.wav +python tts.py --file chapter.txt --voice bf_emma --lang b --speed 0.9 --out chapter.wav +python tts.py "Blend." --voice af_heart,am_michael --out blend.wav # average of two voices +``` +`synthesize(text, voice, lang, speed) -> (samples, 24000)` splits at blank +lines and sentence ends, inserts 250 ms pauses and concatenates - no length limit. + +## Languages +Kokoro-82M v1.0: `a` American English, `b` British English, `e` Spanish, +`f` French, `h` Hindi, `i` Italian, `j` Japanese, `p` Brazilian Portuguese, +`z` Mandarin (`pip install misaki[ja]` / `misaki[zh]` for j/z). **No German.** +For German use Piper (`pip install piper-tts`, voice `de_DE-thorsten-medium`) +and say so in the summary instead of feeding German text to an English voice. + +## Using the audio +- Play: `pip install sounddevice` then `sd.play(samples, 24000); sd.wait()`, + or `python tts.py ... --play`. +- MP3/OGG: write WAV, convert with `ffmpeg -i out.wav -b:a 96k out.mp3`. +- Web page: serve the WAV and use `