diff --git a/.github/topics.json b/.github/topics.json new file mode 100644 index 0000000..1affdea --- /dev/null +++ b/.github/topics.json @@ -0,0 +1 @@ +{"names":["ai-writing","writing-assistant","author-voice","creative-writing","fiction","nonfiction","knowledge-graph","graphrag","humanizer","deslop","codex","oauth","local-first","windows","python","jev","openrouter","typesafe"]} diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 388e93b..3dd0f02 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,4 +1,4 @@ -name: Windows desktop package +name: Build and publish Windows release on: workflow_dispatch: push: @@ -6,32 +6,53 @@ on: permissions: contents: write jobs: - package: - runs-on: windows-latest + quality: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - cache: pip - - run: python -m pip install -r requirements-dev.txt + - run: python -m pip install -q -r requirements-dev.txt - run: python -m pytest -q - - shell: pwsh - run: ./scripts/build_windows.ps1 - - name: Verify frozen backend serves the bundled interface + - run: python -m playwright install --with-deps chromium + - run: python scripts/ui_smoke.py + - run: python scripts/ui_studio_smoke.py + windows: + needs: quality + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -q -r requirements-dev.txt + - run: python -m pytest -q --junitxml=test-results/windows.xml + - name: Build verified Windows package with official Codex runtime shell: pwsh - run: | - python scripts/check_package.py dist/GraphPaper + env: + GITHUB_TOKEN: ${{ github.token }} + run: ./scripts/build_release.ps1 - uses: actions/upload-artifact@v4 with: name: GraphPaper-Windows-x64 - path: dist/GraphPaper-Windows-x64.zip - - name: Attach package to version release + path: | + dist/GraphPaper-v*-Windows-x64.zip + dist/SHA256SUMS.txt + - name: Publish a versioned release when built from a tag if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} shell: pwsh run: | + $version = python -c "from graphpaper import __version__; print(__version__)" + if ($env:GITHUB_REF_NAME -ne "v$version") { throw 'Tag does not match application version.' } gh release view $env:GITHUB_REF_NAME *> $null - if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --title "GraphPaper $env:GITHUB_REF_NAME" --generate-notes } - gh release upload $env:GITHUB_REF_NAME dist/GraphPaper-Windows-x64.zip --clobber + if ($LASTEXITCODE -ne 0) { gh release create $env:GITHUB_REF_NAME --verify-tag --title "GraphPaper $env:GITHUB_REF_NAME" --notes-file docs/RELEASE-0.2.md } + gh release upload $env:GITHUB_REF_NAME "dist/GraphPaper-v$version-Windows-x64.zip" dist/SHA256SUMS.txt --clobber + - uses: actions/upload-artifact@v4 + if: always() + with: + name: Windows-validation + path: test-results/ diff --git a/.gitignore b/.gitignore index 4596dff..884525c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ playwright-report/ *.sqlite3 *.sqlite3-shm *.sqlite3-wal + +vendor/ + +.build-venv/ + +.playwright/ diff --git a/CHANGELOG.md b/CHANGELOG.md index cf20207..1089cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 — 2026-10-05 + +- Learn and edit an author voice from uploaded writing or selected article URLs. +- Separate project folders, stable-file automatic Inbox imports, role subfolders and original uploads. +- Local prose inspection, humanizer/deslopping proposals, protected factual spans and explicit accept/reject with version history. +- Official Codex browser OAuth and subscription-based writing, with an isolated account directory and bundled Windows runtime. +- Dedicated new workflow, API, subprocess and browser tests; versioned Windows release packaging with SHA-256 provenance. + + ## 0.1.0 — 2026-10-05 Initial GraphPaper writing studio: diff --git a/README.md b/README.md index 40c258e..29caf4c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ GraphPaper is a local-first, Windows-oriented writing studio for **nonfiction an ![GraphPaper's interactive graph studio](docs/images/graph.webp) *Actual app screenshot using the explicitly illustrative project included with the application.* -## Open the studio on Windows +## GraphPaper 0.2.0 + +[**Download the Windows release**](https://github.com/AronAxe/GraphPaper/releases/latest) · [What is new](docs/UPDATE-0.2.md) + +Your own writing voice from uploads or article URLs; a project Inbox with automatic local imports; reversible humanizer/deslopping passes; and **Codex / ChatGPT browser sign-in instead of a writing API key**. The Windows package includes Python and the official Codex runtime. Existing API providers and the optional JEV connection remain available. + +## Open the source edition on Windows 1. Extract the complete source ZIP to a normal folder, not inside the ZIP viewer. 2. Double-click **`Open GraphPaper.vbs`**. This source edition needs **Python 3.11 or newer**, including Tcl/Tk. Python 3.12 or 3.13 is the conservative choice for the desktop dependencies. @@ -19,7 +25,7 @@ GraphPaper is a local-first, Windows-oriented writing studio for **nonfiction an There is no need to type commands. The native window uses Microsoft Edge WebView2. See [Windows setup and troubleshooting](docs/WINDOWS.md). -**Executable packaging:** a Windows build script and GitHub Actions workflow are included. They generate a portable `GraphPaper.exe` folder/ZIP; this source package is not a prebuilt or signed Windows executable. Native Windows execution remains to be validated. The backend and interface have been tested on Linux; see [validation scope](docs/QUALITY.md). +**Executable packaging:** a Windows build script and GitHub Actions workflow are included. They generate a portable `GraphPaper.exe` folder/ZIP; this source package is not a prebuilt or signed Windows executable. Version 0.2 has native Windows automated tests and real-HTTP browser validation; see [validation scope](docs/QUALITY.md). Interactive native dialogs remain a separate check. ## A connected writing workflow @@ -71,6 +77,12 @@ There are no analytics, remote fonts, hidden publishing or automatic agent loops **The city that forgot the dark** demonstrates nonfiction angle discovery with clearly fictional, illustrative source notes. **The last keeper of borrowed mornings** demonstrates literary speculative fiction with original canon and character relationships. They are interface examples, not benchmark results or verified reporting. +## Version 0.2 validation update + +The 0.2 update was tested on a native Windows 11 machine with an isolated Python 3.13 environment: **120 automated tests passed**, with one symlink-permission test skipped; **all 26 real-HTTP browser workflow checks passed** with zero JavaScript page errors. The official bundled **Codex CLI 0.160.1** passed its signed-out app-server handshake and required-command checks. These are local Windows results, not GitHub-hosted CI results; the hosted runner remained queued and that run was cancelled. Reports are in [docs/validation/v0.2](docs/validation/v0.2/) (or the corresponding directory from this documentation page). + +A real user OAuth completion, paid model calls, output-quality comparisons and hands-on native-window dialog interaction are not claimed as tested. Historical 0.1 results below remain a record of that earlier build. + ## Development and validation ```bash diff --git a/docs/QUALITY.md b/docs/QUALITY.md index fcdb2a7..4c890c4 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -1,5 +1,11 @@ # Quality, tests and known limits +## Version 0.2 validation update + +The 0.2 update was tested on a native Windows 11 machine with an isolated Python 3.13 environment: **120 automated tests passed**, with one symlink-permission test skipped; **all 26 real-HTTP browser workflow checks passed** with zero JavaScript page errors. The official bundled **Codex CLI 0.160.1** passed its signed-out app-server handshake and required-command checks. These are local Windows results, not GitHub-hosted CI results; the hosted runner remained queued and that run was cancelled. Reports are in [validation/v0.2](validation/v0.2/) (or the corresponding directory from this documentation page). + +A real user OAuth completion, paid model calls, output-quality comparisons and hands-on native-window dialog interaction are not claimed as tested. Historical 0.1 results below remain a record of that earlier build. + ## Initial local validation Validation date: **5 October 2026**. Initial build environment: **Linux, Python 3.13**, system Chromium. Subsequent GitHub Actions results are separate and visible in the repository's Actions tab. @@ -50,3 +56,5 @@ The practical quality goal is a better final article or story with less author c The source launcher and visual native shell need a real Windows interactive validation pass. No signed installer is included. Provider endpoint/model availability can change. Citation support is sampled. There is no automatic paywall bypass, OCR, live-web fact verification, trained author-preference model, cloud-sync collaboration or full WYSIWYG editor. HTML/Word typography should still be checked for a publisher's specific template. Large graphs are visually capped while the full project remains stored. Graphify IDs and native canonical IDs may leave duplicate conceptual labels after augmentation. Direction and pins influence candidate exploration but do not guarantee every requested connection is defensible. + +The compiled Windows executable passed its startup, local backend and bundled-interface self-test. The versioned Windows ZIP includes the official Codex runtime and its verified provenance. This check does not complete a real account OAuth login or make a live model request. diff --git a/docs/RELEASE-0.2.md b/docs/RELEASE-0.2.md new file mode 100644 index 0000000..fa1772e --- /dev/null +++ b/docs/RELEASE-0.2.md @@ -0,0 +1,26 @@ +## GraphPaper 0.2.0 + +A Windows writing studio for fiction and nonfiction, now with: + +- **Your writing voice:** upload samples, paste writing, or discover and selectively import articles from your author/blog URL; learn an editable style profile with adjustable influence. +- **Project folders:** a local Inbox per project, automatic idle-time import, role-specific subfolders and original upload files. +- **Humanizer and deslopping:** separate or combined editing passes, explainable local inspection, protected factual spans, side-by-side proposals, diffs and explicit acceptance. +- **Codex / ChatGPT sign-in:** use your account allowance instead of a writing API key; official runtime included, with browser OAuth handled by Codex. Other API providers remain available. JEV is a separate optional connection. + +### Windows + +Download **GraphPaper-v0.2.0-Windows-x64.zip**, extract the complete folder and open **GraphPaper.exe**. No separate Python installation or terminal workflow is needed. Keep the `_internal` companion folder. Edge WebView2 must be available. The build is unsigned. + +The SHA256SUMS file records the package checksum. The bundled Codex runtime has its own version, download URL and SHA-256 provenance in `_internal/vendor/codex-release.json`. + +### Quality and limits + +The build process runs automated tests, browser workflows and actual Windows package checks. Model responses in tests use controlled fixtures. The official runtime is checked while signed out; a real user's OAuth ceremony and live prose generation cannot be completed by CI. Inspect claims, citations and proposed edits before publishing. + +Existing projects load with defaults for the new features. User data stays in the existing local GraphPaper data directory; extracting the new executable does not replace your project database. + +### Verified on Windows + +120 automated tests passed; one symlink-permission test was skipped. All 26 browser workflow checks passed over real local HTTP with no JavaScript page errors. Official Codex CLI 0.160.1 passed its signed-out app-server handshake. Hosted Actions were queued, so these results come from the isolated native Windows build, not a claimed successful CI run. + +The compiled Windows executable passed its startup, local backend and bundled-interface self-test. The versioned Windows ZIP includes the official Codex runtime and its verified provenance. This check does not complete a real account OAuth login or make a live model request. diff --git a/docs/UPDATE-0.2.md b/docs/UPDATE-0.2.md new file mode 100644 index 0000000..c061f46 --- /dev/null +++ b/docs/UPDATE-0.2.md @@ -0,0 +1,63 @@ +# GraphPaper 0.2 — your voice, your project folder, cleaner prose + +## Your writing voice + +In **Sources → Your writing voice**, upload your own writing, paste a sample, or choose **Find my writing online**. The website tool reads the article/author page you enter and lists up to 30 same-site candidate links. Choose up to 12 of your own pieces to import. It does not assume every link was written by you or crawl an entire site invisibly. + +**Learn my writing style** analyzes balanced beginning/middle/end excerpts and measurable cadence. The resulting profile is editable and has an influence slider and an on/off switch. It guides drafting, revision and humanizing. This is reusable, source-informed prompting, not weight fine-tuning or a guarantee of perfect imitation. Voice samples never become factual citations. When samples change, an old learned profile is ignored until refreshed; current samples can still guide style. Explicitly edited instructions are treated as author direction. + +## A folder for every project + +**Sources → Project folder** opens the project's workspace in Windows Explorer. Its stable ID keeps it associated with the same project even after renaming. The folder's ABOUT file records its human-readable title. + +Put files in **Inbox**, or its **Evidence**, **Voice**, **Canon** or **Inspiration** subfolders. GraphPaper checks automatically every five seconds while that project is open, visible and idle. It waits for a stable file before importing; **Import files now** runs an explicit check. Files added while the app is closed are picked up on a later visit. Source changes do not silently trigger paid model requests. + +Changed inbox files update their associated sources without multiplying duplicates. Removing files leaves the imported source intact. Source roles, file limits, symlink checks and conflict protection remain in effect. Uploads through the app are also kept in **Originals**. **Exports** is an available folder for your own output. Deleting the database project does not delete the folder. SQLite still holds the authoritative graph, outline, draft and history; use Export → Project backup for a portable project. + +## Humanizer and deslopping + +The writing desk offers **Humanize**, **Deslop**, **Both**, and a no-cost **Inspect prose** action. Humanizing focuses on the author's cadence and expression; deslopping focuses on filler, repetition, staged emphasis, formulaic contrasts and unsupported vague attribution. + +These are editing tools, not AI-authorship detectors or promises to evade detection. Intentional dashes, repetition, formal language, humor and scientific qualifications are not blindly banned. The author's samples take precedence over generic style advice. + +Quoted text, numbers, citations, links, code and blockquotes are protected during the edit. Missing or newly invented protected material fails validation. A separate editorial check looks for meaning drift. The proposal is stored separately, with side-by-side comparison and a line diff. **Accept** is explicit; changed drafts cannot be overwritten by stale proposals, and the previous draft remains in Versions. A model's fidelity judgment is not proof; check material claims yourself. + +### Research and inspiration + +GitHub searches for widely starred writing humanizers identified **blader/humanizer**, whose repository showed **54,162 stars on 5 October 2026**. Its structural-pattern-first approach, author-sample priority, and rewrite-then-check workflow informed this independent implementation. **stephenturner/skill-deslop** provided a second writing-specific reference for filler, repeated structures, inflated stakes and context-sensitive register. **peteromallet/desloppify** was excluded as the main prose reference because it targets code quality rather than article editing. No code or full prompt text from those projects is vendored here. + +Primary references: +- https://github.com/blader/humanizer +- https://github.com/blader/humanizer/blob/main/SKILL.md +- https://github.com/stephenturner/skill-deslop +- https://github.com/stephenturner/skill-deslop/blob/main/SKILL.md + +## Codex / ChatGPT sign-in instead of a writing API key + +In **Connections**, select **Codex — sign in with ChatGPT**, then press **Sign in with ChatGPT**. Complete the official browser flow and check the connection. A blank model uses Codex's configured default; the model list can also be loaded. The Windows release bundles an official, digest-verified Codex runtime. A source installation can point to an existing native runtime. + +GraphPaper uses Codex's documented app-server login and non-interactive execution. It does not scrape ChatGPT, ask you to paste session tokens, read another application's auth.json, or silently fall back to API billing. Codex handles its own credentials in GraphPaper's separate `codex-account` directory. Sign-out affects this directory, not your other Codex setups. Runtime provenance and its Apache licence are included in the bundle. Provider API keys are not inherited by the child process. + +Writing through Codex consumes the applicable ChatGPT/Codex account allowance and remains subject to that account's limits and OpenAI's policies. Cloud processing still needs your consent. Codex controls its own output limits; GraphPaper's API output-token setting does not override them. Token usage is recorded when the runtime reports it, without inventing a monetary cost. **JEV is separate**: it needs its own optional OpenRouter/TypeSafe connection. Without one, writing still works and graph screening is explicitly unscored. External Graphify CLI mode also needs separate API credentials; Native graph extraction works with Codex sign-in. + +Primary official documentation: +- https://developers.openai.com/codex/auth +- https://developers.openai.com/codex/app-server +- https://developers.openai.com/codex/noninteractive +- https://github.com/openai/codex + +## Validation boundaries + +The release process runs the existing suite plus new voice, inbox, provenance, protected-edit and Codex subprocess tests. Browser workflows exercise uploads, voice learning/editing, folder ownership and automatic import, humanizing/deslopping comparisons, acceptance/rejection and the Codex settings option. Controlled model and authentication doubles do not establish real prose quality or complete a real user OAuth ceremony. + +A separate Windows check runs the actual bundled Codex executable, verifies the supported CLI flags, and performs a signed-out app-server handshake. No live model request or user sign-in is performed in CI. The compiled GraphPaper application also runs its backend/assets self-test. Interactive Windows dialog behavior and completion of your own browser sign-in remain user-environment checks. The executable is unsigned. + +## Interface previews + +![Author voice settings](images/voice-v0.2.webp) + +![Reversible humanizer comparison](images/humanizer-v0.2.webp) + +![Codex sign-in settings](images/codex-v0.2.webp) + +These are actual UI renders using controlled test material, not live-model quality demonstrations. diff --git a/docs/images/codex-v0.2.webp b/docs/images/codex-v0.2.webp new file mode 100644 index 0000000..9d1e424 Binary files /dev/null and b/docs/images/codex-v0.2.webp differ diff --git a/docs/images/folder-v0.2.webp b/docs/images/folder-v0.2.webp new file mode 100644 index 0000000..a499d31 Binary files /dev/null and b/docs/images/folder-v0.2.webp differ diff --git a/docs/images/humanizer-v0.2.webp b/docs/images/humanizer-v0.2.webp new file mode 100644 index 0000000..b926209 Binary files /dev/null and b/docs/images/humanizer-v0.2.webp differ diff --git a/docs/images/voice-v0.2.webp b/docs/images/voice-v0.2.webp new file mode 100644 index 0000000..fe4ed47 Binary files /dev/null and b/docs/images/voice-v0.2.webp differ diff --git a/docs/validation/v0.2/browser.json b/docs/validation/v0.2/browser.json new file mode 100644 index 0000000..6d7a759 --- /dev/null +++ b/docs/validation/v0.2/browser.json @@ -0,0 +1,25 @@ +{ + "mode": "HTTP browser", + "live_models": false, + "checks": [ + "Welcome and empty state", + "Illustrative graph renders with 18 nodes", + "Inspect graph node and persist pin", + "Path query and highlight", + "Edit and select an angle", + "Edit and reorder outline", + "Manuscript autosave and safe live preview", + "Clickable source references", + "Read and restore earlier version", + "Export options and valid Word output", + "Configure provider through UI (mock transport only)", + "Browser file-input upload and source ingestion", + "Complete graph \u2192 JEV \u2192 angle \u2192 outline \u2192 draft \u2192 review path with deterministic AI", + "Explicit revision job and versioning", + "Fiction from premise, scene generation and continuity ledger", + "Light theme", + "Responsive 390px layout without horizontal overflow" + ], + "page_errors": [], + "ok": true +} \ No newline at end of file diff --git a/docs/validation/v0.2/codex-runtime.json b/docs/validation/v0.2/codex-runtime.json new file mode 100644 index 0000000..2e69cdb --- /dev/null +++ b/docs/validation/v0.2/codex-runtime.json @@ -0,0 +1,7 @@ +{ + "ok": true, + "version": "codex-cli 0.160.1", + "unauthenticated_app_server_handshake": true, + "live_model_call": false, + "browser_oauth_completed": false +} \ No newline at end of file diff --git a/docs/validation/v0.2/loopback.json b/docs/validation/v0.2/loopback.json new file mode 100644 index 0000000..d381108 --- /dev/null +++ b/docs/validation/v0.2/loopback.json @@ -0,0 +1 @@ +{"ok": true, "checks": {"/health": {"status": 200, "bytes": 29}, "/": {"status": 200, "bytes": 718}, "/static/app.js": {"status": 200, "bytes": 71000}, "/static/styles.css": {"status": 200, "bytes": 25023}}} \ No newline at end of file diff --git a/docs/validation/v0.2/package.json b/docs/validation/v0.2/package.json new file mode 100644 index 0000000..e9f9282 --- /dev/null +++ b/docs/validation/v0.2/package.json @@ -0,0 +1,21 @@ +{ + "ok": true, + "checks": { + "/health": { + "status": 200, + "bytes": 29 + }, + "/": { + "status": 200, + "bytes": 718 + }, + "/static/app.js": { + "status": 200, + "bytes": 71000 + }, + "/static/styles.css": { + "status": 200, + "bytes": 25023 + } + } +} \ No newline at end of file diff --git a/docs/validation/v0.2/studio.json b/docs/validation/v0.2/studio.json new file mode 100644 index 0000000..792ea05 --- /dev/null +++ b/docs/validation/v0.2/studio.json @@ -0,0 +1,17 @@ +{ + "mode": "real HTTP", + "live_models": false, + "checks": [ + "Voice sample upload through the actual file input", + "Learn, inspect and edit a persistent author voice profile", + "Project folder import and automatic source ownership", + "Idle folder watcher detects and imports new files without an AI call", + "Local explainable prose inspection", + "Humanizer proposal, side-by-side comparison and explicit acceptance", + "Separate deslopping pass and non-destructive rejection", + "Codex subscription provider selectable without an API key", + "New controls fit a smaller desktop window" + ], + "page_errors": [], + "ok": true +} \ No newline at end of file diff --git a/docs/validation/v0.2/windows.xml b/docs/validation/v0.2/windows.xml new file mode 100644 index 0000000..6bb0983 --- /dev/null +++ b/docs/validation/v0.2/windows.xml @@ -0,0 +1 @@ +C:\Users\aron\AppData\Local\Temp\GraphPaper-release-97a16cdb5c6f4a558141f884c6d33f38\tests\test_studio_update.py:172: Symlinks unavailable for this Windows account \ No newline at end of file diff --git a/graphpaper/__init__.py b/graphpaper/__init__.py index f62b835..d9ef2ba 100644 --- a/graphpaper/__init__.py +++ b/graphpaper/__init__.py @@ -1,2 +1,2 @@ """GraphPaper: an evidence-aware, graph-native writing studio.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/graphpaper/author_web.py b/graphpaper/author_web.py new file mode 100644 index 0000000..e02b7d5 --- /dev/null +++ b/graphpaper/author_web.py @@ -0,0 +1,58 @@ +"""Bounded link discovery for a user-specified author page. No hidden site crawl.""" +from __future__ import annotations +import http.client +from urllib.parse import urljoin, urlsplit, urlunsplit +from bs4 import BeautifulSoup +from .ingest import resolve_public, PinnedHTTPS, MAX_BYTES + + +def extract_links(html, base): + host = urlsplit(base).hostname + soup = BeautifulSoup(html, 'html.parser') + found = {base:{'url':base,'title':soup.title.get_text(' ',strip=True) if soup.title else 'This page'}} + for a in soup.select('article a[href], main a[href], .post a[href], .entry-title a[href], a[href]'): + href = urljoin(base,a.get('href','')) + p = urlsplit(href) + if p.scheme not in {'http','https'} or p.hostname != host or p.username or p.password: + continue + if p.query or any(s in p.path.lower() for s in ['/login','/signup','/privacy','/terms','/tag/','/category/','/feed','/wp-json','/search']): + continue + if p.path.lower().endswith(('.jpg','.png','.svg','.zip','.js','.css')): + continue + url = urlunsplit((p.scheme,p.netloc,p.path,'','')) + title = a.get_text(' ',strip=True) + if len(title) < 8 or url in found: + continue + found[url] = {'url':url,'title':title[:250]} + if len(found) >= 30: + break + return list(found.values()) + + +def discover(url): + for _ in range(5): + p, host, port, ip = resolve_public(url) + conn = PinnedHTTPS(host,ip,port) if p.scheme == 'https' else http.client.HTTPConnection(ip,port,timeout=25) + try: + conn.request('GET',(p.path or '/')+('?' + p.query if p.query else ''), headers={'Host':host,'User-Agent':'GraphPaper/0.2 author-source-discovery','Accept-Encoding':'identity'}) + response = conn.getresponse() + if response.status in {301,302,303,307,308}: + destination = response.getheader('Location') + if not destination: + raise ValueError('Redirect did not include a destination.') + url = urljoin(url,destination) + continue + if response.status != 200: + raise ValueError(f'This page returned HTTP {response.status}. Paste text or upload saved articles instead.') + if response.getheader('Content-Encoding','identity') not in {'','identity'}: + raise ValueError('The site returned unsupported compressed content.') + data = response.read(MAX_BYTES+1) + if len(data) > MAX_BYTES: + raise ValueError('Author page exceeds 20 MB.') + if 'html' not in response.getheader('Content-Type','text/html').lower(): + return {'pages':[{'url':url,'title':'Import this source'}], 'note':'Direct document link.'} + return {'pages':extract_links(data.decode('utf-8',errors='replace'),url), + 'note':'These are same-site candidate pages, not verified authorship. Select only your writing. Nothing is imported or sent to a model until you choose it.'} + finally: + conn.close() + raise ValueError('Too many redirects.') diff --git a/graphpaper/codex.py b/graphpaper/codex.py new file mode 100644 index 0000000..412ba97 --- /dev/null +++ b/graphpaper/codex.py @@ -0,0 +1,233 @@ +"""Official Codex sign-in and execution bridge. Never reads or exports OAuth tokens.""" +from __future__ import annotations +import atexit +import json +import os +from pathlib import Path +import queue +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import webbrowser +from urllib.parse import urlsplit +from .providers import ProviderError, Cancelled + +REGISTRY = {} +REGISTRY_LOCK = threading.RLock() + + +def find_executable(configured=''): + root = Path(getattr(sys, '_MEIPASS', Path(__file__).resolve().parent.parent)) + candidates = [configured, str(root/'vendor'/'codex.exe'), str(root/'vendor'/'codex'), shutil.which('codex')] + for candidate in candidates: + if candidate and Path(candidate).is_file() and Path(candidate).suffix.lower() not in {'.cmd','.bat','.ps1'}: + return str(Path(candidate).resolve()) + # npm shims are not executed through a shell; locate their native binary instead. + npm = Path(os.getenv('APPDATA', str(Path.home()))) / 'npm' / 'node_modules' / '@openai' + for pattern in ['codex/vendor/**/codex.exe', 'codex/node_modules/@openai/codex-win32-*/vendor/**/codex.exe', 'codex-win32-*/vendor/**/codex.exe']: + for path in npm.glob(pattern): + if path.is_file(): + return str(path.resolve()) + raise ProviderError('Codex runtime was not found. Use the Windows release with the bundled runtime, or select an installed native Codex executable in Connections.') + + +def safe_login_url(url): + p = urlsplit(url) + return p.scheme == 'https' and p.hostname in {'auth.openai.com','chatgpt.com','auth0.openai.com'} and not p.username and not p.password + + +class Codex: + def __init__(self, root, executable=''): + self.exe = find_executable(executable) + self.home = Path(root)/'codex-account' + self.home.mkdir(parents=True, exist_ok=True) + try: + self.home.chmod(0o700) + except OSError: + pass + config = self.home/'config.toml' + if not config.exists(): + config.write_text('forced_login_method = "chatgpt"\ncli_auth_credentials_store = "auto"\napproval_policy = "never"\nsandbox_mode = "read-only"\nweb_search = "disabled"\n[features]\nshell_tool = false\n',encoding='utf-8') + self.env = {k:v for k,v in os.environ.items() if not k.endswith(('_API_KEY','_TOKEN')) and k not in {'OPENAI_BASE_URL','CODEX_HOME'}} + self.env['CODEX_HOME'] = str(self.home) + self.env['PYTHONIOENCODING'] = 'utf-8' + self.proc = None + self.pending = {} + self.lock = threading.RLock() + self.sequence = 0 + self.login_id = None + self.login_error = '' + + def start(self): + with self.lock: + if self.proc and self.proc.poll() is None: + return + flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + self.proc = subprocess.Popen([self.exe,'app-server'],cwd=self.home,env=self.env, + stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL, + text=True,encoding='utf-8',errors='strict',bufsize=1,creationflags=flags) + threading.Thread(target=self._read,args=(self.proc,),daemon=True,name='graphpaper-codex-auth').start() + self.rpc('initialize',{'clientInfo':{'name':'graphpaper','title':'GraphPaper','version':'0.2.0'}}) + self._send({'method':'initialized','params':{}}) + + def _send(self, value): + with self.lock: + if not self.proc or self.proc.poll() is not None: + raise ProviderError('Codex stopped. Reconnect in Connections.') + self.proc.stdin.write(json.dumps(value,ensure_ascii=False)+'\n') + self.proc.stdin.flush() + + def _read(self, proc): + try: + while True: + line = proc.stdout.readline(2_000_001) + if not line: + break + if len(line) > 2_000_000: + raise ValueError('Oversized protocol message') + data = json.loads(line) + if 'id' in data and 'method' not in data: + waiter = self.pending.get(data['id']) + if waiter: + waiter.put(data) + elif 'id' in data: + # Sign-in needs no command/file permission; never approve agent operations here. + self._send({'id':data['id'],'error':{'code':-32601,'message':'GraphPaper does not authorize tool execution through the authentication connection.'}}) + elif data.get('method') == 'account/login/completed': + self.login_error = '' if data.get('params',{}).get('success') else 'Sign-in did not complete. Try again.' + except Exception: + self.login_error = 'The Codex connection stopped. Reconnect and try again.' + finally: + for waiter in list(self.pending.values()): + waiter.put({'error':{'message':'Codex connection closed'}}) + + def rpc(self, method, params=None, timeout=40): + with self.lock: + self.sequence += 1 + ident = self.sequence + waiter = queue.Queue() + self.pending[ident] = waiter + try: + self._send({'id':ident,'method':method,'params':params or {}}) + try: + value = waiter.get(timeout=timeout) + except queue.Empty as exc: + raise ProviderError('Codex did not respond. Check the installed runtime and reconnect.') from exc + if 'error' in value: + raise ProviderError(f'Codex could not complete {method}. Reconnect or update the official runtime; no credentials were exposed.') + return value.get('result',{}) + finally: + self.pending.pop(ident,None) + + def status(self): + self.start() + account = self.rpc('account/read',{'refreshToken':False}).get('account') or {} + return {'installed':True,'signed_in':account.get('type')=='chatgpt', 'email':account.get('email',''), + 'plan':account.get('planType',''), 'auth_type':account.get('type'), 'error':self.login_error, + 'note':'Uses your Codex/ChatGPT account allowance. JEV is a separate optional service; no API key is required for writing.'} + + def login(self): + self.start() + result = self.rpc('account/login/start',{'type':'chatgpt'}) + url = result.get('authUrl','') + if not safe_login_url(url): + raise ProviderError('Codex returned an unexpected sign-in destination. No browser was opened.') + self.login_id = result.get('loginId') + self.login_error = '' + webbrowser.open(url) + return {'started':True,'auth_url':url,'note':'Complete sign-in in your browser, then check connection status. Codex handles the local OAuth callback and credential storage.'} + + def logout(self): + self.start() + self.rpc('account/logout') + return {'signed_in':False} + + def models(self): + self.start() + result = self.rpc('model/list',{'limit':100}) + return [{'id':m.get('model',m.get('id','')),'name':m.get('displayName',m.get('model',''))} for m in result.get('data',[]) if m.get('model') or m.get('id')] + + def complete(self, system, user, model, job=None, max_calls=80): + if not self.status()['signed_in']: + raise ProviderError('Sign in with ChatGPT in Connections before using the Codex provider. API-key sessions are not used by this option.') + if job: + job.before_call('Codex',max_calls) + flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 + with tempfile.TemporaryDirectory(prefix='graphpaper-codex-') as temp: + output = Path(temp)/'answer.txt' + args = [self.exe,'exec','--ephemeral','--skip-git-repo-check','--sandbox','read-only','--json', + '-c','approval_policy="never"','-c','features.shell_tool=false','-c','web_search="disabled"', + '-o',str(output),'-'] + if model: + args[-1:-1] = ['--model',model] + prompt = ('You are the prose/structured-output engine inside GraphPaper. This is a text transformation, not a coding task. ' + 'Do not use tools, inspect files, execute commands or access the network. Return only the requested answer.\n\n' + + system + '\n\n' + user) + proc = subprocess.Popen(args,cwd=temp,env=self.env,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE, + text=True,encoding='utf-8',errors='replace',creationflags=flags) + started = time.monotonic() + try: + first = True + while True: + if job: + job.check() + if time.monotonic()-started > 600: + raise ProviderError('Codex exceeded the ten-minute call limit; the previous document remains intact.') + try: + stdout, stderr = proc.communicate(input=prompt if first else None,timeout=.5) + break + except subprocess.TimeoutExpired: + first = False + usage = {} + for line in stdout.splitlines(): + try: + event = json.loads(line) + if event.get('type') == 'turn.completed': + usage = event.get('usage',{}) + except (ValueError,TypeError): + continue + if job: + job.record_usage('Codex subscription',usage,model or 'Codex default') + if proc.returncode or not output.exists(): + raise ProviderError('Codex could not finish this request. Check sign-in, your subscription usage limit, model availability and the runtime version. No API fallback was attempted.') + if output.stat().st_size > 2_000_000: + raise ProviderError('Codex returned an oversized answer.') + text = output.read_text(encoding='utf-8').strip() + if not text: + raise ProviderError('Codex returned an empty answer.') + return text + finally: + if proc.poll() is None: + proc.terminate() + try: + proc.communicate(timeout=5) + except subprocess.TimeoutExpired: + proc.kill();proc.communicate() + + def close(self): + with self.lock: + if self.proc and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=4) + except subprocess.TimeoutExpired: + self.proc.kill() + + +def get_codex(root, executable=''): + key = (str(Path(root).resolve()),executable) + with REGISTRY_LOCK: + if key not in REGISTRY: + REGISTRY[key] = Codex(root,executable) + return REGISTRY[key] + + +def close_all(): + for client in list(REGISTRY.values()): + client.close() + REGISTRY.clear() + +atexit.register(close_all) diff --git a/graphpaper/folders.py b/graphpaper/folders.py new file mode 100644 index 0000000..db762f1 --- /dev/null +++ b/graphpaper/folders.py @@ -0,0 +1,152 @@ +"""Per-project workspaces. Local inbox files become sources without cloud calls.""" +from __future__ import annotations +import hashlib +import json +import os +from pathlib import Path +import re +import threading +import time +from .ingest import MAX_BYTES, clean, digest, extract +from .models import Source + +EXTENSIONS = {'.pdf','.docx','.txt','.md','.markdown','.csv','.html','.htm'} +ROLES = {'evidence','canon','inspiration','voice'} + + +def safe_name(value): + text = re.sub(r'[^\w .-]', '_', value, flags=re.UNICODE).strip(' .')[:100] or 'source' + if text.split('.')[0].upper() in {'CON','PRN','AUX','NUL', *[f'COM{i}' for i in range(1,10)], *[f'LPT{i}' for i in range(1,10)]}: + text = '_' + text + return text + + +class ProjectFolders: + def __init__(self, store, runner): + self.store, self.runner = store, runner + self.lock = threading.RLock() + self.observed = {} + + def path(self, project): + if not re.fullmatch(r'p_[a-f0-9]{12}', project.id): + raise ValueError('Invalid project ID for filesystem access.') + parent = self.store.root / 'Projects' + parent.mkdir(exist_ok=True) + if parent.is_symlink() or (hasattr(parent, 'is_junction') and parent.is_junction()): + raise ValueError('Project root must not be a symbolic link or junction.') + folder = parent / project.id + if folder.is_symlink() or (hasattr(folder, 'is_junction') and folder.is_junction()): + raise ValueError('Project folder must not be a symbolic link or junction.') + folder.mkdir(exist_ok=True) + for name in ['Inbox', 'Originals', 'Exports', *['Inbox/'+r.capitalize() for r in sorted(ROLES)]]: + target = folder / name + if target.is_symlink() or (hasattr(target,'is_junction') and target.is_junction()): + raise ValueError('Workspace subfolders must not be links or junctions.') + target.mkdir(exist_ok=True) + readme = folder/'ABOUT THIS PROJECT.txt' + if not readme.exists(): + readme.write_text(f'{project.title}\nGraphPaper project: {project.id}\n\nDrop documents in Inbox (or a role subfolder). They are imported automatically while this project is open and idle, and on the next visit. Files in Inbox/Voice are writing-style samples. Inbox/Canon contains fictional canon. Inbox/Evidence contains citable sources. No AI calls happen during import.\n\nOriginals holds files uploaded through the app. Exports is available for your own work. Removing a file does not silently delete its imported source. Deleting a project in the app leaves this folder intact. SQLite remains the authoritative project store; export a project backup for portability.\n', encoding='utf-8') + return folder + + def original(self, project, source, data=None, filename=None): + with self.lock: + folder = self.path(project)/'Originals' + name = source.id + '-' + safe_name(filename or source.title) + if data is None: + name += '.txt' + data = source.text.encode('utf-8') + target = folder/name + if target.is_symlink(): + raise ValueError('Original-file destination cannot be a symbolic link.') + if not target.exists(): + target.write_bytes(data) + return target + + def info(self, project): + folder = self.path(project) + return {'path': str(folder), 'inbox': str(folder/'Inbox'), 'automatic': project.auto_import, + 'note': 'Checks while the project is open and idle. No cloud calls. Deleting a project does not delete this folder.'} + + def scan(self, project_id, force=False): + with self.runner.lock, self.lock, self.store.lock: + project = self.store.get(project_id) + if self.runner.active(project_id): + return {'project': project.model_dump(), 'events': [], 'busy': True, **self.info(project)} + inbox = self.path(project)/'Inbox' + records = self.store.cache_get('inbox:'+project_id) or {} + events, changed, seen = [], False, set() + count = 0 + # No following symlink/junction directories, including links nested in user folders. + for folder, dirs, names in os.walk(inbox, followlinks=False): + dirs[:] = [d for d in dirs if not d.startswith('.') and not (Path(folder)/d).is_symlink() and not (hasattr(Path(folder)/d,'is_junction') and (Path(folder)/d).is_junction())] + for name in sorted(names): + path = Path(folder)/name + if name.startswith(('.', '~$')) or path.suffix.lower() not in EXTENSIONS: + continue + count += 1 + if count > 150: + events.append({'status':'warning','path':'Inbox','message':'Only the first 150 supported files were checked. Split larger collections.'}) + break + if path.is_symlink() or inbox.resolve() not in path.resolve().parents: + events.append({'status':'error','path':name,'message':'Links outside the project inbox are not imported.'}) + continue + relative = path.relative_to(inbox).as_posix() + seen.add(relative) + old = records.get(relative, {}) + try: + stat = path.stat() + signature = [stat.st_size, stat.st_mtime_ns] + key = (project_id, relative) + observation = self.observed.get(key) + self.observed[key] = signature + if old.get('stat') == signature: + continue + if not force and observation != signature: + continue + if stat.st_size > MAX_BYTES: + raise ValueError('File exceeds 20 MB.') + data = path.read_bytes() + after = path.stat() + if [after.st_size, after.st_mtime_ns] != signature: + continue + binary_hash = hashlib.sha256(data).hexdigest() + if old.get('binary_hash') == binary_hash: + records[relative] = {**old, 'stat':signature} + continue + text, warnings = extract(data, name) + text_hash = digest(text) + first = Path(relative).parts[0].lower() + role = first if first in ROLES else ('canon' if project.mode == 'fiction' else 'evidence') + current = next((s for s in project.sources if s.id == old.get('source_id')), None) + duplicate = next((s for s in project.sources if s.digest == text_hash and s.role == role and s is not current), None) + if duplicate: + source = duplicate + status = 'duplicate' + elif current: + if current.digest != old.get('text_hash'): + raise ValueError('The imported source changed separately. Rename the inbox file to import a new source without overwriting edits.') + current.text, current.digest, current.warnings = text, text_hash, warnings + source, status, changed = current, 'updated', True + else: + if len(project.sources) >= 100: + raise ValueError('Project source limit of 100 reached.') + number = max([int(s.id[1:]) for s in project.sources if re.fullmatch(r'S\d+',s.id)] + [0])+1 + source = Source(id=f'S{number}', title=name, text=text, role=role, kind=path.suffix[1:], digest=text_hash, warnings=warnings) + project.sources.append(source) + status, changed = 'imported', True + records[relative] = {'source_id':source.id,'binary_hash':binary_hash,'text_hash':text_hash,'stat':signature} + events.append({'status':status,'path':relative,'source_id':source.id,'role':source.role}) + except Exception as exc: + events.append({'status':'error','path':relative,'message':str(exc)[:300]}) + if count > 150: + break + for missing in sorted(set(records)-seen): + if not records[missing].get('missing'): + events.append({'status':'retained','path':missing,'message':'File removed; imported source retained.'}) + records[missing]['missing'] = True + if changed: + if project.graph.nodes: + project.graph.warnings = list(dict.fromkeys(project.graph.warnings + ['Sources changed after the last graph build. Rebuild before relying on coverage.'])) + project = self.store.save(project, project.version) + self.store.cache_put('inbox:'+project_id, records) + return {'project': project.model_dump(), 'events':events, 'changed':changed, **self.info(project)} diff --git a/graphpaper/graphify_adapter.py b/graphpaper/graphify_adapter.py index b27fab0..ab729d5 100644 --- a/graphpaper/graphify_adapter.py +++ b/graphpaper/graphify_adapter.py @@ -14,6 +14,8 @@ def run_graphify(project: Project, clients: Clients, job): + if clients.settings.provider == "codex": + raise ValueError("Use Native extraction or import a Graphify JSON graph with Codex sign-in. The optional external Graphify process requires its own API credentials.") executable = clients.settings.graphify_executable or shutil.which("graphify") if not executable or not Path(executable).is_file(): raise ValueError("Graphify is not installed or its executable was not found. Select Native in Connections, import graph.json, or install graphifyy and choose its executable.") diff --git a/graphpaper/models.py b/graphpaper/models.py index b2119e4..1d36d2f 100644 --- a/graphpaper/models.py +++ b/graphpaper/models.py @@ -126,6 +126,30 @@ class Review(Model): draft_hash: str = "" +class VoiceProfile(Model): + enabled: bool = True + strength: int = Field(75, ge=0, le=100) + name: str = Field('My writing voice', max_length=160) + instructions: str = Field('', max_length=12000) + sample_ids: list[str] = Field(default_factory=list) + sample_hash: str = '' + learned_at: str = '' + metrics: dict[str, Any] = Field(default_factory=dict) + observations: list[str] = Field(default_factory=list) + + +class PolishCandidate(Model): + mode: str + original_hash: str + draft: str = Field(max_length=1_000_000) + created: str = '' + review: dict[str, Any] = Field(default_factory=dict) + before: dict[str, Any] = Field(default_factory=dict) + after: dict[str, Any] = Field(default_factory=dict) + protected_spans: int = 0 + diff: list[str] = Field(default_factory=list) + + class Project(Model): id: str = Field(default_factory=lambda: uid("p_")) title: str = Field(default="Untitled project", min_length=1, max_length=200) @@ -146,12 +170,16 @@ class Project(Model): feedback: list[dict[str, Any]] = Field(default_factory=list) usage: dict[str, Any] = Field(default_factory=lambda: {"calls": 0, "input_tokens": 0, "output_tokens": 0, "reported_cost": 0.0, "unpriced_calls": 0}) demo: bool = False + voice_profile: VoiceProfile = Field(default_factory=VoiceProfile) + polish: PolishCandidate | None = None + auto_import: bool = True class Settings(Model): - provider: Literal["openrouter", "openai-compatible", "anthropic"] = "openrouter" + provider: Literal["openrouter", "openai-compatible", "anthropic", "codex"] = "openrouter" base_url: str = "https://openrouter.ai/api/v1" model: str = "" + codex_executable: str = "" editor_model: str = "" extraction_model: str = "" max_output_tokens: int = Field(7000, ge=1000, le=64000) diff --git a/graphpaper/pipeline.py b/graphpaper/pipeline.py index e06eba4..96f0d07 100644 --- a/graphpaper/pipeline.py +++ b/graphpaper/pipeline.py @@ -253,7 +253,8 @@ def source_pack(p: Project, query: str, limit=38000, ids=None): def voice_notes(p: Project): - return [{"title": s.title, "sample": s.text[:3500], "note": "Style reference only. Do not copy wording or treat as factual evidence."} for s in p.sources if s.enabled and s.role == "voice"][:3] + from .voice import voice_context + return voice_context(p) def make_outline(p: Project, clients: Clients, job: Job): @@ -330,7 +331,7 @@ def revise(p: Project, clients: Clients, job: Job, store: Store, instruction="", review = review_draft(p, clients, job) if not p.review.summary or p.review.draft_hash != digest(p.draft) else p.review pack = source_pack(p, (instruction or review.summary) + original[:4000], min(28000, clients.settings.context_chars // 3)) job.note("Revising the draft without changing its intended thesis or fictional canon.", 87) - revised = clients.complete(BOUNDARY, dumps({"task": "Revise this complete piece. Preserve its best lines, nuance, voice, factual limits and intentional ending. Fix material issues rather than flattening style. Do not add unsupported claims. Return the complete revised Markdown only, not a critique or preface. Source citations remain [S1] format. Fiction has no inline scholarly citations.", "mode": p.mode, "brief": p.brief.model_dump(), "author_instruction": instruction, "review": review.model_dump(), "draft": original, "sources": pack}), role="editor") + revised = clients.complete(BOUNDARY, dumps({"task": "Revise this complete piece. Preserve its best lines, nuance, voice, factual limits and intentional ending. Fix material issues rather than flattening style. Do not add unsupported claims. Return the complete revised Markdown only, not a critique or preface. Source citations remain [S1] format. Fiction has no inline scholarly citations.", "mode": p.mode, "brief": p.brief.model_dump(), "author_instruction": instruction, "review": review.model_dump(), "draft": original, "sources": pack, "author_voice": voice_notes(p)}), role="editor") # Invalid references cannot be accepted through an automated revision gate. audit = citation_audit(p, revised) candidate_project = p.model_copy(deep=True) @@ -391,7 +392,7 @@ def active(self, project_id): return next((j for j in self.jobs.values() if j.project_id == project_id and j.state in {"queued", "running"}), None) def start(self, project_id, action, instruction=""): - if action not in {"graph", "angles", "outline", "draft", "review", "revise"}: + if action not in {"graph", "angles", "outline", "draft", "review", "revise", "voice", "humanize", "deslop", "both"}: raise ValueError("Unknown action") with self.lock: if self.active(project_id): @@ -413,7 +414,13 @@ def _run(self, job, p, settings, instruction): clients = self.clients_factory(settings, self.vault, job) try: self.store.snapshot(p, "Before " + job.action) - if job.action == "graph": + if job.action == "voice": + from .voice import learn_voice + learn_voice(p, clients, job) + elif job.action in {"humanize", "deslop", "both"}: + from .polish import polish_draft + polish_draft(p, clients, job, job.action, instruction) + elif job.action == "graph": build_graph(p, clients, self.store, job) elif job.action == "angles": make_angles(p, clients, job) diff --git a/graphpaper/polish.py b/graphpaper/polish.py new file mode 100644 index 0000000..90ba1a3 --- /dev/null +++ b/graphpaper/polish.py @@ -0,0 +1,97 @@ +"""Prose craft tools: explainable local lint plus reversible model-assisted edits.""" +from __future__ import annotations +from collections import Counter +import difflib +import hashlib +import json +import re +from .voice import metrics, voice_context + +# Independent editorial heuristics informed by the projects credited in docs/UPDATE-0.2.md. +RULES = [ + ('staged-opening', r"\b(?:let['’]s (?:dive in|delve|explore)|without further ado|here['’]s what you need to know|in today['’]s (?:fast[- ]paced|rapidly evolving|digital) (?:world|landscape))\b", 'Open with the actual point rather than announcing it.'), + ('empty-emphasis', r'\b(?:a testament to|pivotal moment|plays a (?:crucial|vital|key) role|cannot be overstated|game[- ]changer|revolutionary breakthrough)\b', 'Replace inflated importance with a supported mechanism or consequence.'), + ('staged-contrast', r"\bnot (?:just|merely|only)\b[^.!?\n]{1,150}\bbut\b", 'Keep this contrast only when both halves do real argumentative work.'), + ('vague-authority', r'\b(?:experts agree|studies show|research suggests|many believe|it is widely (?:known|believed))\b', 'Name the source or keep the attribution appropriately limited.'), + ('stock-transition', r'(?m)^\s*(?:Moreover|Furthermore|Additionally|In conclusion|In summary|To sum up)[,:]', 'Check whether the transition adds meaning or repeats a template.'), + ('stacked-hedges', r'\b(?:could potentially|might possibly|may perhaps|potentially possibly)\b', 'Remove redundant qualifiers, not genuine scientific uncertainty.'), + ('filler', r'\b(?:it is (?:important|worthwhile|essential) to (?:note|mention|remember)|at the end of the day|in order to|the fact of the matter is)\b', 'Say the useful part directly.'), + ('empty-closer', r'\b(?:let that sink in|read that again|that is the real (?:win|lesson)|the future is bright|only time will tell)\b', 'End with a concrete development, not a repeated moral.'), + ('corporate-language', r'\b(?:leverage synergies|unlock (?:the )?(?:full )?potential|seamless integration|holistic approach|ever[- ]evolving landscape|rich tapestry)\b', 'Prefer the exact action, relationship or image.'), + ('chat-wrapper', r"(?m)^\s*(?:Certainly!|Absolutely!|Here is (?:the|your) (?:revised|rewritten)|I hope this helps)", 'Remove chat scaffolding from the manuscript.'), +] +PROTECTED = re.compile(r'(?ms)```.*?```|~~~.*?~~~|^---\n.*?\n---(?=\n|$)|^>[^\n]*(?:\n>[^\n]*)*|`[^`\n]+`|\[[^\]\n]*\]\([^\)\n]+\)|\[S\d+\]|https?://[^\s<>]+|“[^”]+”|"[^"\n]+"|(?= 160: + break + paragraphs = [p.strip() for p in re.split(r'\n\s*\n', prose) if p.strip()] + openings = Counter(' '.join(re.findall(r'\w+', p.lower())[:3]) for p in paragraphs) + repeated = [k for k, count in openings.items() if count >= 3 and len(k.split()) == 3] + return {'findings': findings[:160], 'count': len(findings), 'repeated_openings': repeated[:15], 'metrics': metrics(text), + 'note': 'Editorial cues, not AI-authorship detection. Deliberate repetition, dashes, formal vocabulary and genuine uncertainty may be correct. English-specific phrase cues; the model edit can work in the manuscript language.'} + + +def polish_draft(project, clients, job, mode, instruction=''): + from .pipeline import BOUNDARY + from .models import PolishCandidate, now + from .providers import ProviderError + if mode not in {'humanize', 'deslop', 'both'}: + raise ValueError('Unknown prose edit mode.') + original = project.draft + if not original.strip(): + raise ValueError('Write or generate a draft first.') + before = lint(original) + masked, saved = mask(original) + job.note('Inspecting prose habits and preserving quotations, citations, numbers, links and code.', 10) + task = ('HUMANIZE: make this sound like the author, with purposeful rhythm, natural syntax and concrete existing detail. ' + if mode == 'humanize' else 'DESLOP: remove filler, repeated conclusions, stock emphasis, staged contrasts, vague authority and templated cadence. ' + if mode == 'deslop' else 'HUMANIZE AND DESLOP: remove formulaic filler while restoring the author’s natural rhythm and specificity. ') + prompt = {'task': task + 'Return the complete revised Markdown only. Use the same language. Preserve every material claim, uncertainty, causal direction, named entity, narrator, character motivation and intended ending. Do not invent personal experiences, facts, anecdotes, evidence or additional scenes. Do not mechanically ban punctuation or replace every formal word. Author samples override generic style rules. Every GPKEEP marker must remain exactly once, in its correct semantic position. Markers conceal protected spans; do not rewrite, renumber or infer new contents for them.', + 'mode': project.mode, 'brief': project.brief.model_dump(), 'voice': voice_context(project), + 'author_instruction': instruction, 'lint': before, 'draft': masked} + candidate = clients.complete(BOUNDARY, json.dumps(prompt, ensure_ascii=False), role='editor') + candidate = unmask(candidate, saved) + if Counter(m.group(0) for m in PROTECTED.finditer(original)) != Counter(m.group(0) for m in PROTECTED.finditer(candidate)): + raise ProviderError('The proposed edit introduced or altered protected factual material. Original preserved.') + if len(candidate.strip()) < max(20, len(original.strip()) * .3): + raise ProviderError('The proposed edit discarded too much of the manuscript. Original preserved.') + job.note('Checking the edit for meaning drift before presenting it beside the original.', 65) + review = clients.complete(BOUNDARY, json.dumps({'task': 'Compare original and proposed prose edit. Identify factual/semantic drift, altered named entities, lost qualifiers or reordered citations that no longer support their claims. For fiction also check narrator, scene events, motivation and ending. Judge fidelity separately from fluency. Return an honest review. Do not rewrite.', 'mode': project.mode, 'original': original, 'candidate': candidate, + 'schema': {'meaning_preserved': True, 'summary': 'Specific editorial assessment', 'warnings': ['Concrete risks or unresolved differences'], 'improvements': ['Specific changes']}}, ensure_ascii=False), role='editor', json_mode=True) + if not isinstance(review.get('meaning_preserved'), bool): + raise ProviderError('The edit reviewer did not provide a valid fidelity assessment. Original preserved.') + project.polish = PolishCandidate(mode=mode, original_hash=hashlib.sha256(original.encode()).hexdigest(), draft=candidate, + created=now(), review=review, before=before, after=lint(candidate), protected_spans=len(saved), + diff=list(difflib.unified_diff(original.splitlines(), candidate.splitlines(), fromfile='Original', tofile='Proposed', lineterm=''))[:12000]) + job.note('Proposed edit ready. Compare the versions and explicitly accept or discard it.', 95) diff --git a/graphpaper/providers.py b/graphpaper/providers.py index 58d07a0..fd29384 100644 --- a/graphpaper/providers.py +++ b/graphpaper/providers.py @@ -111,7 +111,7 @@ def post(self, url: str, payload: dict, key: str, label: str, anthropic=False) - def complete(self, system: str, user: str, *, role="writer", json_mode=False, max_tokens=None) -> str | dict: s = self.settings model = {"editor": s.editor_model, "extraction": s.extraction_model}.get(role) or s.model - if not model.strip(): + if not model.strip() and s.provider != "codex": raise ProviderError("Choose a writing model in Connections. Model IDs are configurable; GraphPaper does not silently substitute one.") if len(system) + len(user) > s.context_chars: raise ProviderError("This request exceeds your configured context character budget. Raise it for a suitable model, narrow the selected graph or split the project. Nothing was silently truncated.") @@ -119,6 +119,11 @@ def complete(self, system: str, user: str, *, role="writer", json_mode=False, ma base = endpoint(s.base_url) if json_mode: system += "\nReturn only a valid JSON object. No fences, commentary, NaN or Infinity." + if s.provider == "codex": + from .codex import get_codex + self.check('https://chatgpt.com') + text = get_codex(self.vault.path.parent, s.codex_executable).complete(system, user, model, self.job, s.max_calls) + return parse_json(text) if json_mode else text if s.provider == "anthropic": data = self.post((base if base.endswith("/v1") else base + "/v1") + "/messages", {"model": model, "system": system, "messages": [{"role": "user", "content": user}], "max_tokens": cap}, self.llm_key(), role, True) if data.get("stop_reason") == "max_tokens": @@ -178,6 +183,9 @@ def decide(self, state: dict | str, questions: dict) -> dict | None: return answers def discover_models(self): + if self.settings.provider == 'codex': + from .codex import get_codex + return get_codex(self.vault.path.parent, self.settings.codex_executable).models() if self.settings.provider == "anthropic": headers = {"x-api-key": self.llm_key(), "anthropic-version": "2023-06-01"} else: diff --git a/graphpaper/server.py b/graphpaper/server.py index 3dcee68..c9c7302 100644 --- a/graphpaper/server.py +++ b/graphpaper/server.py @@ -147,6 +147,7 @@ async def create_project(request: Request): if p.mode == "fiction": p.brief.format = "Short story" p.brief.avoid = "Expository monologues, generic imagery, unearned resolutions, continuity breaks." + app.state.folders.path(p) return store.create(p) @app.post("/api/demo") @@ -168,7 +169,7 @@ async def update_project(pid: str, request: Request): version = raw.pop("version", None) if version != p.version: raise Conflict("A newer project version exists. Refresh; your unsaved editor text has been kept in this window.") - allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback"} + allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback", "voice_profile", "auto_import"} if set(raw) - allowed: raise ValueError("Unsupported project field") if "draft" in raw and p.draft != raw["draft"]: @@ -193,13 +194,15 @@ def add_source(pid, title, text, kind="text", role="evidence", url="", warnings= raise ValueError("Limit: 100 sources per project.") text = clean(text) fingerprint = digest(text) - if any(s.digest == fingerprint for s in p.sources): + if any(s.digest == fingerprint and s.role == role for s in p.sources): raise ValueError("This text is already in the project.") n = max([int(s.id[1:]) for s in p.sources if re.fullmatch(r"S\d+", s.id)] + [0]) + 1 p.sources.append(Source(id=f"S{n}", title=title[:300], text=text, kind=kind, role=role, url=url, warnings=warnings or [], digest=fingerprint, author=author[:300], published=published[:100])) if p.graph.nodes: p.graph.warnings = list(dict.fromkeys(p.graph.warnings + ["Sources changed after the last graph build. Rebuild before relying on coverage."])) - return store.save(p, p.version) + p = store.save(p, p.version) + app.state.folders.original(p, p.sources[-1]) + return p @app.post("/api/projects/{pid}/sources/text") async def source_text(pid: str, request: Request): @@ -212,7 +215,9 @@ async def source_file(pid: str, file: UploadFile = File(...), role: str = Form(" await file.close() title = Path(file.filename or "source.txt").name[:300] text, warnings = await run_in_threadpool(extract, data, title) - return add_source(pid, title, text, kind=Path(title).suffix.lstrip("."), role=role, warnings=warnings) + p = add_source(pid, title, text, kind=Path(title).suffix.lstrip("."), role=role, warnings=warnings) + app.state.folders.original(p, p.sources[-1], data, title) + return p @app.post("/api/projects/{pid}/sources/url") async def source_url(pid: str, request: Request): @@ -322,5 +327,7 @@ async def import_project(request: Request): p.title = (p.title + " · imported")[:200] return store.create(p) + from .studio_routes import install + install(app, store, vault, runner, settings) app.mount("/static", StaticFiles(directory=asset_directory()), name="static") return app diff --git a/graphpaper/studio_routes.py b/graphpaper/studio_routes.py new file mode 100644 index 0000000..684a407 --- /dev/null +++ b/graphpaper/studio_routes.py @@ -0,0 +1,113 @@ +"""Author voice, project inbox, prose editing and Codex UI endpoints.""" +from __future__ import annotations +import hashlib +import os +import subprocess +import sys +from fastapi import Request +from starlette.concurrency import run_in_threadpool +from .models import Review +from .storage import Conflict +from .folders import ProjectFolders +from .polish import lint +from .voice import fingerprint, metrics, samples + + +def install(app, store, vault, runner, settings): + folders = ProjectFolders(store,runner) + app.state.folders = folders + + @app.get('/api/projects/{pid}/workspace') + def workspace(pid: str): + p = store.get(pid) + return folders.info(p) + + @app.post('/api/projects/{pid}/workspace/scan') + async def scan(pid: str, request: Request): + raw = await request.json() + return await run_in_threadpool(folders.scan,pid,bool(raw.get('force',False))) + + @app.post('/api/projects/{pid}/workspace/open') + def open_folder(pid: str): + folder = folders.path(store.get(pid)) + if sys.platform == 'win32': + os.startfile(str(folder)) + elif sys.platform == 'darwin': + subprocess.Popen(['open',str(folder)]) + elif os.getenv('DISPLAY'): + subprocess.Popen(['xdg-open',str(folder)],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL) + else: + return {'opened':False,'path':str(folder)} + return {'opened':True,'path':str(folder)} + + @app.get('/api/projects/{pid}/voice') + def voice(pid: str): + p = store.get(pid) + return {'profile':p.voice_profile.model_dump(),'samples':[{'id':s.id,'title':s.title,'url':s.url,'metrics':metrics(s.text)} for s in samples(p)], + 'stale':bool(p.voice_profile.sample_hash and p.voice_profile.sample_hash!=fingerprint(p))} + + @app.post('/api/voice/discover') + async def discover(request: Request): + from .author_web import discover + raw = await request.json() + return await run_in_threadpool(discover,str(raw.get('url',''))) + + @app.post('/api/projects/{pid}/craft/audit') + def audit(pid: str): + return lint(store.get(pid).draft) + + @app.post('/api/projects/{pid}/craft/accept') + async def accept(pid: str, request: Request): + raw = await request.json() + with runner.lock, store.lock: + if runner.active(pid): + raise ValueError('Finish or cancel the current job first.') + p = store.get(pid) + if raw.get('version') != p.version: + raise Conflict('The project changed. Refresh and compare the current draft before applying an edit.') + candidate = p.polish + if not candidate or candidate.original_hash != hashlib.sha256(p.draft.encode()).hexdigest(): + raise Conflict('The draft changed since this edit was proposed. Generate a fresh edit; nothing was overwritten.') + if not candidate.review.get('meaning_preserved') and not raw.get('acknowledge_warnings',False): + raise ValueError('The reviewer flagged meaning changes. Check the comparison and explicitly acknowledge those warnings before applying.') + store.snapshot(p,'Before '+candidate.mode) + p.draft = candidate.draft + p.polish = None + p.review = Review() + p = store.save(p,p.version) + store.snapshot(p,'Accepted prose edit') + return p + + @app.post('/api/projects/{pid}/craft/discard') + async def discard(pid: str, request: Request): + raw = await request.json() + with runner.lock, store.lock: + p = store.get(pid) + if runner.active(pid): + raise ValueError('Finish or cancel the current job first.') + if raw.get('version') != p.version: + raise Conflict('The project changed. Refresh before discarding this candidate.') + p.polish = None + return store.save(p,p.version) + + def codex(): + from .codex import get_codex + return get_codex(store.root,settings().codex_executable) + + @app.get('/api/codex/status') + async def codex_status(): + from .providers import ProviderError + try: + return await run_in_threadpool(lambda:codex().status()) + except ProviderError as e: + return {'installed':False,'signed_in':False,'error':str(e)} + + @app.post('/api/codex/login') + async def codex_login(): + return await run_in_threadpool(lambda:codex().login()) + + @app.post('/api/codex/logout') + async def codex_logout(): + if any(j.state in {'queued','running'} for j in runner.jobs.values()): + raise ValueError('Finish or cancel active jobs before signing out.') + return await run_in_threadpool(lambda:codex().logout()) diff --git a/graphpaper/voice.py b/graphpaper/voice.py new file mode 100644 index 0000000..3929664 --- /dev/null +++ b/graphpaper/voice.py @@ -0,0 +1,82 @@ +"""Author-controlled voice profiles. Samples guide style, never factual support.""" +from __future__ import annotations +import hashlib +import json +import re +import statistics +from .models import now + + +def samples(project): + return [s for s in project.sources if s.enabled and s.role == 'voice'] + + +def fingerprint(project): + return hashlib.sha256(json.dumps([(s.id, hashlib.sha256(s.text.encode()).hexdigest()) for s in samples(project)]).encode()).hexdigest() + + +def metrics(text): + words = re.findall(r"\b[\w]+(?:['’][\w]+)?\b", text) + sentences = [len(re.findall(r'\w+', s)) for s in re.split(r'[.!?]+(?:\s|$)', text) if re.search(r'\w', s)] + paragraphs = [len(re.findall(r'\w+', s)) for s in re.split(r'\n\s*\n', text) if s.strip()] + return {'words': len(words), 'sentences': len(sentences), 'mean_sentence_words': round(statistics.mean(sentences), 1) if sentences else 0, + 'sentence_variation': round(statistics.pstdev(sentences), 1) if sentences else 0, + 'mean_paragraph_words': round(statistics.mean(paragraphs), 1) if paragraphs else 0, + 'questions_per_1000_words': round(text.count('?') * 1000 / max(1, len(words)), 1), + 'dashes_per_1000_words': round(len(re.findall('[—–]', text)) * 1000 / max(1, len(words)), 1)} + + +def sample_pack(project, budget=24000): + """Balanced start/middle/end excerpts, not just the first article's introduction.""" + selected = samples(project) + if not selected: + return [] + selected = selected[:24] + per_source = max(200, budget // len(selected)) + result = [] + for s in selected: + if len(s.text) <= per_source: + excerpts = [s.text] + else: + width = per_source // 3 + middle = max(0, len(s.text)//2 - width//2) + excerpts = [s.text[:width], s.text[middle:middle+width], s.text[-width:]] + result.append({'source_id': s.id, 'title': s.title, 'excerpts': excerpts}) + return result + + +def voice_context(project, budget=9000): + profile = project.voice_profile + if not profile.enabled or profile.strength == 0: + return {'enabled': False} + stale = bool(profile.sample_hash and profile.sample_hash != fingerprint(project)) + return {'enabled': True, 'influence_percent': profile.strength, + 'instructions': profile.instructions if not stale else '', + 'profile_needs_refresh': stale, + 'samples': sample_pack(project, budget), + 'rule': 'Match authorial choices, rhythm, diction and wit, not specific sentences or personal experiences. Samples are style material only, not facts. Preserve deliberate literary devices and the brief. Do not invent lived experience. A stale profile is ignored; current samples still guide style.'} + + +def learn_voice(project, clients, job): + from .providers import ProviderError + from .pipeline import BOUNDARY + from .models import VoiceProfile + material = samples(project) + if not material: + raise ValueError('Add writing samples using the Voice role first.') + if sum(len(s.text.split()) for s in material) < 80: + raise ValueError('Use at least 80 words of your writing; several full pieces produce a more representative profile.') + job.note('Learning rhythm, diction, structure and rhetorical habits from your selected writing.', 15) + budget = min(28000, max(4000, clients.settings.context_chars - 9000)) + raw = clients.complete(BOUNDARY, + json.dumps({'task': 'Build an editable author voice profile from these samples. Observe prose mechanics, not personality, identity, factual beliefs or biography. Distinguish recurring choices from one-off subject matter. Preserve nuanced qualifiers and intentional punctuation; do not impose a generic casual style. Produce instructions usable for both new writing and faithful editing. Note limited or mixed evidence. Do not quote or copy distinctive sample sentences.', + 'samples': sample_pack(project, budget), 'measured_style': [metrics(s.text) for s in material], + 'output_schema': {'name': 'Short author voice label', 'instructions': 'Specific prose guidance with headings for rhythm, diction, structure, stance, humor, imagery, punctuation, fiction/nonfiction differences and avoidances.', 'observations': ['Short, evidence-limited observations']}}, ensure_ascii=False), + role='extraction', json_mode=True) + if not isinstance(raw.get('instructions'), str) or len(raw['instructions'].strip()) < 30: + raise ProviderError('The model did not return a usable voice profile. Existing profile preserved.') + project.voice_profile = VoiceProfile(name=str(raw.get('name', 'My writing voice'))[:160], instructions=raw['instructions'][:12000], + enabled=True, strength=project.voice_profile.strength, sample_ids=[s.id for s in material], sample_hash=fingerprint(project), + learned_at=now(), metrics=metrics('\n\n'.join(s.text for s in material)), + observations=[str(x)[:500] for x in raw.get('observations', [])[:12]]) + job.note('Voice profile ready. Edit it, adjust its influence, or switch it off.', 95) diff --git a/publish-manifest.json b/publish-manifest.json index a61870f..d2692fe 100644 --- a/publish-manifest.json +++ b/publish-manifest.json @@ -2,71 +2,99 @@ "repository": "AronAxe/GraphPaper", "files": { ".gitattributes": "acdb1038616f95b335ceb851c161a6139bf23561b2a74894a9ccb33ded5e71f4", + ".github/topics.json": "341aa983b1829624dec7ab7cd2e83eed08d45bc78305033c1e860631c1227e41", ".github/workflows/import-build.yml": "c56a990b97242f154907f76059f5b859448ca8de7b582928d63bb3717ac0d60a", ".github/workflows/quality.yml": "8cb33528c74e888ea54ef1715d83a093402bd2d1d2491d6be6f69fef6b6fb30b", - ".github/workflows/windows.yml": "97cd8d77889cc402e83e38ae858b4508c04d66c829d5a69354c9f4bc88ab3243", - ".gitignore": "f6913a561709aacbcb26c9f750f7891be315c794b2ee7dcc9cbcacc8e2d499a8", - "CHANGELOG.md": "91703d04a40a08e8984044807933dd2467663b72d55d7d4df23c410b73e87bd8", + ".github/workflows/windows.yml": "71c6a0dcb1cf9d85f4321822fca51ba6c4d6832e23156f4758bfcc394ce0d9bc", + ".gitignore": "229a84e2b07c91b31c30f06df236ea17fe211eb75b217adc2eecb3f9d377f9bf", + "CHANGELOG.md": "602dc1d73f768c6d35eb0ce9ddad0a28a55e2c41de4b41aaf8b999cc6d54b85c", "GraphPaper.pyw": "e862a9309815483e4c166396da8eccd304c118461f6b220246dd18c23bcc9bb4", "LICENSE": "5849844cbc9e9e199cf576224279d63d5fa56ca913f7a13285e4aa4fcb99bf80", "Open GraphPaper.vbs": "6ddf70eba29c281f9373c11b6aa7f69862c8ea7b03050b974c98d817ca11974e", "Publish GraphPaper.pyw": "a55984668dd0040d9bb5fc8c4c18ea5a201eede7e8a51ab79f8f0f095162ec8b", "Publish GraphPaper.vbs": "f9c595bcd13da5df60dcc2cfd906665243628f6e6c10a8b6a03ad1392a9698ad", - "README.md": "25bbc9ce3d5315737b98b0337238ff41586a068d198af424f942d9e489cbec4d", + "README.md": "8143bd573f2895b73af114b25ed90c2d58b4f44247d3fa01446da6db412537be", "docs/ARCHITECTURE.md": "46ffc5baa76654ed590515422c399be347ac0b7f74536216936c4626e485f5cd", "docs/INTEGRATIONS.md": "09b1c5bf44f17e79446c99ea9b41cec76350e726469c1dca997405654672db62", "docs/PUBLISHING.md": "70991445e9ad24ad65ed7153615ca9020aef41eb8d27aea4f3ba43ff8e116d08", - "docs/QUALITY.md": "fa9058c0a68b29e17b019966dfa278d0a47a2bd2328699aa159bf92851f86dfd", + "docs/QUALITY.md": "910fc903a109a5df1c72294dcc5f14ae5dcbd18a68dea8c7f198cabe0fba30e9", + "docs/RELEASE-0.2.md": "c03a41903088590845d0da0707a562b9e6cbf4c5e1ff57d8794763f349093e44", "docs/SECURITY.md": "c896569c368a261b1ba32e7930f5bc8c887b91ccc8c5562c63e7c47867e3ad08", + "docs/UPDATE-0.2.md": "cd71d4bb37bfdbb859fa4ea558a91e0cb61f900f0a4390d87f513abb8b8d815d", "docs/WINDOWS.md": "51d606354cb8dd4d0c714458493844a146e4ebfc549e4df873de04f123150e64", "docs/images/angles.webp": "1f9a0b5b46c9ae7e84ed1f96ea909d8ca6ba3f7daad2d86f424332c0aac1fb4e", + "docs/images/codex-v0.2.webp": "ffb7333e4e6e3d6ddea2ec85f223c9669f5e272ac98f29918ed62516093dec4c", + "docs/images/folder-v0.2.webp": "a0a1fbbd5090c61a8593dc9f3410e9114360fa6e1b98caec488f79272b69be74", "docs/images/graph.webp": "c14057145f1d11e6aac7a67cd8e7b0d64a26121bfccca502cfb500da124c0d82", + "docs/images/graphpaper-header.svg": "80bfe7844f469ef2c3d63dd7ca2321d6c24ebed7f5c56555d797227e81a7955e", + "docs/images/humanizer-v0.2.webp": "3654ab85e4dee3a15a59c206a7c1f7a14b9f9793479e4938af0f32a9f7937b5e", "docs/images/light.webp": "caca0fb694a07bb66ebd68567c64718f1437b8b36d3b8327135a491302a2e2a9", + "docs/images/voice-v0.2.webp": "e7e97b982994d026df590ab0769137ea76f016f67b7fed35f41470e90354ec2b", "docs/images/welcome.webp": "6dcdefa7d665b0f2c9363df0cf85c011f9f4cc150f8f261a950d15b265808663", "docs/images/writing.webp": "e96666a0ceaddb21bf3b33a3dcd0ec84d1eaad3b410ca29a859b4a6cf313aded", "docs/validation/browser.json": "5692769d1e74ce27b9ddd97c858573178b7a15bcc47fc6358cf2bac39774668d", "docs/validation/loopback.json": "6edcf86c685dcf15c926002d6128fd40e4ad5b37f6449973b8f8465d30fb68f9", "docs/validation/pytest.xml": "58085cc6c3e680e826e0a5c0bc782a53cdbd1a7a5e30ed9997531ba9e038fc0d", + "docs/validation/v0.2/browser.json": "5692769d1e74ce27b9ddd97c858573178b7a15bcc47fc6358cf2bac39774668d", + "docs/validation/v0.2/codex-runtime.json": "d103f97769745d4d7a552887cea47380706fa9e4d75dcab1d0424064475d72da", + "docs/validation/v0.2/loopback.json": "caab542951b4eb83f86f959f36fbbdad604f507d07ba9754cd06cd9626059f14", + "docs/validation/v0.2/package.json": "19cc3b40205f73760bfb8398cdd7153230f08a01b8aa7cb3214543ca2eb293a1", + "docs/validation/v0.2/studio.json": "b26277877389bde6c83956862415f54fb6aa5015d2b266257274bc36d892a5f4", + "docs/validation/v0.2/windows.xml": "57ac7d539cddd28a31d6452cc965430b4a6b00d7fae52458a2847831937c953c", "examples/README.md": "0b7b83630202501a3ba6ad070708419aa23e32c8060ef9447491c6434043bbf6", "examples/fiction.json": "e3f90c0eed71102640c8a65ae86510e0258e9c90c3cc18730ed05329f632e88d", "examples/nonfiction.json": "af9196cf15dbe1d431f02c7d8ec11a720f25e1c3e30a756c1f9711b4c65a08cc", - "graphpaper/__init__.py": "473909bfea3184f6fac9e8f74e55acd9dabdbfe96a7a785f4b797ce49869d081", + "graphpaper/__init__.py": "66eb5ca3f645281ad322a9c3d1253269332c0caf26174e7ba59fc27d016695ae", + "graphpaper/author_web.py": "16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff", + "graphpaper/codex.py": "87a1f409dd06e755e0f4aa5cab66f3cd45f26f58658df96adf961d86d10ab98c", "graphpaper/demo.py": "5fcee68b786da702cd4365dca17e3f5df415fa26c7c6928568d64d8b7d693006", "graphpaper/desktop.py": "d176c01f271430e0c4215384ca886f6053823a835414abb9a34fbda83a77198f", "graphpaper/export.py": "f964f4e64364a9392dcb8a3e64b91d1e2e071cd9d593ca052c066681c0c37801", + "graphpaper/folders.py": "37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8", "graphpaper/graph.py": "36310ecd1e89af94f799c4fbc4611d90fea640edcbb07f06f92b832f90300770", - "graphpaper/graphify_adapter.py": "12915cc22e0121dcb8e0b6ea3a602e2067066afd14268f4c1e324cf0cfc2ec32", + "graphpaper/graphify_adapter.py": "3db9a67427664b2ad4b3d2bc31089f162a4d1868f41a0d07e51e7e1ff02dc95f", "graphpaper/ingest.py": "6cb0d9b0d1412e08f627c045f6e70db44882bdf3fd18ff03ea8922a4af83cce8", - "graphpaper/models.py": "420dfa0d871d3c16a4d22bb224c69062a873b16b9bec0d24be8f4b65c58684ae", - "graphpaper/pipeline.py": "7afe84d929160140f65a30d7c0cef4d5020d163e8246008274a301b3475b0c94", - "graphpaper/providers.py": "0e16ba31daf6ab3c35c140fc5f8672acb6d1acc1af5565185ddf4de590580386", + "graphpaper/models.py": "af24623c051d99a108b11cbbbc0f0d25896d20667d462426418614c3c7fdb799", + "graphpaper/pipeline.py": "c4475297377f93cd7712f5c6110a5f1166cc0f228dd64a98eaf25e19ed3271d7", + "graphpaper/polish.py": "8890acded2fee782f88c98177250b58625cd93a045895b5c2f5d286056c46ae7", + "graphpaper/providers.py": "9f74857dcb77eb2d916b0f55c7d5bc24a080c15850b3e4378c225d7bd2cd076f", "graphpaper/secrets.py": "6b7f7e11db619317fc7fd8a27d86d253fe45aa61d607154258a613658044b143", - "graphpaper/server.py": "3bf0fb8f9ce42625f1b35348c28b7d87dbcba57ef65757e01fb30effb6c2a6ea", + "graphpaper/server.py": "70143ff271fe7eb0f08457179c9b175a9675a9d8fbe9ab6b55afd15c94ca6c24", "graphpaper/storage.py": "38b0df820a0c4dae0199bf38656d529e726a4475fe7a47b66e41903e96d14eae", + "graphpaper/studio_routes.py": "bc77494829dfff3961a1076f7a152a5f9119c34d2038abde25105a78b67c8ff9", "graphpaper/support.py": "2f4a3f5f7f6a725b15719a775155bdc73c32ce0df022d43b4fb53dbf7e862d8e", - "pyproject.toml": "99455e6b6201e16373750c1a1dc10028c8daf7de1b414ddaaae1025aaa96485c", + "graphpaper/voice.py": "2527ef556be695942e481bf3df099beb05937c7a059d0e1ece94d7ca32d0e51e", + "pyproject.toml": "4e370871614f8696cd51bea95efcd01ccdd92e601ed344afcd86f07c4782da6e", "requirements-desktop.txt": "182bf080861b4cfda8adcb7ce9debff7181b7619ab8452445e143ec8ebf7044e", "requirements-dev.txt": "180b600f70aadb56ce7665a5697a5b788cbc10351189b3442927096a6632b78e", "requirements.txt": "9bdbe67c6139451e1bf2a81169beb52484343d785fdda8196c91ecd23884828b", + "scripts/build_release.ps1": "00d358ddbafbd02f4e4ede9cbfcee0ffdc250fac09789f5f168220ee28d61f6f", "scripts/build_windows.ps1": "44e0008ff8221097ead7355aa184a60e824d2fcbf0c77e54c74ddfc8f3a54858", + "scripts/bundle_codex.py": "651d08e26949dbda47bca14dbc368d97b82e11a28759792cb6096adcea174efa", + "scripts/check_codex.py": "2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81", "scripts/check_package.py": "ef863315ea3f21a8f94b412276849df73d1b4fb0aaa30d7890a62a426a6cc4b7", "scripts/desktop_entry.py": "97db3eedabd7fa1397386419d627479c31c889d7f593f81f971b443ee27ea514", "scripts/prepare_assets.py": "a77238973118112411cd0ac4e2e4b6b52c6193c7e52de9680362501cc77d4136", "scripts/publish_gui.py": "46bf7dff734e16b31c322e45bbf80abcfa29d7fbbc93f4c8162a119ff9c79ef4", "scripts/ui_smoke.py": "367cd36f8d03a180cc377678cf58db9d986c884928b4dc62c2e37d96898ccfcd", + "scripts/ui_studio_smoke.py": "c1cd0e8b5c884a63004311724c5a41617d204a2bdbeb24f672c5f481d86ba85a", "tests/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "tests/conftest.py": "ec52ebc98e568bae0f9ae6223d5f48640f0698d7605987bdafded5c814bf5381", "tests/test_api.py": "05e5e721805fdd9511fd7d7296984cb13ccb5a5d919f512d84822dbb99591908", + "tests/test_codex_runtime.py": "35688888c892c30bf1e745ab94f09dcf7b8a5cf99c29019b5e15bb172cf3fb6a", "tests/test_core.py": "167baaef162cfb6212e70fd6a1354479ab31352b4ed1000281a8773260d5a6d5", "tests/test_desktop_and_publish.py": "1230c3de1a3beecfae95a70dc44961ceabcfa99910fe204cdea1cce3dfe6128c", "tests/test_pipeline.py": "28373af3ea4739c252de747710923f6945f06a3397b9e59344263c117f48487a", "tests/test_providers.py": "83737b8773b8a11936675fba26388848cf6d95f40ef4b6db25baf40baa9d79cb", "tests/test_safety.py": "41a739403206f67fa9b286f56eae6e07c668ec06f2f70f8a45a184bf8f74da6a", + "tests/test_studio_update.py": "d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653", "tests/test_windowless.py": "421620004c87cf24dec771966602b0666e75ecca67fec05071cf40e4b6887f44", - "ui/app.js": "837aa4111bd7fc4003768e96551a0c630a7d9c1cf6e40d484bdc825d0d93bddb", + "ui/app.js": "c55c58e134eedf7f0ae5f7c0ec93e6e5829237e5ea80aa2d41d41fd5f435604e", "ui/graphpaper.ico": "f94c4061c53e8e1a1f2be96ef1a8c18b20c19a984bc35f6337f1037c1025a493", "ui/icon.svg": "9f97b80a90020b682f96756d1245dfa3cc9f19ed989b0751f802220a3934be0f", - "ui/index.html": "fe9efcf02b30dd769d19504dc198501f2507be8a0b0074bd0aff79bdbec889c0", + "ui/index.html": "b2a07fc114c06163a2453d2ae1a38254bb44c13fc65128b12becb2b794c5a92d", + "ui/studio.css": "c143a880c50ed59b9d315afaacd20377d6e47c54a701ea105f5c0757cde43a24", + "ui/studio.js": "35ec4ed3ac5e691855680103b96fcc184eca9c61089753ebc7e0cfcf79c8ee87", "ui/styles.css": "99fb8fb724a74d2d8cc31ea1e7e6b4422f87dfff7015d26313129e999d8631c2" } } diff --git a/pyproject.toml b/pyproject.toml index 6f45bcc..6f1f205 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphpaper-studio" -version = "0.1.0" +version = "0.2.0" description = "A local-first graph-guided writing studio for nonfiction and fiction" readme = "README.md" requires-python = ">=3.11" @@ -24,7 +24,7 @@ pythonpath = ["."] addopts = "-ra" [tool.setuptools.data-files] -"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/icon.svg", "ui/graphpaper.ico"] +"share/graphpaper/ui" = ["ui/index.html", "ui/styles.css", "ui/app.js", "ui/icon.svg", "ui/graphpaper.ico", "ui/studio.js", "ui/studio.css"] [project.optional-dependencies] desktop = ["pywebview>=6.0,<7"] diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1 new file mode 100644 index 0000000..81a4c68 --- /dev/null +++ b/scripts/build_release.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' +Set-Location (Join-Path $PSScriptRoot '..') +python -m pip install -q -r requirements-desktop.txt 'pyinstaller>=6.16,<7' +if ($LASTEXITCODE -ne 0) { throw 'Dependency installation failed.' } +python scripts/bundle_codex.py +if ($LASTEXITCODE -ne 0) { throw 'Official Codex runtime verification failed.' } +python scripts/check_codex.py +if ($LASTEXITCODE -ne 0) { throw 'Codex runtime protocol check failed.' } +python -m PyInstaller --noconfirm --clean --windowed --onedir --name GraphPaper --paths . --icon ui/graphpaper.ico --add-data 'ui;ui' --add-data 'vendor;vendor' --collect-all webview scripts/desktop_entry.py +if ($LASTEXITCODE -ne 0) { throw 'Windows desktop build failed.' } +Copy-Item README.md, LICENSE -Destination dist/GraphPaper/ +Copy-Item docs/UPDATE-0.2.md -Destination dist/GraphPaper/WHATS-NEW.md +python scripts/check_package.py dist/GraphPaper +if ($LASTEXITCODE -ne 0) { throw 'Compiled application self-test failed.' } +$version = python -c "from graphpaper import __version__; print(__version__)" +$zip = "dist/GraphPaper-v$version-Windows-x64.zip" +Compress-Archive -Path dist/GraphPaper -DestinationPath $zip -Force +$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower() +"$hash GraphPaper-v$version-Windows-x64.zip" | Set-Content -Encoding ascii "dist/SHA256SUMS.txt" +Write-Host "Built $zip" diff --git a/scripts/bundle_codex.py b/scripts/bundle_codex.py new file mode 100644 index 0000000..862b9fd --- /dev/null +++ b/scripts/bundle_codex.py @@ -0,0 +1,77 @@ +"""Fetch the official Windows Codex runtime, verify GitHub's asset digest, keep provenance.""" +from __future__ import annotations +import hashlib +import json +import os +from pathlib import Path +import shutil +import tarfile +import tempfile +import urllib.request +import urllib.error +from urllib.parse import urlsplit +import zipfile + +ROOT=Path(__file__).resolve().parents[1] + + +def get(url): + headers={'User-Agent':'GraphPaper-release-builder','Accept':'application/vnd.github+json'} + if urlsplit(url).hostname=='api.github.com' and os.getenv('GITHUB_TOKEN'): + headers['Authorization']='Bearer '+os.environ['GITHUB_TOKEN'] + return urllib.request.urlopen(urllib.request.Request(url,headers=headers),timeout=90) + + +def main(): + version=os.getenv('CODEX_RELEASE_TAG','rust-v0.160.1') + api='https://api.github.com/repos/openai/codex/releases/'+('latest' if version=='latest' else 'tags/'+version) + with get(api) as r:release=json.load(r) + if release.get('prerelease') or release.get('draft'): + raise ValueError('Only an official stable Codex release may be bundled.') + assets=release['assets'];tag=release['tag_name'] + destination=ROOT/'vendor';destination.mkdir(exist_ok=True) + provenance={'repository':'openai/codex','release':tag,'release_url':release['html_url'],'assets':[]} + for stem,required in [('codex',True),('codex-command-runner',False),('codex-windows-sandbox-setup',False)]: + matches=[a for a in assets if a['name'].startswith(stem+'-x86_64-pc-windows-msvc') and a['name'].endswith(('.tar.gz','.zip'))] + if not matches: + if required:raise ValueError('Official Windows x64 Codex archive not found.') + continue + asset=sorted(matches,key=lambda a:a['name'])[0] + expected=asset.get('digest','') + if not expected.startswith('sha256:'): + raise ValueError('GitHub did not supply a SHA-256 digest for '+asset['name']) + url=asset['browser_download_url'] + if not url.startswith('https://github.com/openai/codex/releases/download/'): + raise ValueError('Unexpected release download URL.') + with tempfile.TemporaryDirectory() as tmp: + archive=Path(tmp)/asset['name'];hasher=hashlib.sha256();size=0 + with get(url) as source,archive.open('wb') as out: + while chunk:=source.read(1024*1024): + size+=len(chunk) + if size>300*1024*1024:raise ValueError('Runtime archive exceeds 300 MB.') + hasher.update(chunk);out.write(chunk) + if hasher.hexdigest()!=expected.split(':',1)[1]:raise ValueError('Codex runtime checksum mismatch.') + if archive.name.endswith('.zip'): + with zipfile.ZipFile(archive) as z: + names=[n for n in z.namelist() if n.lower().endswith('.exe') and Path(n).name.startswith(stem)] + if not names:raise ValueError('Missing runtime executable.') + data=z.read(names[0]) + else: + with tarfile.open(archive,'r:gz') as t: + members=[m for m in t.getmembers() if m.isfile() and m.name.lower().endswith('.exe') and Path(m.name).name.startswith(stem)] + if not members:raise ValueError('Missing runtime executable.') + with t.extractfile(members[0]) as source:data=source.read(400*1024*1024+1) + if len(data)>400*1024*1024:raise ValueError('Expanded runtime exceeds limit.') + (destination/(stem+'.exe')).write_bytes(data) + provenance['assets'].append({'name':asset['name'],'download_url':url,'archive_sha256':expected.split(':',1)[1], + 'executable':stem+'.exe','executable_sha256':hashlib.sha256(data).hexdigest()}) + for filename in ['LICENSE','NOTICE']: + try: + with get(f'https://raw.githubusercontent.com/openai/codex/{tag}/{filename}') as r:text=r.read(100_000) + (destination/(filename+'.Codex.txt')).write_bytes(text) + except urllib.error.HTTPError as e: + if filename=='LICENSE' or e.code!=404:raise + (destination/'codex-release.json').write_text(json.dumps(provenance,indent=2)+'\n',encoding='utf-8') + print(json.dumps(provenance,indent=2)) + +if __name__=='__main__':main() diff --git a/scripts/check_codex.py b/scripts/check_codex.py new file mode 100644 index 0000000..85abec3 --- /dev/null +++ b/scripts/check_codex.py @@ -0,0 +1,24 @@ +"""Live-runtime, signed-out protocol check. Does not authenticate or make a model call.""" +from pathlib import Path +import json +import subprocess +import sys +import tempfile +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from graphpaper.codex import Codex + +runtime=ROOT/'vendor/codex.exe' +version=subprocess.check_output([str(runtime),'--version'],text=True,timeout=20).strip() +help_text=subprocess.check_output([str(runtime),'exec','--help'],text=True,timeout=20) +for flag in ['--ephemeral','--json','--sandbox','--output-last-message']: + assert flag in help_text, 'Required runtime option missing: '+flag +with tempfile.TemporaryDirectory() as tmp: + c=Codex(Path(tmp),str(runtime)) + try: + status=c.status() + assert status['installed'] and not status['signed_in'],status + finally:c.close() +report={'ok':True,'version':version,'unauthenticated_app_server_handshake':True,'live_model_call':False,'browser_oauth_completed':False} +path=ROOT/'test-results/codex-runtime.json';path.parent.mkdir(exist_ok=True);path.write_text(json.dumps(report,indent=2),encoding='utf-8') +print(json.dumps(report,indent=2)) diff --git a/scripts/ui_studio_smoke.py b/scripts/ui_studio_smoke.py new file mode 100644 index 0000000..8878833 --- /dev/null +++ b/scripts/ui_studio_smoke.py @@ -0,0 +1,151 @@ +"""GraphPaper 0.2 UI checks. HTTP in CI; explicit in-process bridge on restricted hosts.""" +from __future__ import annotations +import argparse +import base64 +import json +from pathlib import Path +import socket +import sys +import tempfile +import threading +import time +import traceback +ROOT=Path(__file__).resolve().parents[1] +sys.path.insert(0,str(ROOT)) +from fastapi.testclient import TestClient +from graphpaper.server import create_app +from graphpaper.models import Settings +from tests.conftest import ScriptedClients +from playwright.sync_api import sync_playwright, expect + + +class StudioClients(ScriptedClients): + def complete(self,system,user,**kwargs): + try:raw=json.loads(user) + except ValueError:return super().complete(system,user,**kwargs) + task=raw.get('task','') + if task.startswith('Build an editable author voice'): + self._record('voice') + return {'name':'Precise and personal','instructions':'Use direct verbs and varied sentence length. Keep humor dry; preserve uncertainty when it matters.','observations':['The samples favor concrete verbs.']} + if task.startswith(('HUMANIZE','DESLOP')): + self._record('polish') + return raw['draft'].replace('It is important to note that ','') + if task.startswith('Compare original and proposed prose'): + self._record('polish-review') + return {'meaning_preserved':True,'summary':'Removed filler while preserving the claim.','warnings':[],'improvements':['Direct opening.']} + return super().complete(system,user,**kwargs) + + +def wait(page,action): + deadline=time.monotonic()+20 + while time.monotonic() state.job && ({state:state.job.state,action:state.job.action,error:state.job.error})') + if status and status['action']==action: + if status['state']=='completed':return + if status['state']=='failed':raise AssertionError(status) + page.wait_for_timeout(100) + raise AssertionError('Job did not complete: '+action) + + +def main(): + parser=argparse.ArgumentParser();parser.add_argument('--offline',action='store_true');parser.add_argument('--chromium');parser.add_argument('--out',default='test-results/studio');args=parser.parse_args() + out=ROOT/args.out;out.mkdir(parents=True,exist_ok=True) + report={'mode':'in-process HTTP bridge' if args.offline else 'real HTTP','live_models':False,'checks':[],'page_errors':[],'ok':False} + with tempfile.TemporaryDirectory() as tmp: + app=create_app(tmp);app.state.runner.clients_factory=StudioClients + app.state.store.set_settings(Settings(model='test/model',allow_cloud=True,remember_keys=False).model_dump()) + client=TestClient(app);client.get('/');client.headers['X-GraphPaper']='1' + server=None + if not args.offline: + import uvicorn + sock=socket.socket();sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + server=uvicorn.Server(uvicorn.Config(app,log_level='error')) + thread=threading.Thread(target=lambda:server.run(sockets=[sock]),daemon=True);thread.start() + while not server.started:time.sleep(.05) + def bridge(req): + kwargs={'headers':req.get('headers',{})} + if req.get('form') is not None: + files=[];data={} + for part in req['form']: + if 'bytes' in part:files.append((part['name'],(part['filename'],base64.b64decode(part['bytes']),part['type']))) + else:data[part['name']]=part['value'] + kwargs.update(files=files,data=data) + else:kwargs['content']=req.get('body') + r=client.request(req['method'],req['url'],**kwargs) + return {'status':r.status_code,'headers':dict(r.headers),'body':base64.b64encode(r.content).decode()} + with sync_playwright() as pw: + browser=pw.chromium.launch(headless=True,**({'executable_path':args.chromium} if args.chromium else {})) + page=browser.new_page(viewport={'width':1440,'height':1000});page.set_default_timeout(12000) + page.on('pageerror',lambda e:report['page_errors'].append(str(e))) + try: + if args.offline: + html=(ROOT/'ui/index.html').read_text() + for name in ['styles.css','studio.css']:html=html.replace(f'','') + for name in ['app.js','studio.js']:html=html.replace(f'','') + ico='data:image/svg+xml;base64,'+base64.b64encode((ROOT/'ui/icon.svg').read_bytes()).decode() + html=html.replace('/static/icon.svg',ico) + page.expose_function('__studioRequest',bridge);page.set_content(html) + page.evaluate('''() => {window.fetch=async(url,opt={})=>{let form=null,body=opt.body;if(body instanceof FormData){form=[];for(const [name,v] of body.entries()){if(v instanceof File){const bytes=new Uint8Array(await v.arrayBuffer());let binary='';for(const b of bytes)binary+=String.fromCharCode(b);form.push({name,filename:v.name,type:v.type,bytes:btoa(binary)});}else form.push({name,value:v});}body=undefined;}const r=await window.__studioRequest({url:String(url),method:opt.method||'GET',headers:opt.headers||{},body,form});return new Response(Uint8Array.from(atob(r.body),c=>c.charCodeAt(0)),{status:r.status,headers:r.headers});};}''') + for name in ['app.js','studio.js']:page.add_script_tag(content=(ROOT/'ui'/name).read_text().replace('/static/icon.svg',ico)) + else:page.goto(f'http://127.0.0.1:{port}/') + page.wait_for_selector('.welcome') + page.get_by_role('button',name='New project',exact=True).click();page.locator('#project-title').fill('A voice of your own');page.get_by_role('button',name='Create project',exact=True).click();page.wait_for_selector('.studio-strip') + pid=page.evaluate('state.p.id');base='/api/projects/'+pid + page.get_by_role('button',name='Your writing voice',exact=True).click() + with page.expect_file_chooser() as chosen:page.get_by_role('button',name='Upload samples',exact=True).click() + sample=('I would rather explain a thing than pretend it is mysterious. Some sentences are short. Others need room for qualifications, because precision matters more than a slogan. '*8).encode() + chosen.value.set_files({'name':'my-essay.md','mimeType':'text/markdown','buffer':sample}) + page.wait_for_selector('.studio-sample');assert client.get(base).json()['sources'][0]['role']=='voice' + report['checks'].append('Voice sample upload through the actual file input') + page.get_by_role('button',name='Learn my writing style',exact=True).click();wait(page,'voice') + page.get_by_role('button',name='Your writing voice',exact=True).click();assert 'direct verbs' in page.locator('#voice-instructions').input_value() + page.locator('#voice-instructions').fill('Use precise verbs. Vary rhythm. Leave deliberate wit alone.') + page.get_by_role('button',name='Save voice settings',exact=True).click() + assert client.get(base).json()['voice_profile']['instructions'].startswith('Use precise') + report['checks'].append('Learn, inspect and edit a persistent author voice profile') + page.get_by_role('button',name='Your writing voice',exact=True).click();page.screenshot(path=str(out/'voice.png'),full_page=True);page.get_by_role('button',name='Close dialog',exact=True).click() + page.get_by_role('button',name='Project folder',exact=True).click();info=client.get(base+'/workspace').json() + (Path(info['inbox'])/'Evidence'/'research.md').write_text('A source with a specific factual claim.',encoding='utf-8') + page.get_by_role('button',name='Import files now',exact=True).click() + page.wait_for_timeout(500);assert len(client.get(base).json()['sources'])==2 + page.screenshot(path=str(out/'folder.png'),full_page=True);page.get_by_role('button',name='Close dialog',exact=True).click() + report['checks'].append('Project folder import and automatic source ownership') + (Path(info['inbox'])/'Voice'/'second-voice.txt').write_text('Another representative piece of my writing. '*30) + page.locator('.nav-link[data-tab="sources"]').click() + deadline=time.monotonic()+13 + while time.monotonic()0 + page.get_by_role('button',name='Close dialog',exact=True).click();report['checks'].append('Local explainable prose inspection') + page.get_by_role('button',name='Humanize',exact=True).click();page.get_by_role('button',name='Create proposed edit',exact=True).click();wait(page,'humanize') + assert client.get(base).json()['draft'].startswith('It is important') + page.get_by_role('button',name='Compare proposed edit',exact=True).click();page.wait_for_selector('.studio-compare');page.screenshot(path=str(out/'humanizer.png'),full_page=True) + page.get_by_role('button',name='Accept this edit',exact=True).click();expect(page.locator('.modal')).to_have_count(0);expect(page.locator('#manuscript')).to_have_value('the measured value was 12.5% [S2].') + report['checks'].append('Humanizer proposal, side-by-side comparison and explicit acceptance') + page.locator('#manuscript').fill('It is important to note that another point remains unchanged.');page.wait_for_timeout(800) + page.get_by_role('button',name='Deslop',exact=True).click();page.get_by_role('button',name='Create proposed edit',exact=True).click();wait(page,'deslop') + page.get_by_role('button',name='Compare proposed edit',exact=True).click();page.get_by_role('button',name='Discard proposal',exact=True).click();expect(page.locator('.modal')).to_have_count(0) + assert client.get(base).json()['draft'].startswith('It is important') + report['checks'].append('Separate deslopping pass and non-destructive rejection') + page.locator('[data-action="settings"]').click();page.locator('#s-provider').select_option('codex') + assert page.locator('#s-model').input_value()=='' + page.screenshot(path=str(out/'codex.png'),full_page=True);page.get_by_role('button',name='Save connections',exact=True).click() + assert client.get('/api/settings').json()['provider']=='codex' + report['checks'].append('Codex subscription provider selectable without an API key') + page.set_viewport_size({'width':1000,'height':800});page.locator('.nav-link[data-tab="sources"]').click() + assert page.evaluate('document.documentElement.scrollWidth')<=1000 + report['checks'].append('New controls fit a smaller desktop window') + assert not report['page_errors'],report['page_errors'] + report['ok']=True + except Exception: + report['error']=traceback.format_exc();page.screenshot(path=str(out/'failure.png'),full_page=True);raise + finally: + (out/'report.json').write_text(json.dumps(report,indent=2));browser.close() + app.state.runner.pool.shutdown(wait=True,cancel_futures=True) + if server:server.should_exit=True;thread.join(timeout=5);sock.close() + client.close() + print(json.dumps(report,indent=2)) + +if __name__=='__main__':main() diff --git a/tests/test_codex_runtime.py b/tests/test_codex_runtime.py new file mode 100644 index 0000000..064d2d0 --- /dev/null +++ b/tests/test_codex_runtime.py @@ -0,0 +1,76 @@ +"""Exercise the actual subprocess/JSON-RPC bridge with a local Codex test double.""" +import json +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock +import pytest +from graphpaper.codex import Codex +from graphpaper.pipeline import Job +from graphpaper.providers import ProviderError + +FAKE = r''' +import json, os, pathlib, sys +home=pathlib.Path(os.environ['CODEX_HOME']) +if 'app-server' in sys.argv: + for line in sys.stdin: + d=json.loads(line) + if 'id' not in d: continue + method=d.get('method') + if method=='account/read': + result={'account':{'type':'chatgpt','email':'author@example.test','planType':'pro'} if (home/'signed-in.test').exists() else None} + elif method=='account/login/start': + (home/'signed-in.test').write_text('test session') + result={'authUrl':'https://auth.openai.com/authorize?state=test','loginId':'test-login'} + elif method=='account/logout': + (home/'signed-in.test').unlink(missing_ok=True); result={} + elif method=='model/list': + result={'data':[{'id':'test-model','model':'test-model','displayName':'Test model'}]} + else: result={} + print(json.dumps({'id':d['id'],'result':result}),flush=True) +else: + prompt=sys.stdin.read() + (home/'exec-args.test').write_text(json.dumps(sys.argv)) + (home/'input.test').write_text(prompt) + output=pathlib.Path(sys.argv[sys.argv.index('-o')+1]) + output.write_text('A complete piece of test prose.',encoding='utf-8') + print(json.dumps({'type':'turn.completed','usage':{'input_tokens':42,'output_tokens':8}})) +''' + + +@pytest.fixture +def runtime(tmp_path,monkeypatch): + script=tmp_path/'fake_codex.py';script.write_text(FAKE,encoding='utf-8') + real=subprocess.Popen + def launch(args,*a,**kw): + if args[0]==str(script.resolve()):args=[sys.executable,str(script),*args[1:]] + return real(args,*a,**kw) + monkeypatch.setattr(subprocess,'Popen',launch) + monkeypatch.setenv('OPENAI_API_KEY','do-not-inherit-this-key') + monkeypatch.setattr('webbrowser.open',Mock(return_value=True)) + c=Codex(tmp_path,str(script)) + yield c + c.close() + + +def test_browser_login_exec_usage_and_logout(runtime): + assert not runtime.status()['signed_in'] + assert runtime.login()['started'] + assert runtime.status()['signed_in'] + assert runtime.models()[0]['id']=='test-model' + job=Job('p','draft') + assert runtime.complete('Write prose.','About the sea.','test-model',job)=='A complete piece of test prose.' + assert job.usage['calls']==1 and job.usage['input_tokens']==42 and job.usage['output_tokens']==8 + assert job.usage['reported_cost']==0 and job.usage['unpriced_calls']==1 + args=json.loads((runtime.home/'exec-args.test').read_text()) + assert '--ephemeral' in args and 'read-only' in args and 'features.shell_tool=false' in args + assert 'OPENAI_API_KEY' not in runtime.env + assert runtime.home.name=='codex-account' + runtime.logout() + assert not runtime.status()['signed_in'] + + +def test_codex_generation_refuses_unsigned_account(runtime): + with pytest.raises(ProviderError,match='Sign in'):runtime.complete('system','data','') + assert not (runtime.home/'exec-args.test').exists() diff --git a/tests/test_studio_update.py b/tests/test_studio_update.py new file mode 100644 index 0000000..6a6d8b6 --- /dev/null +++ b/tests/test_studio_update.py @@ -0,0 +1,218 @@ +from __future__ import annotations +import hashlib +import json +from pathlib import Path +import re +import sys +from unittest.mock import Mock +import pytest +from graphpaper.models import Project, Source, Settings, VoiceProfile, PolishCandidate +from graphpaper.voice import metrics, fingerprint, sample_pack, voice_context, learn_voice +from graphpaper.polish import lint, mask, unmask, polish_draft +from graphpaper.author_web import extract_links +from graphpaper.pipeline import Job +from graphpaper.providers import ProviderError +from graphpaper.folders import safe_name +from .conftest import ScriptedClients + + +def sample_project(): + p=Project() + p.sources=[Source(id='S1',title='My voice',role='voice',text='I write about things I can explain. Some sentences are short. Others take the time they need, because the point deserves a little more room. '*6)] + return p + + +def test_old_project_migrates_with_new_defaults(): + p=Project.model_validate({'title':'Existing project'}) + assert p.auto_import and p.voice_profile.strength==75 and p.polish is None + + +def test_metrics_and_balanced_voice_samples(): + p=sample_project();p.sources[0].text='START '+('middle words '*5000)+' END' + pack=sample_pack(p,300) + assert pack[0]['excerpts'][0].startswith('START') + assert pack[0]['excerpts'][-1].endswith(' END') + assert sum(map(len,pack[0]['excerpts']))<=300 + assert metrics('One. Two words. Three short words?')['mean_sentence_words']==2 + + +def test_voice_excludes_evidence_and_disabled_samples(): + p=sample_project() + p.sources += [Source(id='S2',title='Facts',text='Not my voice.'),Source(id='S3',title='Disabled',text='Also not selected.',role='voice',enabled=False)] + assert len(sample_pack(p))==1 + p.voice_profile.enabled=False + assert voice_context(p)=={'enabled':False} + + +def test_voice_profile_staleness_and_off_switch(): + p=sample_project();p.voice_profile.instructions='Learned voice';p.voice_profile.sample_hash=fingerprint(p) + assert voice_context(p)['instructions']=='Learned voice' + p.sources[0].text+=' Changed.' + assert voice_context(p)['profile_needs_refresh'] and not voice_context(p)['instructions'] + p.voice_profile.strength=0 + assert not voice_context(p)['enabled'] + + +def test_learn_voice_persists_real_profile(): + c=Mock();c.settings=Settings();c.complete.return_value={'name':'Direct and dry','instructions':'Vary sentence length. Use exact verbs. Keep humor dry and occasional.','observations':['Sample-limited observations.']} + p=sample_project();learn_voice(p,c,Job(p.id,'voice')) + assert p.voice_profile.name=='Direct and dry' and p.voice_profile.sample_ids==['S1'] + assert p.voice_profile.sample_hash==fingerprint(p) + assert 'factual beliefs or biography' in c.complete.call_args.args[1] + + +def test_learning_requires_enough_words(): + with pytest.raises(ValueError):learn_voice(Project(),Mock(),Job('p','voice')) + p=sample_project();p.sources[0].text='Tiny sample.' + with pytest.raises(ValueError,match='80'):learn_voice(p,Mock(),Job(p.id,'voice')) + + +@pytest.mark.parametrize('text',['A 12.5% change [S1].', '“Keep these exact words.”', 'Use `foo(x)` at https://example.org/test.', '```py\nx=2\n```', '> A quoted block.\n> Second line.', '[Readable label](https://example.org)']) +def test_protected_spans_roundtrip(text): + encoded,spans=mask(text) + assert unmask(encoded,spans)==text + if spans: + with pytest.raises(ValueError):unmask(encoded+next(iter(spans)),spans) + with pytest.raises(ValueError):unmask(encoded.replace(next(iter(spans)),''),spans) + + +def test_lint_does_not_treat_quotation_as_authors_filler(): + report=lint('It is important to note that this works.\n\n“Let that sink in.”\n\n```\nLet that sink in.\n```') + assert any(f['rule']=='filler' for f in report['findings']) + assert not any(f['rule']=='empty-closer' for f in report['findings']) + assert 'not AI-authorship detection' in report['note'] + + +class EditClient: + def complete(self,system,user,**kw): + raw=json.loads(user) + if kw.get('json_mode'): + return {'meaning_preserved':True,'summary':'Filler removed.','warnings':[],'improvements':['More direct opening.']} + return raw['draft'].replace('It is important to note that ','') + + +@pytest.mark.parametrize('mode',['humanize','deslop','both']) +def test_polish_is_reversible_proposal_with_protected_facts(mode): + p=sample_project();p.draft='It is important to note that the count was 12.5% in “the test” [S1].' + old=p.draft;polish_draft(p,EditClient(),Job(p.id,mode),mode) + assert p.draft==old + assert p.polish.draft=='the count was 12.5% in “the test” [S1].' + assert p.polish.original_hash==hashlib.sha256(old.encode()).hexdigest() + assert p.polish.before['count']>p.polish.after['count'] + + +def test_polish_rejects_invented_number(): + class Invent(EditClient): + def complete(self,system,user,**kw): + answer=super().complete(system,user,**kw) + return answer+' 9999' if isinstance(answer,str) else answer + p=sample_project();p.draft='It is important to note that this is the complete original statement.' + with pytest.raises(ProviderError,match='protected'):polish_draft(p,Invent(),Job(p.id,'humanize'),'humanize') + assert p.polish is None + + +def test_accept_reject_and_stale_edit(client,app): + p=Project(draft='Original text.') + p.polish=PolishCandidate(mode='deslop',original_hash=hashlib.sha256(p.draft.encode()).hexdigest(),draft='Proposed text.',review={'meaning_preserved':True}) + app.state.store.create(p);base='/api/projects/'+p.id + accepted=client.post(base+'/craft/accept',json={'version':0}) + assert accepted.status_code==200 and accepted.json()['draft']=='Proposed text.' + assert any(v['draft']=='Original text.' for v in app.state.store.revisions(p.id)) + p=app.state.store.get(p.id);p.polish=PolishCandidate(mode='deslop',original_hash='obsolete',draft='Stale.');app.state.store.save(p,p.version) + assert client.post(base+'/craft/accept',json={'version':p.version}).status_code==409 + assert app.state.store.get(p.id).draft=='Proposed text.' + assert client.post(base+'/craft/discard',json={'version':p.version}).json()['polish'] is None + + +def test_fidelity_warning_needs_explicit_author_approval(client,app): + p=Project(draft='Original text.');p.polish=PolishCandidate(mode='humanize',original_hash=hashlib.sha256(p.draft.encode()).hexdigest(),draft='Different.',review={'meaning_preserved':False}) + app.state.store.create(p);url='/api/projects/'+p.id+'/craft/accept' + assert client.post(url,json={'version':0}).status_code==400 + assert client.post(url,json={'version':0,'acknowledge_warnings':True}).status_code==200 + + +def test_project_folders_are_separate_and_automatically_role_mapped(client,app): + p=client.post('/api/projects',json={'title':'One'}).json();q=client.post('/api/projects',json={'title':'Two'}).json() + first=client.get('/api/projects/'+p['id']+'/workspace').json();second=client.get('/api/projects/'+q['id']+'/workspace').json() + assert first['path']!=second['path'] + inbox=Path(first['inbox']);(inbox/'Voice'/'my-essay.md').write_text('My own sample text.',encoding='utf-8') + (inbox/'Evidence'/'paper.txt').write_text('Factual material.',encoding='utf-8') + result=client.post('/api/projects/'+p['id']+'/workspace/scan',json={'force':True}).json() + assert result['changed'] and {s['role'] for s in result['project']['sources']}=={'voice','evidence'} + assert not app.state.store.get(q['id']).sources + assert app.state.store.get(p['id']).usage['calls']==0 + assert not client.post('/api/projects/'+p['id']+'/workspace/scan',json={'force':True}).json()['changed'] + + +def test_inbox_requires_stable_file_then_updates_without_duplication(client): + p=client.post('/api/projects',json={'title':'Inbox'}).json();base='/api/projects/'+p['id'] + info=client.get(base+'/workspace').json();file=Path(info['inbox'])/'new.txt';file.write_text('First content.',encoding='utf-8') + assert not client.post(base+'/workspace/scan',json={}).json()['changed'] + r=client.post(base+'/workspace/scan',json={}).json();assert r['changed'];sid=r['project']['sources'][0]['id'] + file.write_text('Changed content in the same document.',encoding='utf-8') + r=client.post(base+'/workspace/scan',json={'force':True}).json();assert len(r['project']['sources'])==1 + assert r['project']['sources'][0]['id']==sid + file.unlink();r=client.post(base+'/workspace/scan',json={'force':True}).json() + assert len(r['project']['sources'])==1 and r['events'][0]['status']=='retained' + + +def test_upload_retains_original_and_same_text_can_be_voice(client): + p=client.post('/api/projects',json={'title':'Uploads'}).json();base='/api/projects/'+p['id'] + assert client.post(base+'/sources/file',files={'file':('notes.md',b'Same text.','text/markdown')},data={'role':'evidence'}).status_code==200 + r=client.post(base+'/sources/file',files={'file':('notes.md',b'Same text.','text/markdown')},data={'role':'voice'}) + assert r.status_code==200 and len(r.json()['sources'])==2 + folder=Path(client.get(base+'/workspace').json()['path'])/'Originals' + assert len(list(folder.glob('*.md')))==2 + + +def test_inbox_symlink_does_not_import_outside_project(client,tmp_path): + p=client.post('/api/projects',json={'title':'Links'}).json();base='/api/projects/'+p['id'] + info=client.get(base+'/workspace').json();outside=tmp_path/'outside.txt';outside.write_text('Not project material.') + try:(Path(info['inbox'])/'link.txt').symlink_to(outside) + except (OSError,NotImplementedError):pytest.skip('Symlinks unavailable for this Windows account') + result=client.post(base+'/workspace/scan',json={'force':True}).json() + assert not result['project']['sources'] + + +@pytest.mark.parametrize('name',['CON','NUL.txt','../../secret','A:B*?<>|.txt']) +def test_safe_windows_original_names(name): + result=safe_name(name) + assert '/' not in result and ':' not in result and not result.startswith('..') + assert result.split('.')[0].upper() not in {'CON','NUL'} + + +def test_author_link_discovery_stays_on_site_and_excludes_login(): + html='My essaysMy first full essayElsewhere articlePlease sign inA second essayDuplicate article' + links=extract_links(html,'https://writer.example/blog') + assert {x['url'] for x in links}=={'https://writer.example/blog','https://writer.example/first','https://writer.example/second'} + + +def test_codex_setting_and_auth_url_allowlist(): + from graphpaper.codex import safe_login_url + assert Settings(provider='codex').provider=='codex' + assert safe_login_url('https://auth.openai.com/authorize?state=abc') + assert safe_login_url('https://chatgpt.com/oauth') + assert not safe_login_url('https://auth.openai.com.evil.test/') + assert not safe_login_url('http://auth.openai.com/') + assert not safe_login_url('https://evil@auth.openai.com/') + + +def test_codex_provider_uses_bridge_not_api_transport(tmp_path,monkeypatch): + from graphpaper.providers import Clients + from graphpaper.secrets import Vault + bridge=Mock();bridge.complete.return_value='{"ok":true}' + monkeypatch.setattr('graphpaper.codex.get_codex',lambda *a:bridge) + c=Clients(Settings(provider='codex',allow_cloud=True,model=''),Vault(tmp_path)) + c.post=Mock(side_effect=AssertionError('Must not call API')) + assert c.complete('instructions','data',json_mode=True)=={'ok':True} + c.post.assert_not_called() + assert 'JSON' in bridge.complete.call_args.args[0] + + +def test_codex_still_requires_explicit_cloud_consent(tmp_path,monkeypatch): + from graphpaper.providers import Clients + from graphpaper.secrets import Vault + bridge=Mock();monkeypatch.setattr('graphpaper.codex.get_codex',lambda *a:bridge) + c=Clients(Settings(provider='codex',allow_cloud=False),Vault(tmp_path)) + with pytest.raises(ProviderError):c.complete('system','private text') + bridge.complete.assert_not_called() diff --git a/ui/app.js b/ui/app.js index 35ac14b..c99beff 100644 --- a/ui/app.js +++ b/ui/app.js @@ -65,7 +65,7 @@ function progressDone(t){const p=state.p;return t==='sources'?p.sources.length>0 function render(){ const p=state.p,s=state.settings; document.documentElement.dataset.theme=s.theme||'dark'; - $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; + $('#app').innerHTML=`
${''}
${icon('check')}${esc(state.save)}${p?ibtn('Project options','project-options','settings'):''}${btn(p?'Export':'Import project',p?'export':'import-project',p?'download':'upload','small')}
${p?projectHTML():welcomeHTML()}
`; if(p&&state.tab==='graph'&&p.graph.nodes.length)requestAnimationFrame(drawGraph); setupDrops();paintJob(); } @@ -119,8 +119,8 @@ function drawGraph(){ function selectNode(id){state.node=id;state.edge=null;state.path=[];drawGraph();refreshDirection();} function refreshDirection(){const e=$('.direction-panel');if(e)e.outerHTML=directionHTML();} function paintJob(){const slot=$('#job-slot');if(!slot)return;const j=state.job;if(!j){slot.innerHTML='';return;}const msg=j.messages?.at(-1);slot.innerHTML=`
${busy()?'':icon(j.state==='completed'?'check':'alert')}
${esc(typeof msg==='string'?msg:msg?.text||j.action+' · '+j.state)}
${esc(j.state)} · ${fmt(j.usage?.calls)} calls · ${fmt((j.usage?.input_tokens||0)+(j.usage?.output_tokens||0))} tokens ${j.usage?.reported_cost?'· $'+Number(j.usage.reported_cost).toFixed(4)+' reported':''}${j.error?' · '+esc(j.error):''}
${busy()?btn('Cancel','cancel-job','stop','small ghost'):ibtn('Dismiss job result','dismiss-job','x')}
`;} -async function startJob(action,instruction=''){if(!editable())return;await flush();if(!state.settings.allow_cloud&&!['localhost','127.0.0.1','[::1]'].includes(new URL(state.settings.base_url).hostname)){toast('Connect your model and allow sending selected material before running AI actions.');showSettings();return;}if(!state.settings.model){toast('Choose a writing model in Connections first.');showSettings();return;}state.job=await api('/projects/'+state.p.id+'/jobs',{action,instruction});render();poll();} -function poll(){clearTimeout(pollTimer);if(!state.job)return;const jid=state.job.id,pid=state.p.id;pollTimer=setTimeout(async()=>{try{const j=await api('/jobs/'+jid);if(state.job?.id!==jid||state.p?.id!==pid)return;state.job=j;paintJob();if(['queued','running'].includes(j.state)){poll();return;}const p=await api('/projects/'+state.p.id);state.p=p;syncSummary();if(j.state==='completed'){state.tab={graph:'graph',angles:'angles',outline:'outline',draft:'write',review:'write',revise:'write'}[j.action]||state.tab;state.layout={};toast('Finished. Your project is saved locally.');}else toast(j.error||'Job stopped. Saved versions are still available.',j.state==='failed');render();}catch(e){toast(e.message,true);poll();}},1000);} +async function startJob(action,instruction=''){if(!editable())return;await flush();if(!state.settings.allow_cloud&&!['localhost','127.0.0.1','[::1]'].includes(new URL(state.settings.base_url).hostname)){toast('Connect your model and allow sending selected material before running AI actions.');showSettings();return;}if(!state.settings.model&&state.settings.provider!=='codex'){toast('Choose a writing model in Connections first.');showSettings();return;}state.job=await api('/projects/'+state.p.id+'/jobs',{action,instruction});render();poll();} +function poll(){clearTimeout(pollTimer);if(!state.job)return;const jid=state.job.id,pid=state.p.id;pollTimer=setTimeout(async()=>{try{const j=await api('/jobs/'+jid);if(state.job?.id!==jid||state.p?.id!==pid)return;state.job=j;paintJob();if(['queued','running'].includes(j.state)){poll();return;}const p=await api('/projects/'+state.p.id);state.p=p;syncSummary();if(j.state==='completed'){state.tab={graph:'graph',angles:'angles',outline:'outline',draft:'write',review:'write',revise:'write',humanize:'write',deslop:'write',both:'write',voice:'sources'}[j.action]||state.tab;state.layout={};toast('Finished. Your project is saved locally.');}else toast(j.error||'Job stopped. Saved versions are still available.',j.state==='failed');render();}catch(e){toast(e.message,true);poll();}},1000);} function newDialog(mode='nonfiction'){showModal('Begin a new project','You can change the direction as the work develops.',`
`,btn('Create project','create-project','arrow','primary'));} function rolesHTML(value){return ['evidence','canon','inspiration','voice'].map(v=>``).join('');} function pasteDialog(){showModal('Bring a source into the studio','Keep the original text. The graph will link its claims back to these passages.',`
`,btn('Add source','add-text','plus','primary'),true);} diff --git a/ui/index.html b/ui/index.html index 444f47e..078248f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,8 +3,9 @@ GraphPaper — Writing studio - + +

Opening your writing studio…

diff --git a/ui/studio.css b/ui/studio.css new file mode 100644 index 0000000..e178321 --- /dev/null +++ b/ui/studio.css @@ -0,0 +1,2 @@ +.studio-strip{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:18px 20px;border:1px solid var(--border);background:var(--panel);border-radius:12px;margin-bottom:22px}.studio-strip strong{font-size:14px;font-weight:550}.studio-strip p,.studio-auth p{font-size:12px;color:var(--muted);margin-top:4px}.studio-tools{padding:0 0 21px;margin-bottom:20px;border-bottom:1px solid var(--border)}.studio-tools>.text-link{display:block}.studio-auth{padding:21px;border-radius:12px;background:var(--bg);border:1px solid var(--border);margin-bottom:25px}.studio-auth h3{font-family:var(--serif);font-weight:400;font-size:22px}.studio-samples{border:1px solid var(--border);border-radius:10px;padding:5px 15px;max-height:240px;overflow:auto}.studio-sample{display:flex;align-items:center;gap:11px;padding:12px 0;border-bottom:1px solid var(--border)}.studio-sample:last-child{border:0}.studio-sample strong{font-size:12px;font-weight:500}.studio-sample small,.studio-url{display:block;font-size:10px;color:var(--muted);overflow-wrap:anywhere}.studio-metrics{display:flex;flex-wrap:wrap;gap:20px;padding:17px;border:1px solid var(--border);border-radius:10px;background:var(--bg);font-size:11px;color:var(--muted)}.studio-metrics strong{font-size:19px;font-weight:450;color:var(--accent);display:block}.studio-path{width:100%;font-size:12px;font-family:Consolas,monospace}.studio-folder-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.studio-folder-grid>div{padding:15px;border:1px solid var(--border);border-radius:9px}.studio-folder-grid strong{font-size:12px;font-weight:550;color:var(--accent)}.studio-folder-grid p{font-size:11px;color:var(--muted);margin-top:5px}.studio-edit-summary{padding-bottom:18px;font-size:13px}.studio-edit-summary p{font-size:11px;color:var(--muted);margin-top:8px}.studio-compare{display:grid;grid-template-columns:1fr 1fr;gap:18px}.studio-compare>section{min-width:0;border:1px solid var(--border);border-radius:10px;background:var(--bg)}.studio-compare h3{padding:14px 19px;border-bottom:1px solid var(--border);font-size:13px;letter-spacing:.03em}.studio-compare .prose{max-height:470px;overflow:auto;padding:20px;font-size:14px}.studio-compare .prose h1{font-size:23px}.studio-diff{max-height:380px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;font:11px/1.7 Consolas,monospace;background:var(--bg);padding:15px;border-radius:8px}.studio-diff .addition{color:var(--accent);background:#8eb77918}.studio-diff .deletion{color:var(--red);background:#d6796918}@media(max-width:720px){.studio-strip{flex-direction:column;align-items:flex-start}.studio-compare,.studio-folder-grid{grid-template-columns:1fr}.studio-auth{padding:16px}.studio-strip .row{gap:7px}} +.checkbox-row input[type=checkbox]{width:16px;min-width:16px;flex:0 0 16px}.studio-auth #codex-status a{overflow-wrap:anywhere} diff --git a/ui/studio.js b/ui/studio.js new file mode 100644 index 0000000..373866d --- /dev/null +++ b/ui/studio.js @@ -0,0 +1,47 @@ +/* GraphPaper 0.2: author voice, project inbox, reversible prose tools and Codex sign-in. */ +'use strict'; +const studio = {syncing:false,discovery:[],audit:null}; +const priorSourcesHTML = sourcesHTML; +sourcesHTML = function(){return `
Your material. Your voice.

Keep files together and let your own writing guide the result.

${btn('Your writing voice','studio-voice','pen','small')}${btn('Project folder','studio-folder','folder','small')}
`+priorSourcesHTML();}; +const priorWriteHTML = writeHTML; +writeHTML = function(){let html=priorWriteHTML();const tools=`
Prose craft
${btn('Humanize','studio-polish','','small',busy()?'disabled':'')}${btn('Deslop','studio-deslop','','small',busy()?'disabled':'')}${btn('Inspect prose','studio-audit','search','small ghost')}
${state.p.polish?btn('Compare proposed edit','studio-compare','branch','primary mt12'):''}
`;return html.replace('
',tools+'
');}; +const priorSettingsForm = settingsForm; +settingsForm = function(){let html=priorSettingsForm();html=html.replace('`);return `

Use your ChatGPT account

Codex subscription access, with browser sign-in.

No writing API key
${btn('Sign in with ChatGPT','studio-codex-login','lock','primary small')}${btn('Check sign-in','studio-codex-status','refresh','small')}${btn('Sign out','studio-codex-logout','','small ghost')}

The Windows release includes the official Codex runtime. Sign-in and credential storage are handled by Codex in a separate GraphPaper account directory. JEV remains an optional, separate connection.

Existing Codex runtime
`+html;}; +const priorSaveSettings = saveSettings; +saveSettings = async function(){if($('#s-codex_executable'))state.settings.codex_executable=$('#s-codex_executable').value.trim();return priorSaveSettings();}; +function voiceDialog(){const p=state.p,v=p.voice_profile,examples=p.sources.filter(s=>s.role==='voice'&&s.enabled);showModal('Your writing voice','Learn from your own articles, then keep the profile under your control.',`
${btn('Upload samples','studio-upload-voice','upload','small')}${btn('Paste a sample','studio-paste-voice','file','small')}${btn('Find my writing online','studio-author-web','link','small')}
${examples.length?examples.map(s=>`
${icon('file')}
${esc(s.title)}${esc(s.id)} · ${fmt(words(s.text))} words${s.url?' · web source':''}
${ibtn('Read sample','source','eye',`data-id="${esc(s.id)}"`)}
`).join(''):'

Add pieces you wrote. A few representative articles or chapters work better than isolated slogans.

'}
${btn(v.learned_at?'Relearn from samples':'Learn my writing style','studio-learn-voice','spark','primary',busy()?'disabled':'')}One analysis request. The profile is used for new drafts and revisions.
${v.metrics?.words?`
${fmt(v.metrics.words)} sample words${v.metrics.mean_sentence_words} words / sentence${v.metrics.sentence_variation} sentence variation
`:''}${v.observations?.length?`
What the profile noticed${v.observations.map(x=>`

${esc(x)}

`).join('')}
`:''}
Samples guide rhythm, diction and rhetorical choices. They are never cited as facts or copied into the new piece. This is reusable prompting, not model fine-tuning. Changing samples makes the learned profile stale until you relearn it.
`,btn('Save voice settings','studio-save-voice','check','primary'),true);} +function authorWebDialog(){showModal('Find your writing online','Enter an article, blog archive or author page. Choose what belongs in your voice library.',`
${btn('Find articles','studio-discover','search','small')}

Only linked pages on the same site are listed. This does not verify authorship; select your own work, not other contributors, navigation pages or quotations.

`,btn('Import selected samples','studio-import-authors','plus','primary','disabled'),true);} +async function folderDialog(){await flush();const info=await api('/projects/'+state.p.id+'/workspace');showModal('This project’s folder','Drop documents into its Inbox. They become part of this project automatically.',`
${btn('Open folder','studio-open-folder','folder','primary small')}${btn('Import files now','studio-scan-folder','refresh','small')}
Inbox / Evidence

Citable research and source articles.

Inbox / Voice

Your writing samples.

Inbox / Canon

Established characters, events and world rules.

Inbox / Inspiration

Possibilities, not factual evidence.

Local import makes no AI calls. Original app uploads are kept in Originals. Removed inbox files do not erase sources; deleting the project does not delete this folder. SQLite remains the project’s saved state.
`,btn('Save preference','studio-save-folder','check','primary'),true);} +function polishDialog(mode){showModal(mode==='deslop'?'Remove the slop':'Make it sound like you','A proposed edit is saved separately. Your current draft stays untouched.',`
Quotations, numbers, citations, links and code are protected. A second model check looks for meaning drift. You compare the result before accepting it. No invented experiences, random mistakes or AI-detector-evasion tricks.
`,btn('Create proposed edit','studio-run-craft','pen','primary'));} +async function auditDialog(){await flush();const audit=await api('/projects/'+state.p.id+'/craft/audit',{});studio.audit=audit;showModal('Prose inspection','Local checks only. No model request and no changes to your draft.',`
${audit.count} editorial cues${audit.metrics.mean_sentence_words} words / sentence${audit.metrics.sentence_variation} sentence variation
${audit.findings.map(f=>`
${esc(f.rule)}
${esc(f.excerpt)}

${esc(f.suggestion)}

`).join('')||'

No listed phrase patterns were found. This is not a certificate of good prose.

'}${audit.repeated_openings.length?`

Repeated paragraph openings: ${audit.repeated_openings.map(esc).join('; ')}

`:''}
${esc(audit.note)}
`,btn('Humanize','studio-polish','pen','small')+btn('Deslop','studio-deslop','pen','primary small'),true);} +function compareDialog(){const c=state.p.polish;if(!c){toast('No proposed prose edit yet.');return;}showModal('Original and proposed edit','Read the changes. The original remains your current draft until you accept.',`
${esc(c.review.summary||'Proposed '+c.mode+' edit')}

${c.before.count} → ${c.after.count} listed cues · ${c.protected_spans} protected spans kept intact

${(c.review.warnings||[]).map(x=>`
${esc(x)}
`).join('')}

Original

${markdown(state.p.draft)}

Proposed

${markdown(c.draft)}
Line-by-line changes
${c.diff.map(l=>`${esc(l)}`).join('\n')}
${!c.review.meaning_preserved?'':''}`,btn('Discard proposal','studio-discard-craft','x','ghost')+btn('Accept this edit','studio-accept-craft','check','primary'),true);} +async function scanFolder(force=false){if(studio.syncing||busy()||!state.p)return;await flush();studio.syncing=true;const id=state.p.id,version=state.p.version;try{const result=await api('/projects/'+id+'/workspace/scan',{force});if(state.p?.id!==id)return result;if(result.changed&&state.p.version===version){const edits={...pending};state.p=result.project;Object.assign(state.p,edits);syncSummary();if(!Object.keys(edits).length&&!$('.modal'))render();}if(force&&$('#folder-events'))$('#folder-events').innerHTML=result.events.map(e=>`

${esc(e.path)} · ${esc(e.status)}${e.message?' — '+esc(e.message):''}

`).join('')||'

Inbox is up to date. No new files to import.

';else if(result.changed)toast(`${result.events.filter(e=>['imported','updated'].includes(e.status)).length} inbox file(s) added or updated.`);return result;}finally{studio.syncing=false;}} +const priorHandleAction=handleAction; +handleAction=async function(el){const action=el.dataset.action;if(!action.startsWith('studio-'))return priorHandleAction(el);switch(action){ + case 'studio-voice':await flush();voiceDialog();break; + case 'studio-upload-voice':if(!editable())return;chooseFile('.pdf,.docx,.txt,.md,.html,.htm',true,async files=>{await flush();for(const file of files){if(file.size>20*1024*1024)throw new Error('Each sample must be under 20 MB.');const form=new FormData();form.append('file',file);form.append('role','voice');state.p=await api('/projects/'+state.p.id+'/sources/file',form);}syncSummary();render();voiceDialog();toast('Writing samples added.');});break; + case 'studio-paste-voice':if(editable()){pasteDialog();$('#source-role').value='voice';}break; + case 'studio-author-web':authorWebDialog();break; + case 'studio-discover':el.disabled=true;try{$('#modal-status').textContent='Finding linked articles…';const r=await api('/voice/discover',{url:$('#author-url').value});studio.discovery=r.pages;$('#author-results').innerHTML=r.pages.map((p,i)=>``).join('');$('#modal-status').textContent=r.note;$('[data-action="studio-import-authors"]').disabled=false;}finally{el.disabled=false;}break; + case 'studio-import-authors':{const pages=$$('.author-page:checked').map(x=>studio.discovery[Number(x.value)]);if(!pages.length||pages.length>12)throw new Error('Select between 1 and 12 writing samples.');el.disabled=true;await flush();const errors=[];try{for(let i=0;iOpen sign-in page';break;} + case 'studio-codex-status':{await saveSettings();$('#codex-status').textContent='Checking Codex…';const r=await api('/codex/status');$('#codex-status').textContent=r.signed_in?`Connected as ${r.email||'your ChatGPT account'}${r.plan?' · '+r.plan:''}. Select Codex as the writing provider. A blank model uses the Codex default.`:r.error||'Not signed in yet. Complete the browser sign-in and check again.';break;} + case 'studio-codex-logout':await api('/codex/logout',{});$('#codex-status').textContent='Signed out of GraphPaper’s Codex account. Other Codex installations were not signed out.';break; + }}; +document.addEventListener('input',event=>{if(event.target.id==='voice-strength')$('#voice-strength-value').textContent=event.target.value;}); +document.addEventListener('change',event=>{if(event.target.id==='s-provider'&&event.target.value==='codex'){$('#s-base_url').value='https://api.openai.com/v1';for(const id of ['model','editor_model','extraction_model'])$('#s-'+id).value='';$('#s-model').placeholder='Blank = your Codex default model';}}); +setInterval(()=>{const focused=document.activeElement;if(!state.p?.auto_import||studio.syncing||busy()||$('.modal')||Object.keys(pending).length||state.save==='Saving…'||document.hidden||focused?.matches('input,textarea,select'))return;scanFolder(false).catch(e=>console.warn('Project inbox:',e.message));},5000); +if(state.projects.length||state.p)render();