7 Commits
Author SHA1 Message Date
JesseMarkowitzandClaude Opus 5 04ebb8987c release: v1.0.0, and split the README into docs/
The archive does what it was built to do, unattended, on both machines:
four providers, incremental and resumable, Markdown plus Joplin, on a
schedule, with results pushed to a phone and failures that announce
themselves. The one item left on the roadmap — a StartOS service that
consolidates both machines into one corpus — is an optional convenience,
not a gap. Hence 1.0.0 rather than another 0.x.

The code in this release shipped across b6ce636..8c3e00a: the claude.ai
403 session-invalid fix, the fork-subagent recursion fix, the FAILED-push
backstops on both platforms, and asset-download retries with cross-run
recovery, a circuit breaker and media counts in the notification. This
commit cuts the release and pays a documentation debt.

The README had reached 939 lines / 6,490 words / 48 KB / 96 headings,
doing four unrelated jobs at once, and FUTURE.md asked for the split to
ride along with the next substantial README edit rather than break every
existing anchor for its own sake. A release is that edit. README.md is
now 303 lines and keeps only what you need to install and configure the
tool; the rest moved verbatim into docs/providers.md, docs/cli.md,
docs/scheduling.md and docs/troubleshooting.md. No section was rewritten
or dropped and every original heading still exists, so each anchor still
resolves somewhere — but bookmarks into moved sections now need the
docs/ path, which is the cost this deferral was protecting against.

.gitignore needed the fix FUTURE.md predicted: the blanket *.md rule
(exported conversations are Markdown and may be private) matched all
four new pages, and git check-ignore confirmed every one would have been
silently ignored. !docs/*.md is added with a comment saying why. Gitea's
rendering of relative docs/ links cannot be checked before pushing, so
it still needs confirming after.

Also corrects two stale claims in the changelog: the Windows backstop is
no longer "not yet run under Windows PowerShell 5.1 or Task Scheduler"
(confirmed on winmini today, LastTaskResult 0 — only the FAILED push
itself remains unexercised there), and the test count is 414, not 409.

414 tests passing. No code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gCWSknsWvhLnMFwa7qnGp
2026-10-20 22:18:37 -04:00
JesseMarkowitzandClaude Opus 5 8c3e00a520 fix: retry asset downloads on 5xx and recover them across runs
An asset download is two hops — mint a signed URL, then fetch the bytes —
and only the first went through _make_request. The second was a bare
session.request with no 5xx handling, which lost two images in the
2026-10-20 09:00 run: one to a 500 the retried first hop had already
recovered from, one to a 503. Both re-downloaded cleanly an hour later
(106 KB and 1.3 MB), so the bytes were there all along.

A third asset timed out and aborted its whole conversation. curl_cffi's
RequestException subclasses OSError, so the un-wrapped timeout slipped
past resolve_media's except ProviderError into the export loop's
except OSError and was reported as "File write failed for conversation
6a945155" — a conversation that had already downloaded ~20 images.
Nothing was wrong with any file write.

The retry loop is factored out of _make_request into _send_with_retries,
which returns the raw response so a bytes caller gets the same handling
as a JSON one. Hop 2 goes through it: 5xx retried, Retry-After honoured,
timeouts surfacing as ProviderError for resolve_media to placeholder.
The hop is now logged, which it never was. Assets get their own ladder —
an immediate retry, then ~2/4/8/16s with jitter kept, since a storage
incident fails many assets at once — bounded by a per-asset wall-clock
budget, because the timeout case costs 40s a go.

Beyond the retry:

- Transient failures (5xx, timeout, network) are recorded as
  pending_media so a later run re-fetches just that asset; capped at
  3 runs. 404/403 stay permanent — settled 2026-08-17 for 19 assets.
- A circuit breaker stops downloads after 5 consecutive transient
  failures, recording the rest as retryable. Permanent failures never
  trip it; any success resets the streak.
- Media failures now appear in the set ids.
  The run that lost two assets pushed a clean "OK".

Keeps FUTURE.md's per-conversation cache reset correctly closed. The
README split (FUTURE.md §2) stays otial
enough to justify breaking every anchor link.

37 new tests (414 passing). Verified live: both lost assets recovered,
doctor green, a read-only chatgpt dns.
The cross-run retry and breaker are unit-tested only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/cwa7qnGp
Claude-Session: https://claude.ai/code/session_012gCWSknsWvhLnMFwa7qnGp
2026-10-20 10:32:20 -04:00
JesseMarkowitzandClaude Opus 5.5 805689c649 fix: give the Windows scheduled task the same FAILED-push backstop
The task now runs scheduling\run-sync.ps1 instead of an inline cmd.exe
loop: every provider is attempted, a run that dies without the app's
own report pushes a high-priority FAILED with the exception class only,
and each run's output is appended to cache\logs\scheduled-sync.log
because Task Scheduler keeps none. Verified under PowerShell 7.6 on
Linux with a stand-in cmd.exe; not yet run under Windows PowerShell 5.1
or Task Scheduler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rwKTmug58sEsJ72AuWsEi
2026-10-07 06:05:20 -04:00
JesseMarkowitzandClaude Opus 5.5 23c6e1f512 fix: push a FAILED notification when a scheduled run dies without reporting
sync pushes its result only from the end of a run it finished, so a crash or an early exit sent nothing, while the providers that survived kept pushing OK. The systemd unit now runs scheduling/run-sync.sh, which keeps the per-provider loop and pushes a high-priority failure, with the exception class only, for any run that exited non-zero without the app's own report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF
2026-10-05 07:14:16 -04:00
JesseMarkowitzandClaude Opus 5.5 64068bb19b fix: fork subagents recursed forever in the Claude Code export
A fork subagent's transcript opens with a copy of the parent turn that spawned it, its own Agent call included; folding that call re-entered the same fork until RecursionError, failing every daily sync since 2026-09-24. Skip a spawn call while its own subagent is being expanded, and strip the <fork-boilerplate> preamble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF
2026-10-05 07:14:16 -04:00
JesseMarkowitzandClaude Opus 5.5 b6ce636891 fix: detect claude.ai's 403 session-invalid as an expired key; ChatGPT .1 cookie is optional
claude.ai answers an invalid or expired sessionKey with 403 account_session_invalid, never 401, so the refresh-your-cookie message could not fire for Claude. Auth detection is now a provider decision (_is_auth_failure); Claude matches the 403 on its error code so a real permission error still reports as itself. README and .env.example no longer claim both ChatGPT cookie chunks are required.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF
2026-10-05 07:14:01 -04:00
JesseMarkowitz d3745e1de4 fix: test suite sent real push notifications via the developer's .env 2026-08-18 14:26:05 -04:00
28 changed files with 2341 additions and 733 deletions
+10 -2
View File
@@ -6,10 +6,18 @@
# --- ChatGPT --- # --- ChatGPT ---
# How to get: open chatgpt.com in Chrome → F12 → Application tab # How to get: open chatgpt.com in Chrome → F12 → Application tab
# → Cookies → https://chatgpt.com → find the two cookie chunks: # → Cookies → https://chatgpt.com → find the session token cookie. Chrome splits a
# cookie only above ~4KB, so you will see ONE of these two layouts:
#
# __Secure-next-auth.session-token (the whole value) → CHATGPT_SESSION_TOKEN
# (leave _1 empty)
# or, when the token was large enough to be split:
# __Secure-next-auth.session-token.0 (starts with "eyJ") → CHATGPT_SESSION_TOKEN # __Secure-next-auth.session-token.0 (starts with "eyJ") → CHATGPT_SESSION_TOKEN
# __Secure-next-auth.session-token.1 (the remainder) → CHATGPT_SESSION_TOKEN_1 # __Secure-next-auth.session-token.1 (the remainder) → CHATGPT_SESSION_TOKEN_1
# Token type: JWE. Typically valid for ~7 days. #
# CHATGPT_SESSION_TOKEN_1 is OPTIONAL — leave it empty when there is no .1 cookie.
# But if a .1 cookie does exist, you must copy it: a partial .0 fails silently
# (HTTP 200 with no accessToken). Token type: JWE. Typically valid for ~7 days.
CHATGPT_SESSION_TOKEN= CHATGPT_SESSION_TOKEN=
CHATGPT_SESSION_TOKEN_1= CHATGPT_SESSION_TOKEN_1=
+3
View File
@@ -24,6 +24,9 @@ exports/
!FUTURE.md !FUTURE.md
!FUTURE-ARCHIVE.md !FUTURE-ARCHIVE.md
!CHANGELOG.md !CHANGELOG.md
# The split-out documentation (README.md's detail pages). Without this the
# blanket *.md above would ignore every new docs/ page silently, with no error.
!docs/*.md
# Cache and logs # Cache and logs
cache/ cache/
+87
View File
@@ -3,6 +3,93 @@
All notable changes to this project will be documented here. All notable changes to this project will be documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.0] - 2026-10-20
The archive does what it was built to do, on both machines, unattended: four
providers, incremental and resumable, Markdown plus Joplin, on a schedule, with
results pushed to a phone and failures that announce themselves. What remains on
the roadmap is one optional consolidation (`FUTURE.md` §1), not a gap — so this
is 1.0.0 rather than another 0.x.
This release is mostly the unglamorous half of that claim: four bugs that each
made a scheduled run lie about its own outcome, and the documentation split that
939 lines had outgrown.
### Fixed
- **A transient 5xx on an image download lost the image permanently, and a timeout lost the whole conversation.** An asset download is two hops: `GET /files/{id}/download` mints a signed URL, then that URL is fetched for the bytes. Only the first went through `_make_request`. The second was a bare `session.request` with no 5xx handling at all, which produced two distinct failures in the 2026-10-20 09:00 run:
```
09:04:05 .../files/<id>/download → 500 Retry 1/3 in 2.2s
09:04:17 → 500 Retry 2/3 in 4.3s
09:04:32 → 500 Retry 3/3 in 9.1s
09:04:42 → 200 ← hop 1 recovered on attempt 4
09:04:47 WARNING Signed URL returned HTTP 500 ← hop 2, one try, gone
```
Hop 1 was patiently retried and won; hop 2 failed once and gave up. A second asset was lost the same way to a **503**, so this was never specific to 500. Both assets downloaded cleanly on 2026-10-20 at 10:00 — 106 KB and 1.3 MB — confirming the bytes were there all along and only the retry policy lost them.
Worse, a third asset **timed out**, and that aborted the entire conversation. `curl_cffi`'s `RequestException` subclasses `OSError`, so an un-wrapped timeout on hop 2 slipped past `resolve_media`'s `except ProviderError` and was caught by the export loop's `except OSError`, which reported it as **"File write failed for conversation 6a945155"** — a conversation that had already downloaded ~20 images. Nothing was wrong with any file write. The design intent in `media.py` ("Failures keep placeholders; never fatal") was bypassed because the exception took a path nobody had anticipated.
The retry loop is now factored out of `_make_request` into `_send_with_retries`, which returns the raw response so a caller wanting **bytes** gets identical handling to one wanting JSON. Hop 2 goes through it, which fixes both bugs at once: 5xx is retried, `Retry-After` is honoured, and a timeout arrives as `ProviderError` — which `resolve_media` already knows how to placeholder. The hop is also now logged, which it never was; its absence from the log is what made this take a debug run to find. `_pace()` is no longer called by hand there, because `_send_with_retries` paces every attempt — pacing twice would have added `REQUEST_DELAY` to all 88 assets in that run.
Assets get a more patient ladder than the conversation API, since they fail for their own reasons: an immediate retry (free, and a single bad backend node is the commonest shape of a transient 5xx), then ~2/4/8/16s with jitter. **Jitter is deliberately kept** rather than using fixed steps — a storage incident fails many assets at once, and un-jittered retries would hit a recovering backend in lockstep. The ladder is bounded by a wall-clock budget per asset, and the final attempt's read timeout is clamped to what remains, because the timeout case costs 40s a go and six unbounded attempts of it would eat a scheduled run's execution window (two hours, per the Windows task definition).
### Added
- **Transiently failed assets are re-attempted on later runs.** Previously `mark_exported` ran regardless, so a conversation whose image 500'd was "up to date" forever and the image was unrecoverable without hand-editing the manifest. Failures are now split by whether retrying can possibly help: `expired-or-missing` (404) and `forbidden` (403) are permanent — that was settled exhaustively on 2026-08-17 for 19 assets whose bytes OpenAI lost in a Library migration — while 5xx, timeouts and network blips are transient. Transient refs are recorded against the conversation as `pending_media`, which makes `is_cached` treat it as stale so the next run re-fetches them. Re-exporting is cheap: media already on disk is reused and Joplin resource IDs are keyed by path, so only the missing asset is fetched and only the new resource uploaded. Capped at 3 runs (`MAX_MEDIA_RETRY_RUNS`), because an asset that 5xxs every morning would otherwise re-export and re-sync its conversation forever — one of today's conversations took 3m15s and 50 resource uploads. The refs are kept after the cap as a record of what was lost.
This leaves the "per-conversation cache reset" item in `FUTURE.md` correctly closed: the case that would have motivated it — recovering one conversation after a transient asset failure — now heals itself, and the documented workaround (edit the manifest) still covers the rest.
- **A media circuit breaker, so a bad day at the provider costs one quiet morning.** Five consecutive *transient* asset failures stop downloads for the remainder of the run; the skipped assets are recorded as retryable rather than abandoned, so the next run collects them. Permanent failures never count toward tripping it — a deleted upload says nothing about the provider's health, and this archive holds 19 of those that would otherwise trip it on a perfectly healthy server. Any success resets the streak, so ordinary scattered losses never trip it either. Assets already on disk are still linked while the breaker is open, since that needs no network.
- **Media failures appear in the push notification.** `format_summary` now adds `N media failed` per provider. The 2026-10-20 run lost two assets and pushed a clean "AI archive OK" with no hint of it — the counts existed only in the log file, which is somewhere you have to think to go and look. Counts only, never asset ids, for the same reason conversation titles are excluded: a topic on public ntfy.sh is readable by anyone who knows its name. Media failures deliberately do **not** raise the notification's priority or mark the run failed — the transcript is archived and a transient asset recovers by itself.
### Changed
- **The README is split into a short overview plus four documents in `docs/`,** closing `FUTURE.md` §2. It had reached 939 lines / 6,490 words / 48 KB / 96 headings — a ~25-minute read doing four unrelated jobs at once, and still growing (it was 830 / 5,562 / 37 KB / 61 when the split was first proposed). `README.md` is now 303 lines and keeps only what you need to install and configure the tool: the ToS warning, installation, `doctor`, `.env` reference, output structure, security notes. The rest moved verbatim — no section was rewritten or dropped, and every original heading still exists, so every anchor still resolves *somewhere*:
| Document | Content |
|----------|---------|
| `docs/providers.md` | Session tokens, lifetimes and expiry detection, the `auth` wizard, ChatGPT Projects, Claude Code sessions, Codex sessions |
| `docs/cli.md` | CLI reference — all eleven commands |
| `docs/scheduling.md` | systemd timer, Windows task, what to know before relying on it, notifications |
| `docs/troubleshooting.md` | How the cache works, every failure message, media download failures, filing a bug report |
The cost this deferral was protecting against is real and is now paid: a bookmark or note pointing at a README anchor that moved (`#cli-reference`, `#troubleshooting`, `#getting-your-session-tokens`, …) no longer resolves, because the anchor is in another file. The three questions `FUTURE.md` left open are settled as follows. **`.gitignore` did need the fix it predicted** — the blanket `*.md` rule (exported conversations are Markdown and may be private) matched all four new pages, and `git check-ignore` confirmed every one would have been committed-never and ignored silently; `!docs/*.md` is added alongside the existing per-file re-includes, with a comment saying why. **Cross-file anchors are used, not avoided**, but only for the four links that already existed between these sections, each verified to point at a heading that still exists. **Gitea rendering of relative `docs/` links is unverified** — it cannot be checked before pushing, so confirm it after.
### Notes
- Verified live rather than only in tests: the two assets lost in the morning run were re-downloaded successfully through the new path (`doctor` all green, 414 tests passing, a read-only `export --dry-run --provider chatgpt` exercising the refactored request core across 4 listing pages / 379 conversations). The cross-run retry and the breaker are covered by unit tests only — reproducing a multi-day storage incident against the live provider is not something this change waited for.
- **Every scheduled Claude Code sync since 2026-09-24 died with `RecursionError`.** Claude Code's `fork` subagents write a transcript that opens with a copy of the parent turn that spawned them — the fork's own `Agent` call included. `_extract_messages` folds a subagent inline whenever it meets its spawn call, so it met that copy inside the fork, folded the same fork again, and recursed until Python's limit. One fork anywhere in `~/.claude/projects` was enough to fail the whole provider; seven existed across three sessions, and the Codex half of the run, unaffected, hid the cause behind a generic exit 1.
The recursive pass now carries the spawn ids being expanded around it and skips a tool_use whose id is among them — the enclosing subagent block already stands for that call. Because the set accumulates, a longer cycle (A spawns B, whose transcript re-spawns A) stops too. The fork's `<fork-boilerplate>` preamble — generic worker rules the harness prepends — is now stripped with the other harness tags, so a fork block opens with its actual directive.
`TestSubagentFold.test_fork_containing_its_own_spawn_call` reproduces the on-disk shape (a `fork-context-ref` record, the copied spawn call, the boilerplate-wrapped directive) and fails with the original `RecursionError` against the unfixed code. Verified against the real archive: all 85 sessions normalize, the seven forks each render as one subagent block.
- **A scheduled run that crashed sent no notification, and the providers that survived said "OK".** `sync` pushes its ntfy result from the end of a run it finished, so the `RecursionError` above — and any crash, any exit before the sync starts (the ToS gate, a cache error), a launcher that cannot build its venv — sent nothing at all. Worse, the unit runs each provider as its own `sync`, so codex kept pushing a low-priority "AI archive OK" every morning for the eleven days claude-code was dead: a broken provider was indistinguishable from a quiet day.
The systemd unit's `ExecStart` is now `scheduling/run-sync.sh`, which carries the old per-provider loop and pushes a high-priority **FAILED** notification for any run that exits non-zero without the app's "Sync completed with failures" banner (printed right after the app's own push, so an app-reported failure is not reported twice). The push names the provider and, for a crash, the exception class only — `claude-code: crashed (RecursionError)` — never its message, holding to `src/notify.py`'s counts-only rule for a topic anyone can read. It honours `NTFY_NOTIFY=off` and reads `NTFY_*` the way the app does: environment first, then `.env`. Re-run `install-systemd-timer.sh` to pick it up.
Verified against a fake launcher and a local capture server: a crash pushes with the class and without the message, an exit before the sync pushes, an app-reported failure and a success push nothing extra, `off` pushes nothing, and the run still exits 1 if any provider failed.
The Windows task gets the same backstop. Its action is now `scheduling\run-sync.ps1` (via `powershell.exe -File`, window hidden) in place of the inline `cmd.exe` loop, with the same rules: every provider attempted, exit 1 if any failed, a FAILED push carrying the class only for a run that died unreported, `NTFY_NOTIFY=off` honoured, `NTFY_*` read from the environment first. Because Task Scheduler keeps no output, each run's output is appended to `cache\logs\scheduled-sync.log` (one rollover to `.1` past 1 MB), and the push points there. The wrapper sets `PYTHONIOENCODING=utf-8`: with stdout redirected to a file rather than a console, Python would otherwise encode in the ANSI code page and fail on the first `✓`. Re-run `Register-AiChatSyncTask.ps1` with your providers to pick it up.
Verified on Linux under PowerShell 7.6 with a stand-in `cmd.exe`, the same fake launcher and capture server: the same five outcomes as above, plus a bearer token, an environment `NTFY_TOPIC` overriding `.env`, an unreachable server (warning, exit still 1) and the `chatgpt,claude` single-argument form. Since confirmed on winmini under Windows PowerShell 5.1 and Task Scheduler (2026-10-20): `Register-AiChatSyncTask.ps1 -Provider chatgpt,claude` registered the wrapper, and a `Start-ScheduledTask` run of it completed with `LastTaskResult: 0`, writing both providers' output to `cache\logs\scheduled-sync.log`. Registering is a one-time write, so a `git pull` does **not** update an already-registered task — re-run the registration script after taking this change. The one path still unexercised on Windows is the FAILED push itself: it needs a run that dies without reporting, which has not happened there yet.
- **An expired Claude session key reported a raw JSON dump instead of how to fix it.** `_make_request` routed only **401** to the auth handler (`src/providers/base.py`), and claude.ai does not use 401 — an invalid or expired `sessionKey` comes back as `403 permission_error` with `details.error_code = account_session_invalid`. So the one message that names the cookie, its ~30-day lifetime and the DevTools path to refresh it could never fire for Claude. What the user got instead was the generic 4xx path: `HTTP 403 — error: {'type': 'permission_error', 'message': 'Invalid authorization'…}`, which reads like a permissions problem with the account and not like "your key expired, here is how to replace it."
Measured live 2026-09-20 against `GET /api/organizations`: a valid key returns 200, while an expired key, a deliberately malformed key and **no cookie at all** return byte-identical 403s carrying that code — i.e. the API treats a dead session as an absent one. This is the same mistake as the ChatGPT media 403s below: assuming 403 means "forbidden" when the service uses it for "unauthenticated."
Auth detection is now a provider decision rather than a hardcoded status. `BaseProvider._is_auth_failure(response)` defaults to 401 and `ClaudeProvider` overrides it to add 403 **matched on `account_session_invalid`**, not on the bare status — so a genuine permission error, which carries a different code, is still reported as itself rather than being mislabelled an expired key. `_handle_401` is renamed `_handle_auth_failure` and takes the response, because a handler named for one status that must handle two is how this stayed hidden; its messages now state the status actually observed instead of asserting "401 Unauthorized". ChatGPT is unaffected: it does not override the default, so the deleted-asset 403 path is untouched.
Seven regression tests cover the split (`TestAuthFailureDetection`), including the two that matter most: a Claude 403 with a *different* error code must **not** be treated as an auth failure, and a ChatGPT 403 must not either. The docs that repeated the wrong premise — `README.md`'s expiry table and "When Tokens Expire" section, the `auth` wizard's on-screen note, and the `ClaudeProvider` docstring — are corrected in the same change.
- **The docs claimed both ChatGPT cookie chunks were required; they are not.** `README.md` stated flatly that "ChatGPT splits large session tokens across two cookies to stay under the browser's 4KB cookie limit. Both are required," and `.env.example` documented only the chunked layout — so a machine whose session token happens to fit in a single `__Secure-next-auth.session-token` cookie looked broken, with the user hunting for a `.1` that does not exist. Chrome splits a cookie only above ~4KB, so the layout varies by session size and the *same account* can be chunked on one machine and not on another.
The code was already correct: `CHATGPT_SESSION_TOKEN_1` is optional (`src/providers/chatgpt.py:162`) and the `auth` wizard already told you to paste a lone cookie into `.0` and leave `.1` blank. Only the reference docs were wrong, and they are the ones read when setting up a new machine.
Measured 2026-09-20 against `/api/auth/session`, reassembling a real 4089-byte token to test each naming: chunked `.0`+`.1` → 200 with an `accessToken`; the whole value under the unchunked name → 200 with an `accessToken`; the whole value under `.0` alone → 200 with an `accessToken`. The server reassembles a complete value sent under `.0`, so both layouts authenticate as the code already assumed. A *partial* `.0` with its `.1` omitted is the one combination that fails, and it fails **silently** — HTTP 200 with no `accessToken` rather than an error — which is now documented in both files alongside the correction.
- **The test suite sent real push notifications to the developer's phone.** `TestSyncCommand` invokes the actual `sync` command, which calls `load_config()`, which calls `load_dotenv()` — so the real `.env` was loaded and its live `NTFY_TOPIC` used for the POST. Every `pytest` run fired three or four pushes, including a fabricated "codex: 3 conversation(s) failed to export" straight out of a fixture, which is worse than noise: it reports a failure that never happened. Nothing appeared in `cache/logs/exporter.log` to explain it, because every test invocation passes `--no-log-file`.
A `tests/conftest.py` autouse fixture now neutralises the environment for every test: `NTFY_TOPIC`/`NTFY_TOKEN` are emptied, and `NTFY_SERVER` and `JOPLIN_API_URL` are pointed at a closed local port, so a stray topic cannot reach the internet and a test cannot write notes into a real Joplin instance. The values are **emptied rather than deleted** — `load_dotenv(override=False)` skips only keys already present, so deleting one lets `.env` put it back. Verified by instrumenting `requests` across a full run: zero outbound requests, where the same instrumentation without the fixture records POSTs to the live ntfy topic.
## [0.9.0] - 2026-08-18 ## [0.9.0] - 2026-08-18
### Fixed ### Fixed
+5 -40
View File
@@ -1,9 +1,9 @@
# Planned Future Work # Planned Future Work
> **Status 2026-08-18 (v0.9.0).** The tool archives four providers — two web > **Status 2026-10-20 (v1.0.0).** The tool archives four providers — two web (`chatgpt`,
> (`chatgpt`, `claude`) and two local agent-transcript (`claude-code`, `codex`) > `claude`) and two local agent-transcript (`claude-code`, `codex`) — on a
> — on a schedule, on Linux and Windows, reporting results by push notification. > schedule, on Linux and Windows, reporting results by push notification.
> Two items below are genuinely planned. Everything else has shipped or been > **One item below is genuinely planned.** Everything else has shipped or been
> decided against. > decided against.
Completed work moves to the changelog; this file holds only what is *not* built Completed work moves to the changelog; this file holds only what is *not* built
@@ -71,42 +71,6 @@ What this needs, and what it does *not*:
Meanwhile Joplin is sufficient — it already syncs (encrypted) offsite, and it is Meanwhile Joplin is sufficient — it already syncs (encrypted) offsite, and it is
where the archive is actually read. This is a "nice eventually", not a gap. where the archive is actually read. This is a "nice eventually", not a gap.
## 2. Split the README into separate documents
The README is 830 lines / 5,562 words / 37 KB — about a 25-minute read, with 61
headings. An H2-only table of contents was added 2026-08-18 and helps, but it
treats the symptom: the file is doing at least four unrelated jobs at once.
Rough shape of a split:
| Document | Content today |
|----------|---------------|
| `README.md` | What it is, install, first run, a pointer to the rest |
| `docs/providers.md` | ChatGPT Projects, Claude Code Sessions, Codex Sessions, session tokens |
| `docs/cli.md` | CLI Reference — ~250 lines on its own, the single biggest section |
| `docs/scheduling.md` | Scheduling a Daily Run, notifications |
| `docs/troubleshooting.md` | Troubleshooting, How the Cache Works |
Not urgent, because it has a real cost the TOC did not: any existing link into a
README section — a bookmark, a note, another repo, a commit message — breaks
when that section moves to another file. Worth folding into the next substantial
README edit rather than doing as a change of its own.
Two things to settle when it happens:
- **`.gitignore` ignores `*.md` on purpose** — exported conversations are
Markdown and may contain private content — and re-includes each doc by name
(`!README.md`, `!FUTURE.md`, …). New files under `docs/` will be silently
ignored, with no error, until `!docs/*.md` is added. This already bit the
creation of `FUTURE-ARCHIVE.md` on 2026-08-18.
- Whether `docs/` renders acceptably on the Gitea instance hosting this repo.
Relative links between Markdown files do work there, but confirm before
splitting rather than after.
- Whether anchors in the split files are stable enough to link *between*
documents, or whether cross-references should point at file tops only. GFM
anchors derive from heading text and break silently on a reword — the same
fragility the TOC already carries.
--- ---
# Decided against # Decided against
@@ -142,3 +106,4 @@ Detail for each release is in `CHANGELOG.md`.
- **v0.7.0** — `canary` drift detection; real ChatGPT token-health check on `doctor`; removal of the non-viable browser-cookie path - **v0.7.0** — `canary` drift detection; real ChatGPT token-health check on `doctor`; removal of the non-viable browser-cookie path
- **v0.8.0** — Claude Code coverage reopened: subagent capture as folded `<details>`, repo `[tags]` in titles, its own `AI-ClaudeCode` notebook with self-healing note moves, multi-root scanning (`CLAUDE_CODE_DIR`, `CLAUDE_CONFIG_DIR`) - **v0.8.0** — Claude Code coverage reopened: subagent capture as folded `<details>`, repo `[tags]` in titles, its own `AI-ClaudeCode` notebook with self-healing note moves, multi-root scanning (`CLAUDE_CODE_DIR`, `CLAUDE_CONFIG_DIR`)
- **v0.9.0** — Codex CLI provider; launcher scripts removing the virtualenv ceremony on both platforms; `sync` with a meaningful exit code; daily scheduling for Linux and Windows; ntfy push notifications; ChatGPT project attribution via `gizmo_id` and the `projects` command; a silent data-loss fix in both local providers (`splitlines` breaking on U+0085/U+2028/U+2029) - **v0.9.0** — Codex CLI provider; launcher scripts removing the virtualenv ceremony on both platforms; `sync` with a meaningful exit code; daily scheduling for Linux and Windows; ntfy push notifications; ChatGPT project attribution via `gizmo_id` and the `projects` command; a silent data-loss fix in both local providers (`splitlines` breaking on U+0085/U+2028/U+2029)
- **v1.0.0** — Reliability pass on unattended runs: asset downloads retried on 5xx with a per-asset time budget, transiently failed assets recovered on later runs, a media circuit breaker, media failures surfaced in the push notification; a timeout on an asset no longer aborts its conversation; claude.ai's 403 session-invalid recognised as an expired key; `fork` subagents no longer recurse forever; a crashed scheduled run pushes a FAILED notification on both platforms; the README split into an overview plus `docs/`
+33 -584
View File
@@ -8,23 +8,26 @@ Supports incremental sync — only new or updated conversations are exported on
## Contents ## Contents
**This page** — what it is, installing it, the first run, configuration and where files land.
- [Terms of Service Warning](#terms-of-service-warning) - [Terms of Service Warning](#terms-of-service-warning)
- [Installation](#installation) - [Installation](#installation)
- [First Run: Run Doctor](#first-run-run-doctor) - [First Run: Run Doctor](#first-run-run-doctor)
- [Getting Your Session Tokens](#getting-your-session-tokens)
- [The `auth` Command](#the-auth-command)
- [`.env` Setup](#env-setup) - [`.env` Setup](#env-setup)
- [ChatGPT Projects](#chatgpt-projects)
- [Claude Code Sessions](#claude-code-sessions)
- [Codex Sessions](#codex-sessions)
- [Scheduling a Daily Run](#scheduling-a-daily-run)
- [Output Structure](#output-structure) - [Output Structure](#output-structure)
- [CLI Reference](#cli-reference) - [Documentation](#documentation)
- [How the Cache Works](#how-the-cache-works)
- [Troubleshooting](#troubleshooting)
- [Future Work](#future-work) - [Future Work](#future-work)
- [Security Notes](#security-notes) - [Security Notes](#security-notes)
**Detail lives in `docs/`:**
| Document | Covers |
|----------|--------|
| [docs/providers.md](docs/providers.md) | Session tokens and how to refresh them, the `auth` wizard, ChatGPT Projects, Claude Code sessions, Codex sessions |
| [docs/cli.md](docs/cli.md) | CLI reference — every command and flag |
| [docs/scheduling.md](docs/scheduling.md) | The daily run on Linux and Windows, and push notifications |
| [docs/troubleshooting.md](docs/troubleshooting.md) | How the cache works, what each failure means, filing a bug report |
--- ---
## Terms of Service Warning ## Terms of Service Warning
@@ -151,63 +154,6 @@ It probes one conversation per provider and checks only the fields the parser de
--- ---
## Getting Your Session Tokens
Session tokens are how your browser stays logged in. This tool uses them to access your chat history on your behalf.
Tokens are entered manually — copy them from your browser's DevTools and run the wizard:
```bash
ai-chat-exporter auth
```
The wizard detects your OS, shows the correct DevTools shortcut, and writes the values to `.env` without echoing them to the terminal.
> **Why no auto-extraction from the browser?** Modern Chromium browsers (Chrome 127+, current Brave) encrypt cookies on Windows with **App-Bound Encryption**: the key is bound to the browser through a SYSTEM-level service, so reading cookies off disk requires Administrator rights and a PsExec-style SYSTEM impersonation that antivirus flags as credential theft — and it still fails on Brave specifically. There is no reliable, non-invasive way to pull these cookies automatically, so the manual DevTools flow below is the supported path.
### Token Lifetimes
| Provider | Cookie Name | Lifetime | Expiry Detection |
|----------|-------------|----------|-----------------|
| ChatGPT | `__Secure-next-auth.session-token.0` + `.1` | refresh ~weekly | `error` field of `/api/auth/session` — `doctor` reports "ChatGPT token active". The token is an encrypted JWE, so its `exp` is **not** readable client-side, and the `expires` field is a misleading rolling window; the `error` (`RefreshAccessTokenError` when dead) is the honest signal. |
| Claude | `sessionKey` | ~30 days | Opaque token — only detectable via 401 response |
### Finding Tokens in Chrome DevTools
1. Open the provider's website and make sure you're logged in
2. Press **F12** (Windows/Linux) or **Cmd+Option+I** (macOS) to open DevTools
3. Click the **Application** tab
4. In the left panel, expand **Cookies** and click the site URL
5. Find the cookie by name and copy its **Value**
**ChatGPT:** go to `https://chatgpt.com` → find **two** cookies:
- `__Secure-next-auth.session-token.0` — copy Value (starts with `eyJ`) → `CHATGPT_SESSION_TOKEN`
- `__Secure-next-auth.session-token.1` — copy Value → `CHATGPT_SESSION_TOKEN_1`
ChatGPT splits large session tokens across two cookies to stay under the browser's 4KB cookie limit. Both are required.
**Claude:** go to `https://claude.ai` → find `sessionKey` → copy Value
### When Tokens Expire
When a token expires you'll see a `401 Unauthorized` error. To refresh:
- Re-run the `auth` wizard: `ai-chat-exporter auth`
- Or manually update the value in your `.env` file
---
## The `auth` Command
The easiest way to configure tokens is the interactive wizard:
```bash
ai-chat-exporter auth
```
This walks you through finding your token, validates it, shows the expiry date (ChatGPT only), and offers to write it to your `.env` automatically. Tokens are never echoed to the terminal.
---
## `.env` Setup ## `.env` Setup
Copy `.env.example` to `.env` and fill in your values: Copy `.env.example` to `.env` and fill in your values:
@@ -255,7 +201,7 @@ cp .env.example .env
A topic on public ntfy.sh is readable by anyone who knows its name, so A topic on public ntfy.sh is readable by anyone who knows its name, so
notifications carry per-provider counts and a machine name only — never notifications carry per-provider counts and a machine name only — never
conversation titles. See [Getting notified](#getting-notified). conversation titles. See [Getting notified](docs/scheduling.md#getting-notified).
### Cache & logging ### Cache & logging
@@ -267,193 +213,6 @@ conversation titles. See [Getting notified](#getting-notified).
--- ---
## ChatGPT Projects
ChatGPT project conversations are stored separately from your main conversation list and require extra configuration.
### Finding your project IDs
The quickest way is to let the exporter find them:
```
ai-chat-exporter projects
```
It lists every project your conversations actually belong to, marks which are
missing from `.env`, and prints a paste-ready `CHATGPT_PROJECT_IDS=` line
(`--write` updates `.env` for you). If your ChatGPT account does not include
the project on conversation summaries, add `--deep` and it reads each
conversation's detail instead — slower, one request per conversation, but
complete.
Why it matters: project attribution is resolved from each conversation's own
`gizmo_id`, so exports file correctly whether or not a project is configured.
But the *listing* pass still needs `CHATGPT_PROJECT_IDS` — conversations that
live only inside a project never appear in the default conversation list, so an
unlisted project's chats are never fetched at all. Every export run also names
any unconfigured project it encounters.
To find them by hand instead:
1. Open ChatGPT and click a Project in the left sidebar
2. Look at the browser URL — it will look like:
`https://chatgpt.com/g/g-p-68c2b2b3037c8191890036fb4ae3ed9f-my-project/project`
3. Copy the `g-p-…` part (everything up to but not including the slug after the second `-`)
Add all your project IDs to `.env` as a comma-separated list:
```
CHATGPT_PROJECT_IDS=g-p-68c2b2b3037c8191890036fb4ae3ed9f,g-p-anotherprojectid
```
The `auth` wizard can also guide you through this step interactively.
---
## Claude Code Sessions
The `claude-code` provider archives your local [Claude Code](https://claude.com/claude-code) agent transcripts — no tokens, no API, no ToS exposure. Sessions are read from `~/.claude/projects/`.
```bash
ai-chat-exporter export --provider claude-code
ai-chat-exporter joplin --provider claude-code
```
Exports are prose-only by default: your prompts and Claude's write-ups are kept, tool activity is grouped into one-line placeholders (`> 🔧 Tool output — 14 calls: Read ×9, Bash ×2 (86KB) — omitted`), and internal reasoning is dropped (counted in the run summary). Set `EXPORTER_HIDDEN_CONTENT=full` to keep everything. Sessions become notes under their own top-level **`AI-ClaudeCode`** Joplin notebook, in a sub-notebook per launch folder. The provider is included in `--provider all` whenever a sessions directory exists.
**Subagents.** Claude Code stores subagent (Task-tool) transcripts as separate files under `<session>/subagents/`; each is folded into its parent session inline, as a collapsible `<details>` block labeled with the subagent's type and description (its own tool traffic is collapsed like the main dialogue).
**Repo tags.** Sessions launched from a workspace root all share one folder-named notebook, so titles carry the repos each session touched — `Resume StartWRT project work [start-technologies]` — for at-a-glance scanning and search. A file's repo is the git repository it lives in (nearest ancestor with a `.git`), resolved from the tool paths in the transcript, so work is tagged wherever it happened — even across workspaces — and config/one-off files are ignored (they aren't repos). Repos are frequency-ordered and capped at 3. To never tag specific repos, set `CLAUDE_CODE_REPO_TAG_IGNORE` (comma-separated). Note this reads your current git layout, so a repo you later delete or move drops from the tag on re-export.
**Multiple locations.** By default the provider scans `~/.claude/projects/` plus `$CLAUDE_CONFIG_DIR/projects` when `CLAUDE_CONFIG_DIR` is set. To scan additional roots (e.g. other machines' sessions copied onto this box), set `CLAUDE_CODE_DIR` to a `:`-separated list:
```bash
CLAUDE_CODE_DIR="$HOME/.claude/projects:/mnt/backup/laptop/.claude/projects"
```
Sessions from all roots are merged by folder (no per-machine label); if the same session UUID appears in two roots, the newer copy wins. Note the exporter only sees this machine's disk and cannot recover sessions Claude Code has already pruned — run it regularly.
---
## Codex Sessions
The `codex` provider archives your local [Codex CLI](https://chatgpt.com/codex) agent transcripts — same deal as Claude Code: no tokens, no API, no ToS exposure. Rollout files are read from `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
```bash
ai-chat-exporter export --provider codex
ai-chat-exporter joplin --provider codex
```
Exports are prose-only by default, with the same placeholder format as Claude Code, into their own top-level **`AI-Codex`** Joplin notebook. Repo tags work the same way (`CODEX_REPO_TAG_IGNORE` to suppress), and additional roots can be scanned with `CODEX_DIR` (`:`-separated); `CODEX_HOME`'s `sessions/` is picked up automatically when that variable is set.
Three things differ from Claude Code, all forced by how Codex stores its data:
**Reasoning cannot be exported.** Codex encrypts it at rest — every reasoning record carries `encrypted_content` with no plaintext summary in any layer of the file. It is always dropped and counted; `EXPORTER_HIDDEN_CONTENT=full` cannot bring it back.
**Incomplete tool calls are reported.** Codex writes each session twice in one file: its own typed items (what actually ran) and the raw model-facing wire format (everything attempted). The provider reads the typed layer — it is already decoded, and it omits harness plumbing that would otherwise need stripping — but cross-checks the raw layer for calls that never produced a result, so placeholders read `3 calls: exec_command ×3 (+2 did not complete)`. Those are commands that failed to launch, that you aborted, or that were still running when the turn ended.
**Cloud tasks are out of scope.** `codex cloud` tasks run server-side and are reachable at `chatgpt.com/backend-api/api/codex/tasks`, but local CLI sessions are never uploaded there, so the cloud API is not an alternative source for these transcripts and this provider stays entirely offline. If you start using `codex cloud exec`, those transcripts would be cloud-only and would need separate work.
---
## Scheduling a Daily Run
`sync` chains `export` then `joplin` in one invocation, which is what a scheduler
wants — one command, and a meaningful exit code so a failed run is visible
instead of silent.
```bash
./ai-chat-exporter sync # every configured provider
./ai-chat-exporter sync --provider codex # just one
```
### Linux (systemd user timer)
```bash
./scheduling/install-systemd-timer.sh --provider claude-code --provider codex
./scheduling/install-systemd-timer.sh --uninstall
```
Defaults to 09:00 daily (`--time 21:30` to change). `Persistent=true` means a
machine that was off at the scheduled time runs the archive at next boot rather
than skipping the day. To archive while logged out, `loginctl enable-linger $USER`.
Check on it with `systemctl --user list-timers aichat-sync.timer` and
`journalctl --user -u aichat-sync.service -n 50`.
### Windows (Task Scheduler)
```powershell
.\scheduling\Register-AiChatSyncTask.ps1 -Provider chatgpt,claude
.\scheduling\Register-AiChatSyncTask.ps1 -Unregister
```
Per-user task, no admin rights needed. `-StartWhenAvailable` is the counterpart
of systemd's `Persistent=true`.
### What to know before you rely on it
**List only the providers that work unattended on that machine.** The local
providers (`claude-code`, `codex`) need no credentials and always work. The web
providers depend on a session token that expires and can only be refreshed by
hand via DevTools — so on a machine where that token is stale, scheduling them
means a failed run every single day, which is a good way to learn to ignore
failures you actually want to see. That is why `--provider` is repeatable in both
installers: schedule the coding machine for `claude-code` + `codex`, the browser
machine for `chatgpt` + `claude`.
**Both installers pass `--joplin-optional`.** If Joplin desktop isn't running,
the sync warns instead of failing: the export has already captured the local
transcripts (the part that can disappear), and the notes are rebuilt from the
cache on the next run that finds Joplin up.
**One provider failing does not skip the rest.** Both installers loop over the
providers in a single action rather than one action each, because systemd
`oneshot` stops at the first failing `ExecStart` and Task Scheduler reports only
the last action's result. Every provider is attempted; the run still exits
non-zero if any failed.
### Getting notified
A scheduled run is silent by default. Output goes to three pull-only places: the
exporter's own log (`cache/logs/exporter.log`), the systemd journal on Linux
(`journalctl --user -u aichat-sync.service`), and Task Scheduler's
`LastTaskResult` on Windows.
To have runs report back, set an [ntfy](https://ntfy.sh) topic in `.env`:
```bash
NTFY_TOPIC=my-archive-topic
```
Then check it works before waiting on a scheduled run:
```bash
ai-chat-exporter notify # show settings
ai-chat-exporter notify --test # send a test push
```
`sync` pushes a result whenever a topic is configured — a success carries the
per-provider counts at low/default priority, a failure carries the reason at
high priority with an alert tag, so a failed archive is distinguishable from a
quiet one on your phone. `NTFY_NOTIFY=failure` notifies only on failure; `off`
disables it; `--notify` / `--no-notify` override per run.
The message includes the **machine name**, which matters because both machines
archive into one topic. It contains counts only — never conversation titles. A
topic on public ntfy.sh is readable by anyone who knows its name, so if you want
it private, self-host (`NTFY_SERVER`) or use an access-controlled topic with
`NTFY_TOKEN`.
A notification is never fatal: if ntfy is unreachable, the run logs a warning and
still reports its real exit code.
**Acknowledge the ToS notice once, interactively.** It's stored in the cache
manifest per machine. Until then a scheduled run exits 1 with an explanation
rather than hanging on a prompt no one can answer.
---
## Output Structure ## Output Structure
All exported files go under `EXPORT_DIR`. The folder structure maps directly to Joplin notebooks. All exported files go under `EXPORT_DIR`. The folder structure maps directly to Joplin notebooks.
@@ -503,344 +262,34 @@ Each provider+project combination maps to a flat Joplin notebook created automat
--- ---
## CLI Reference ## Documentation
### Global flags This page covers installing and configuring the exporter. Everything else is in
`docs/`:
```
--verbose / -v DEBUG output to console - **[Providers and Tokens](docs/providers.md)** — getting a session token out of
--quiet / -q WARNING and above only DevTools, what each one's lifetime is and how expiry is detected, the `auth`
--debug DEBUG + full tracebacks + redacted API response bodies wizard, and the setup each provider needs: ChatGPT Projects (which are not in
--no-log-file Disable file logging the default conversation listing), Claude Code sessions, Codex sessions.
--version Print version and exit - **[CLI Reference](docs/cli.md)** — `auth`, `doctor`, `export`, `list`,
``` `joplin`, `sync`, `notify`, `prune`, `projects`, `canary`, `cache`, with the
flags and examples for each.
### `auth` — Interactive token setup - **[Running on a Schedule](docs/scheduling.md)** — the systemd user timer, the
Windows scheduled task, what to know before relying on either, and how run
```bash results reach your phone.
ai-chat-exporter auth # manual wizard (DevTools flow) - **[Troubleshooting](docs/troubleshooting.md)** — how the cache decides what to
``` re-export, every error message the tool produces and what it means, media
download failures, and what to include in a bug report.
Guided wizard to find and save session tokens and ChatGPT project IDs. Detects OS and shows the correct DevTools shortcut, then writes the values to `.env`.
### `doctor` — Health check
```bash
ai-chat-exporter doctor
```
Checks: token presence, JWT validity and expiry, directory permissions, disk space, live API reachability. Exits with code 0 if all pass, 1 if any fail.
### `export` — Export conversations
```bash
# Export everything (new/updated only)
ai-chat-exporter export
# Single provider
ai-chat-exporter export --provider claude
# JSON output
ai-chat-exporter export --format json
# Both Markdown and JSON
ai-chat-exporter export --format both
# Only conversations updated since a date
ai-chat-exporter export --since 2024-06-01
# Only conversations in a specific project (case-insensitive substring)
ai-chat-exporter export --project "learning python"
# Only conversations outside any project
ai-chat-exporter export --project none
# Write to a custom directory
ai-chat-exporter export --output /path/to/my/notes
# Preview without writing anything
ai-chat-exporter export --dry-run
```
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--format [markdown|json|both]`, `--output PATH`, `--since YYYY-MM-DD`, `--project NAME`, `--hidden-content [full|placeholder|omit]`, `--download-media [images|all|off]`, `--max-conversations N`, `--force`, `--dry-run`
**Re-rendering the whole archive after an upgrade.** New formatting or features (collapse policy, media downloads) only change conversations as they're re-exported. To re-render everything you already have, use `--force` — it re-exports every conversation even if unchanged, **without** `cache --clear`, so your Joplin note links are preserved (a later `joplin` run updates the existing notes instead of duplicating them).
A force re-render runs as a tracked "campaign": each run re-renders the least-recently-exported conversations, the "still to go" count shrinks each run, and finished providers do no further work. The simplest approach is one uncapped pass:
```bash
ai-chat-exporter export --force # re-renders everything in one paced pass
ai-chat-exporter joplin # update notes + upload media
```
Or spread the load with `--max-conversations`, re-running until it reports "Force re-render complete":
```bash
ai-chat-exporter export --force --max-conversations 50 # repeat until complete
```
### `list` — List conversations
```bash
# List all conversations for all providers
ai-chat-exporter list
# Single provider
ai-chat-exporter list --provider chatgpt
# Filter by project
ai-chat-exporter list --project "learning python"
# Only conversations outside any project
ai-chat-exporter list --project none
```
Fetches and displays all conversations without exporting them. Useful for verifying what the tool can see before running an export.
### `joplin` — Sync to Joplin
```bash
# Sync all pending conversations to Joplin
ai-chat-exporter joplin
# Preview what would be synced without sending anything
ai-chat-exporter joplin --dry-run
# Sync a single provider
ai-chat-exporter joplin --provider chatgpt
# Sync only conversations in a specific project
ai-chat-exporter joplin --project "learning python"
# Sync only conversations outside any project
ai-chat-exporter joplin --project none
```
Reads the local export cache and pushes each exported Markdown file to Joplin as a note. Notebooks are created automatically. Re-running is safe — notes are updated (not duplicated).
**Prerequisites:**
1. Run `export` first to generate the Markdown files
2. Open Joplin → Tools → Options → Web Clipper → enable the service
3. Copy the Authorization token and add `JOPLIN_API_TOKEN=<token>` to your `.env`
4. Joplin desktop must be open when you run this command
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--project NAME`, `--dry-run`
### `sync` — Export and sync in one run
```bash
# The whole archive run: export, then push to Joplin
ai-chat-exporter sync
# One provider
ai-chat-exporter sync --provider codex
# Export only; don't touch Joplin
ai-chat-exporter sync --skip-joplin
# Joplin being closed is a warning, not a failure (used by the schedulers)
ai-chat-exporter sync --joplin-optional
```
Equivalent to `export` followed by `joplin` with the same `--provider`. Intended
for scheduled runs — see [Scheduling a Daily Run](#scheduling-a-daily-run).
Unlike the individual commands, `sync` sets a **meaningful exit code**: non-zero
if any conversation failed to export or any note failed to sync. A provider whose
listing call fails outright (an expired web session token being the usual cause)
counts its whole batch as failed. A provider that is simply unconfigured, or that
had nothing new, is ordinary success. `export` on its own always exits 0, which
is fine when you're reading the summary table and useless to a scheduler.
`--joplin-optional` downgrades an unreachable Joplin to a warning: the export has
already captured the local transcripts, and the notes are rebuilt from the cache
by the next run that finds Joplin open.
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--since YYYY-MM-DD`, `--hidden-content [full|placeholder|omit]`, `--max-conversations N`, `--skip-joplin`, `--joplin-optional`, `--notify/--no-notify`, `--dry-run`
Note this is a deliberate subset of `export`'s options — `--format`, `--output`,
`--project`, `--download-media` and `--force` are not passed through. Use
`export` directly for those. (`--download-media` still applies from `.env`; the
flag is only a per-run override.)
### `notify` — Push-notification settings and test
```bash
# Show the current settings
ai-chat-exporter notify
# Send a test push to confirm the topic works
ai-chat-exporter notify --test
```
Shows the resolved ntfy configuration and which machine name will appear in the
title. See [Getting notified](#getting-notified) for what a scheduled run sends.
Options: `--test`
### `prune` — Delete stale export files
```bash
# Preview what would be deleted
ai-chat-exporter prune --dry-run
# Delete (asks for confirmation; -y skips the prompt)
ai-chat-exporter prune
```
Deletes export files no longer referenced by the cache manifest — leftovers
from old folder layouts or fixed bugs that would otherwise double-sync into
Joplin. Refuses to run when the manifest is empty (e.g. right after
`cache --clear`) so it can never wipe a freshly cleared archive. The `doctor`
command separately verifies that every manifest entry's file exists on disk.
### `projects` — Discover ChatGPT project IDs
```bash
# List the projects your conversations belong to
ai-chat-exporter projects
# Also inspect conversations whose listing entry doesn't name a project
ai-chat-exporter projects --deep
# Write the discovered IDs straight into .env
ai-chat-exporter projects --write
```
`CHATGPT_PROJECT_IDS` is maintained by hand, and a project missing from it is
invisible to the listing pass — conversations that live *only* inside that
project are never fetched at all. This reports every project your conversations
belong to, marks the ones absent from `.env`, and prints a paste-ready line.
`--deep` fetches each conversation's detail when the listing doesn't name its
project: complete, but one request per conversation, so it's slow.
Options: `--deep`, `--write`
### `canary` — Check for provider API drift
```bash
ai-chat-exporter canary
ai-chat-exporter canary --provider chatgpt
```
The web providers are undocumented internal APIs that can change shape without
notice, and the failure mode is silent — a renamed field means content is
quietly dropped rather than an error being raised. The canary fetches one
listing page and one conversation per provider and asserts only the fields the
normalizer actually depends on.
Findings are `ERROR` (a load-bearing field is missing or mistyped — the parser
will break or silently lose data) or `WARN` (something unfamiliar appeared;
worth investigating, not necessarily broken). **Exits non-zero on any ERROR**,
so it can be scheduled or run in CI. Local providers have no remote schema and
are not probed.
Options: `--provider [chatgpt|claude|all]`
### `cache` — Manage the sync manifest
```bash
# Show statistics
ai-chat-exporter cache --show
# Clear all cached entries (forces full re-export next run)
ai-chat-exporter cache --clear
# Clear a single provider
ai-chat-exporter cache --clear --provider claude
```
---
## How the Cache Works
The cache manifest lives at `cache/manifest.json` (inside the install directory) and records every exported conversation: its title, project, `updated_at` timestamp, output file path, and (after Joplin sync) the Joplin note ID.
On every `export` run:
1. Fetch the full conversation list from the provider
2. Compare each conversation's `updated_at` against the manifest
3. Export only conversations that are new or have been updated
4. Write each successfully exported conversation to the manifest **immediately** (not batched)
On every `joplin` run:
1. Read the manifest to find conversations not yet synced to Joplin, or re-exported since last sync
2. Push each pending Markdown file to Joplin (create or update)
3. Store the Joplin note ID in the manifest so subsequent runs update rather than duplicate
**This design makes every run inherently resumable.** If the tool is interrupted for any reason — rate limit, network drop, Ctrl+C, crash — simply re-run the same command. It will skip already-processed conversations and continue from where it stopped.
To force a full re-export: `ai-chat-exporter cache --clear` then re-run export.
---
## Troubleshooting
### `401 Unauthorized`
Your session token has expired.
- Run `ai-chat-exporter auth` to get a new token interactively
- Or manually copy a fresh cookie value into your `.env` file
Note: Claude's `sessionKey` is an opaque string — the only way to know it's expired is the 401 error. ChatGPT JWTs have an `exp` claim that the `doctor` command can decode and display.
### `429 Rate Limited`
The tool automatically pauses, saves progress, and exits with a clear message showing how many conversations were exported vs remaining. Just re-run the same export command to resume — the cache picks up exactly where it left off.
### Joplin: "JOPLIN_API_TOKEN is not set"
You need to configure the token before running the `joplin` command:
1. Open Joplin desktop
2. Go to Tools → Options → Web Clipper
3. Enable the Web Clipper service
4. Copy the Authorization token shown on that page
5. Add `JOPLIN_API_TOKEN=<token>` to your `.env` file
### Joplin: "Joplin is not responding"
Joplin desktop must be running when you run the `joplin` command. The Web Clipper service shuts down when Joplin is closed.
### Joplin: "Joplin rejected the API token (HTTP 401)"
The token in `JOPLIN_API_TOKEN` doesn't match what Joplin expects. Get a fresh token from Joplin → Tools → Options → Web Clipper → Authorization token.
### Joplin: note timed out
If you see a timeout error, Joplin took longer than `JOPLIN_REQUEST_TIMEOUT` seconds (default: 30) to respond. Possible causes:
- The conversation is very large and Joplin is slow to index it
- Joplin is busy syncing or loading a large library
- Joplin has frozen — try restarting it
To increase the timeout: add `JOPLIN_REQUEST_TIMEOUT=60` to your `.env`.
### ChatGPT project conversations not appearing
Make sure you've added the project IDs to `CHATGPT_PROJECT_IDS` in your `.env`. See [ChatGPT Projects](#chatgpt-projects) for how to find them. Project conversations are not included in the default conversation listing — they must be fetched separately.
### Schema warnings in logs (`Unexpected API response shape`)
The provider's internal API may have changed. Run with `--debug`, sanitize the output (remove any personal content), and check the project's GitHub Issues for known fixes.
### Non-text content warnings
Since v0.4.0, rich content is preserved as typed blocks in the export. ChatGPT voice transcripts render as text and audio assets as `📎 File attached` placeholders with size and duration metadata. Anything the extractor doesn't recognise renders as a visible `> ⚠️ Unsupported content` block naming the type and observed keys, *and* increments a counter in the post-export summary so you can tell whether real content is being silently skipped.
### Embedded images and media
Since v0.6.0, image attachments are downloaded by default into a `media/` folder beside each export and inlined as `![](media/…)`. Audio and other files download only with `EXPORTER_DOWNLOAD_MEDIA=all`. On the next `joplin` run these are uploaded as Joplin resources so they render inside the note. Assets that have expired on the provider's side (older AI-generated images often do) keep their text placeholder and are tallied as `media failed` in the run summary — they're never fatal to the export. Set `EXPORTER_DOWNLOAD_MEDIA=off` to skip downloads entirely.
### Tool output / hidden context collapsed
Since v0.6.0, content that was invisible in the ChatGPT web UI is collapsed by default to one-line placeholders like `> 🔧 Tool output — file_search (15.0 KB) — omitted`. This is ChatGPT's file-retrieval tool re-injecting your attached files on every run — it can be 90% of a conversation's bytes while containing none of the dialogue. Custom Instructions collapse the same way (`> ℹ️ Hidden context`). The post-export summary lists every collapsed message by origin with the total KB omitted. To keep everything, set `EXPORTER_HIDDEN_CONTENT=full` in `.env` or pass `--hidden-content full`.
### Empty export / all conversations skipped
No new or updated conversations since your last run. To verify: `ai-chat-exporter cache --show`. To force a full re-export: `ai-chat-exporter cache --clear`.
### Filing a bug report
1. Run with `--debug`: `ai-chat-exporter export --debug 2>&1 | tee debug.log`
2. Remove any personal conversation content from `debug.log`
3. Open a GitHub Issue with the sanitized log and the exact command you ran
--- ---
## Future Work ## Future Work
See `FUTURE.md` for the full roadmap. Current priorities: See `FUTURE.md` for the full roadmap. Current priority:
- **A StartOS service** that centralises every machine's conversations into one - **A StartOS service** that centralises every machine's conversations into one
corpus and owns the Joplin connection, so each machine only has to upload corpus and owns the Joplin connection, so each machine only has to upload
(`FUTURE.md` §8) (`FUTURE.md` §1)
- **Splitting this README** into a short overview plus separate documents
--- ---
+254
View File
@@ -0,0 +1,254 @@
# CLI Reference
Every command, its flags and what it does.
← [README](../README.md) · [Providers and Tokens](providers.md) · [Scheduling](scheduling.md) · [Troubleshooting](troubleshooting.md)
---
## Global flags
```
--verbose / -v DEBUG output to console
--quiet / -q WARNING and above only
--debug DEBUG + full tracebacks + redacted API response bodies
--no-log-file Disable file logging
--version Print version and exit
```
## `auth` — Interactive token setup
```bash
ai-chat-exporter auth # manual wizard (DevTools flow)
```
Guided wizard to find and save session tokens and ChatGPT project IDs. Detects OS and shows the correct DevTools shortcut, then writes the values to `.env`.
## `doctor` — Health check
```bash
ai-chat-exporter doctor
```
Checks: token presence, JWT validity and expiry, directory permissions, disk space, live API reachability. Exits with code 0 if all pass, 1 if any fail.
## `export` — Export conversations
```bash
# Export everything (new/updated only)
ai-chat-exporter export
# Single provider
ai-chat-exporter export --provider claude
# JSON output
ai-chat-exporter export --format json
# Both Markdown and JSON
ai-chat-exporter export --format both
# Only conversations updated since a date
ai-chat-exporter export --since 2024-06-01
# Only conversations in a specific project (case-insensitive substring)
ai-chat-exporter export --project "learning python"
# Only conversations outside any project
ai-chat-exporter export --project none
# Write to a custom directory
ai-chat-exporter export --output /path/to/my/notes
# Preview without writing anything
ai-chat-exporter export --dry-run
```
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--format [markdown|json|both]`, `--output PATH`, `--since YYYY-MM-DD`, `--project NAME`, `--hidden-content [full|placeholder|omit]`, `--download-media [images|all|off]`, `--max-conversations N`, `--force`, `--dry-run`
**Re-rendering the whole archive after an upgrade.** New formatting or features (collapse policy, media downloads) only change conversations as they're re-exported. To re-render everything you already have, use `--force` — it re-exports every conversation even if unchanged, **without** `cache --clear`, so your Joplin note links are preserved (a later `joplin` run updates the existing notes instead of duplicating them).
A force re-render runs as a tracked "campaign": each run re-renders the least-recently-exported conversations, the "still to go" count shrinks each run, and finished providers do no further work. The simplest approach is one uncapped pass:
```bash
ai-chat-exporter export --force # re-renders everything in one paced pass
ai-chat-exporter joplin # update notes + upload media
```
Or spread the load with `--max-conversations`, re-running until it reports "Force re-render complete":
```bash
ai-chat-exporter export --force --max-conversations 50 # repeat until complete
```
## `list` — List conversations
```bash
# List all conversations for all providers
ai-chat-exporter list
# Single provider
ai-chat-exporter list --provider chatgpt
# Filter by project
ai-chat-exporter list --project "learning python"
# Only conversations outside any project
ai-chat-exporter list --project none
```
Fetches and displays all conversations without exporting them. Useful for verifying what the tool can see before running an export.
## `joplin` — Sync to Joplin
```bash
# Sync all pending conversations to Joplin
ai-chat-exporter joplin
# Preview what would be synced without sending anything
ai-chat-exporter joplin --dry-run
# Sync a single provider
ai-chat-exporter joplin --provider chatgpt
# Sync only conversations in a specific project
ai-chat-exporter joplin --project "learning python"
# Sync only conversations outside any project
ai-chat-exporter joplin --project none
```
Reads the local export cache and pushes each exported Markdown file to Joplin as a note. Notebooks are created automatically. Re-running is safe — notes are updated (not duplicated).
**Prerequisites:**
1. Run `export` first to generate the Markdown files
2. Open Joplin → Tools → Options → Web Clipper → enable the service
3. Copy the Authorization token and add `JOPLIN_API_TOKEN=<token>` to your `.env`
4. Joplin desktop must be open when you run this command
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--project NAME`, `--dry-run`
## `sync` — Export and sync in one run
```bash
# The whole archive run: export, then push to Joplin
ai-chat-exporter sync
# One provider
ai-chat-exporter sync --provider codex
# Export only; don't touch Joplin
ai-chat-exporter sync --skip-joplin
# Joplin being closed is a warning, not a failure (used by the schedulers)
ai-chat-exporter sync --joplin-optional
```
Equivalent to `export` followed by `joplin` with the same `--provider`. Intended
for scheduled runs — see [Scheduling a Daily Run](scheduling.md#scheduling-a-daily-run).
Unlike the individual commands, `sync` sets a **meaningful exit code**: non-zero
if any conversation failed to export or any note failed to sync. A provider whose
listing call fails outright (an expired web session token being the usual cause)
counts its whole batch as failed. A provider that is simply unconfigured, or that
had nothing new, is ordinary success. `export` on its own always exits 0, which
is fine when you're reading the summary table and useless to a scheduler.
`--joplin-optional` downgrades an unreachable Joplin to a warning: the export has
already captured the local transcripts, and the notes are rebuilt from the cache
by the next run that finds Joplin open.
Options: `--provider [chatgpt|claude|claude-code|codex|all]`, `--since YYYY-MM-DD`, `--hidden-content [full|placeholder|omit]`, `--max-conversations N`, `--skip-joplin`, `--joplin-optional`, `--notify/--no-notify`, `--dry-run`
Note this is a deliberate subset of `export`'s options — `--format`, `--output`,
`--project`, `--download-media` and `--force` are not passed through. Use
`export` directly for those. (`--download-media` still applies from `.env`; the
flag is only a per-run override.)
## `notify` — Push-notification settings and test
```bash
# Show the current settings
ai-chat-exporter notify
# Send a test push to confirm the topic works
ai-chat-exporter notify --test
```
Shows the resolved ntfy configuration and which machine name will appear in the
title. See [Getting notified](scheduling.md#getting-notified) for what a scheduled run sends.
Options: `--test`
## `prune` — Delete stale export files
```bash
# Preview what would be deleted
ai-chat-exporter prune --dry-run
# Delete (asks for confirmation; -y skips the prompt)
ai-chat-exporter prune
```
Deletes export files no longer referenced by the cache manifest — leftovers
from old folder layouts or fixed bugs that would otherwise double-sync into
Joplin. Refuses to run when the manifest is empty (e.g. right after
`cache --clear`) so it can never wipe a freshly cleared archive. The `doctor`
command separately verifies that every manifest entry's file exists on disk.
## `projects` — Discover ChatGPT project IDs
```bash
# List the projects your conversations belong to
ai-chat-exporter projects
# Also inspect conversations whose listing entry doesn't name a project
ai-chat-exporter projects --deep
# Write the discovered IDs straight into .env
ai-chat-exporter projects --write
```
`CHATGPT_PROJECT_IDS` is maintained by hand, and a project missing from it is
invisible to the listing pass — conversations that live *only* inside that
project are never fetched at all. This reports every project your conversations
belong to, marks the ones absent from `.env`, and prints a paste-ready line.
`--deep` fetches each conversation's detail when the listing doesn't name its
project: complete, but one request per conversation, so it's slow.
Options: `--deep`, `--write`
## `canary` — Check for provider API drift
```bash
ai-chat-exporter canary
ai-chat-exporter canary --provider chatgpt
```
The web providers are undocumented internal APIs that can change shape without
notice, and the failure mode is silent — a renamed field means content is
quietly dropped rather than an error being raised. The canary fetches one
listing page and one conversation per provider and asserts only the fields the
normalizer actually depends on.
Findings are `ERROR` (a load-bearing field is missing or mistyped — the parser
will break or silently lose data) or `WARN` (something unfamiliar appeared;
worth investigating, not necessarily broken). **Exits non-zero on any ERROR**,
so it can be scheduled or run in CI. Local providers have no remote schema and
are not probed.
Options: `--provider [chatgpt|claude|all]`
## `cache` — Manage the sync manifest
```bash
# Show statistics
ai-chat-exporter cache --show
# Clear all cached entries (forces full re-export next run)
ai-chat-exporter cache --clear
# Clear a single provider
ai-chat-exporter cache --clear --provider claude
```
+171
View File
@@ -0,0 +1,171 @@
# Providers and Tokens
How to obtain and refresh the session tokens for the two web providers, and the
per-provider setup for ChatGPT Projects, Claude Code and Codex.
← [README](../README.md) · [Configuration](../README.md#env-setup) · [CLI Reference](cli.md) · [Scheduling](scheduling.md) · [Troubleshooting](troubleshooting.md)
---
## Getting Your Session Tokens
Session tokens are how your browser stays logged in. This tool uses them to access your chat history on your behalf.
Tokens are entered manually — copy them from your browser's DevTools and run the wizard:
```bash
ai-chat-exporter auth
```
The wizard detects your OS, shows the correct DevTools shortcut, and writes the values to `.env` without echoing them to the terminal.
> **Why no auto-extraction from the browser?** Modern Chromium browsers (Chrome 127+, current Brave) encrypt cookies on Windows with **App-Bound Encryption**: the key is bound to the browser through a SYSTEM-level service, so reading cookies off disk requires Administrator rights and a PsExec-style SYSTEM impersonation that antivirus flags as credential theft — and it still fails on Brave specifically. There is no reliable, non-invasive way to pull these cookies automatically, so the manual DevTools flow below is the supported path.
### Token Lifetimes
| Provider | Cookie Name | Lifetime | Expiry Detection |
|----------|-------------|----------|-----------------|
| ChatGPT | `__Secure-next-auth.session-token` (split into `.0` + `.1` when over ~4KB) | refresh ~weekly | `error` field of `/api/auth/session` — `doctor` reports "ChatGPT token active". The token is an encrypted JWE, so its `exp` is **not** readable client-side, and the `expires` field is a misleading rolling window; the `error` (`RefreshAccessTokenError` when dead) is the honest signal. |
| Claude | `sessionKey` | ~30 days | Opaque token — only detectable from an API rejection. claude.ai answers an invalid session with **403** `permission_error` / `account_session_invalid`, **not** 401; `doctor` reports it on the "Claude API reachable" row. |
### Finding Tokens in Chrome DevTools
1. Open the provider's website and make sure you're logged in
2. Press **F12** (Windows/Linux) or **Cmd+Option+I** (macOS) to open DevTools
3. Click the **Application** tab
4. In the left panel, expand **Cookies** and click the site URL
5. Find the cookie by name and copy its **Value**
**ChatGPT:** go to `https://chatgpt.com` → find the session token cookie. You will
see **one of two layouts**, depending on how large your session token is:
- **One cookie**, `__Secure-next-auth.session-token` — copy Value → `CHATGPT_SESSION_TOKEN`, and leave `CHATGPT_SESSION_TOKEN_1` empty.
- **Two cookies**, `__Secure-next-auth.session-token.0` and `.1` — copy `.0` (starts with `eyJ`) → `CHATGPT_SESSION_TOKEN`, and `.1` → `CHATGPT_SESSION_TOKEN_1`.
Chrome splits a cookie only when it exceeds ~4KB, so a larger session is chunked
and a smaller one is not — the same account can differ from machine to machine.
`CHATGPT_SESSION_TOKEN_1` is optional; both layouts authenticate, because the
server reassembles a complete value sent under the `.0` name.
What does *not* work is sending a **partial** chunk — `.0` on its own when a `.1`
exists. That fails silently: `/api/auth/session` answers HTTP 200 with no
`accessToken` rather than an error. If you see two cookies, copy both.
**Claude:** go to `https://claude.ai` → find `sessionKey` → copy Value
### When Tokens Expire
An expired token shows up as an authentication error naming the cookie to
refresh and how. The status differs by provider — ChatGPT reports 401, while
claude.ai reports **403 "Invalid authorization"** (`account_session_invalid`) —
so don't read a 403 from Claude as a permissions problem with your account.
To refresh:
- Re-run the `auth` wizard: `ai-chat-exporter auth`
- Or manually update the value in your `.env` file
`ai-chat-exporter doctor` is the quickest check: the "token set" rows only test
that a value is present, so an expired credential passes those and fails on the
"API reachable" row.
---
## The `auth` Command
The easiest way to configure tokens is the interactive wizard:
```bash
ai-chat-exporter auth
```
This walks you through finding your token, validates it, shows the expiry date (ChatGPT only), and offers to write it to your `.env` automatically. Tokens are never echoed to the terminal.
---
## ChatGPT Projects
ChatGPT project conversations are stored separately from your main conversation list and require extra configuration.
### Finding your project IDs
The quickest way is to let the exporter find them:
```
ai-chat-exporter projects
```
It lists every project your conversations actually belong to, marks which are
missing from `.env`, and prints a paste-ready `CHATGPT_PROJECT_IDS=` line
(`--write` updates `.env` for you). If your ChatGPT account does not include
the project on conversation summaries, add `--deep` and it reads each
conversation's detail instead — slower, one request per conversation, but
complete.
Why it matters: project attribution is resolved from each conversation's own
`gizmo_id`, so exports file correctly whether or not a project is configured.
But the *listing* pass still needs `CHATGPT_PROJECT_IDS` — conversations that
live only inside a project never appear in the default conversation list, so an
unlisted project's chats are never fetched at all. Every export run also names
any unconfigured project it encounters.
To find them by hand instead:
1. Open ChatGPT and click a Project in the left sidebar
2. Look at the browser URL — it will look like:
`https://chatgpt.com/g/g-p-68c2b2b3037c8191890036fb4ae3ed9f-my-project/project`
3. Copy the `g-p-…` part (everything up to but not including the slug after the second `-`)
Add all your project IDs to `.env` as a comma-separated list:
```
CHATGPT_PROJECT_IDS=g-p-68c2b2b3037c8191890036fb4ae3ed9f,g-p-anotherprojectid
```
The `auth` wizard can also guide you through this step interactively.
---
## Claude Code Sessions
The `claude-code` provider archives your local [Claude Code](https://claude.com/claude-code) agent transcripts — no tokens, no API, no ToS exposure. Sessions are read from `~/.claude/projects/`.
```bash
ai-chat-exporter export --provider claude-code
ai-chat-exporter joplin --provider claude-code
```
Exports are prose-only by default: your prompts and Claude's write-ups are kept, tool activity is grouped into one-line placeholders (`> 🔧 Tool output — 14 calls: Read ×9, Bash ×2 (86KB) — omitted`), and internal reasoning is dropped (counted in the run summary). Set `EXPORTER_HIDDEN_CONTENT=full` to keep everything. Sessions become notes under their own top-level **`AI-ClaudeCode`** Joplin notebook, in a sub-notebook per launch folder. The provider is included in `--provider all` whenever a sessions directory exists.
**Subagents.** Claude Code stores subagent (Task-tool) transcripts as separate files under `<session>/subagents/`; each is folded into its parent session inline, as a collapsible `<details>` block labeled with the subagent's type and description (its own tool traffic is collapsed like the main dialogue).
**Repo tags.** Sessions launched from a workspace root all share one folder-named notebook, so titles carry the repos each session touched — `Resume StartWRT project work [start-technologies]` — for at-a-glance scanning and search. A file's repo is the git repository it lives in (nearest ancestor with a `.git`), resolved from the tool paths in the transcript, so work is tagged wherever it happened — even across workspaces — and config/one-off files are ignored (they aren't repos). Repos are frequency-ordered and capped at 3. To never tag specific repos, set `CLAUDE_CODE_REPO_TAG_IGNORE` (comma-separated). Note this reads your current git layout, so a repo you later delete or move drops from the tag on re-export.
**Multiple locations.** By default the provider scans `~/.claude/projects/` plus `$CLAUDE_CONFIG_DIR/projects` when `CLAUDE_CONFIG_DIR` is set. To scan additional roots (e.g. other machines' sessions copied onto this box), set `CLAUDE_CODE_DIR` to a `:`-separated list:
```bash
CLAUDE_CODE_DIR="$HOME/.claude/projects:/mnt/backup/laptop/.claude/projects"
```
Sessions from all roots are merged by folder (no per-machine label); if the same session UUID appears in two roots, the newer copy wins. Note the exporter only sees this machine's disk and cannot recover sessions Claude Code has already pruned — run it regularly.
---
## Codex Sessions
The `codex` provider archives your local [Codex CLI](https://chatgpt.com/codex) agent transcripts — same deal as Claude Code: no tokens, no API, no ToS exposure. Rollout files are read from `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
```bash
ai-chat-exporter export --provider codex
ai-chat-exporter joplin --provider codex
```
Exports are prose-only by default, with the same placeholder format as Claude Code, into their own top-level **`AI-Codex`** Joplin notebook. Repo tags work the same way (`CODEX_REPO_TAG_IGNORE` to suppress), and additional roots can be scanned with `CODEX_DIR` (`:`-separated); `CODEX_HOME`'s `sessions/` is picked up automatically when that variable is set.
Three things differ from Claude Code, all forced by how Codex stores its data:
**Reasoning cannot be exported.** Codex encrypts it at rest — every reasoning record carries `encrypted_content` with no plaintext summary in any layer of the file. It is always dropped and counted; `EXPORTER_HIDDEN_CONTENT=full` cannot bring it back.
**Incomplete tool calls are reported.** Codex writes each session twice in one file: its own typed items (what actually ran) and the raw model-facing wire format (everything attempted). The provider reads the typed layer — it is already decoded, and it omits harness plumbing that would otherwise need stripping — but cross-checks the raw layer for calls that never produced a result, so placeholders read `3 calls: exec_command ×3 (+2 did not complete)`. Those are commands that failed to launch, that you aborted, or that were still running when the turn ended.
**Cloud tasks are out of scope.** `codex cloud` tasks run server-side and are reachable at `chatgpt.com/backend-api/api/codex/tasks`, but local CLI sessions are never uploaded there, so the cloud API is not an alternative source for these transcripts and this provider stays entirely offline. If you start using `codex cloud exec`, those transcripts would be cloud-only and would need separate work.
+124
View File
@@ -0,0 +1,124 @@
# Running on a Schedule
Installing the daily run on Linux and Windows, what to know before relying on
it, and how results reach your phone.
← [README](../README.md) · [Providers and Tokens](providers.md) · [CLI Reference](cli.md) · [Troubleshooting](troubleshooting.md)
---
## Scheduling a Daily Run
`sync` chains `export` then `joplin` in one invocation, which is what a scheduler
wants — one command, and a meaningful exit code so a failed run is visible
instead of silent.
```bash
./ai-chat-exporter sync # every configured provider
./ai-chat-exporter sync --provider codex # just one
```
### Linux (systemd user timer)
```bash
./scheduling/install-systemd-timer.sh --provider claude-code --provider codex
./scheduling/install-systemd-timer.sh --uninstall
```
Defaults to 09:00 daily (`--time 21:30` to change). `Persistent=true` means a
machine that was off at the scheduled time runs the archive at next boot rather
than skipping the day. To archive while logged out, `loginctl enable-linger $USER`.
Check on it with `systemctl --user list-timers aichat-sync.timer` and
`journalctl --user -u aichat-sync.service -n 50`.
### Windows (Task Scheduler)
```powershell
.\scheduling\Register-AiChatSyncTask.ps1 -Provider chatgpt,claude
.\scheduling\Register-AiChatSyncTask.ps1 -Unregister
```
Per-user task, no admin rights needed. `-StartWhenAvailable` is the counterpart
of systemd's `Persistent=true`. The task runs `scheduling\run-sync.ps1` with its
window hidden; Task Scheduler keeps no output, so each run's output is appended
to `cache\logs\scheduled-sync.log` (rolled over to `.1` past 1 MB).
Check on it with `Get-ScheduledTaskInfo -TaskName AiChatExporterSync` (look at
`LastTaskResult`: 0 is success) and
`Get-Content cache\logs\scheduled-sync.log -Tail 50`.
### What to know before you rely on it
**List only the providers that work unattended on that machine.** The local
providers (`claude-code`, `codex`) need no credentials and always work. The web
providers depend on a session token that expires and can only be refreshed by
hand via DevTools — so on a machine where that token is stale, scheduling them
means a failed run every single day, which is a good way to learn to ignore
failures you actually want to see. That is why `--provider` is repeatable in both
installers: schedule the coding machine for `claude-code` + `codex`, the browser
machine for `chatgpt` + `claude`.
**Both installers pass `--joplin-optional`.** If Joplin desktop isn't running,
the sync warns instead of failing: the export has already captured the local
transcripts (the part that can disappear), and the notes are rebuilt from the
cache on the next run that finds Joplin up.
**One provider failing does not skip the rest.** Both installers loop over the
providers in a single action rather than one action each, because systemd
`oneshot` stops at the first failing `ExecStart` and Task Scheduler reports only
the last action's result. Every provider is attempted; the run still exits
non-zero if any failed. The loop is `scheduling/run-sync.sh` on Linux (the
unit's `ExecStart`) and `scheduling\run-sync.ps1` on Windows (the task's action).
### Getting notified
A scheduled run is silent by default. Output goes to three pull-only places: the
exporter's own log (`cache/logs/exporter.log`), the systemd journal on Linux
(`journalctl --user -u aichat-sync.service`), and on Windows Task Scheduler's
`LastTaskResult` plus `cache\logs\scheduled-sync.log`.
To have runs report back, set an [ntfy](https://ntfy.sh) topic in `.env`:
```bash
NTFY_TOPIC=my-archive-topic
```
Then check it works before waiting on a scheduled run:
```bash
ai-chat-exporter notify # show settings
ai-chat-exporter notify --test # send a test push
```
`sync` pushes a result whenever a topic is configured — a success carries the
per-provider counts at low/default priority, a failure carries the reason at
high priority with an alert tag, so a failed archive is distinguishable from a
quiet one on your phone. `NTFY_NOTIFY=failure` notifies only on failure; `off`
disables it; `--notify` / `--no-notify` override per run.
`sync` can only push from the end of a run it finished. A crash, an exit before
the sync starts (the ToS gate, a cache error) or a launcher that can't build its
venv sends nothing — and since each provider pushes separately, the ones that
succeeded still say "OK", so a dead provider looks like a quiet day. The
scheduler wrappers (`scheduling/run-sync.sh`, `scheduling\run-sync.ps1`) close
that gap: any run that exits non-zero without the
app having reported it gets a high-priority **FAILED** push naming the provider
and, for a crash, the exception's class (`claude-code: crashed (RecursionError)`)
— the class only, never its message, which can carry a conversation title. The
traceback is in the journal on Linux and in `cache\logs\scheduled-sync.log` on
Windows.
The message includes the **machine name**, which matters because both machines
archive into one topic. It contains counts only — never conversation titles. A
topic on public ntfy.sh is readable by anyone who knows its name, so if you want
it private, self-host (`NTFY_SERVER`) or use an access-controlled topic with
`NTFY_TOKEN`.
A notification is never fatal: if ntfy is unreachable, the run logs a warning and
still reports its real exit code.
**Acknowledge the ToS notice once, interactively.** It's stored in the cache
manifest per machine. Until then a scheduled run exits 1 with an explanation
rather than hanging on a prompt no one can answer.
+134
View File
@@ -0,0 +1,134 @@
# Troubleshooting
What the cache does, what each failure means, and how to file a bug report.
← [README](../README.md) · [Providers and Tokens](providers.md) · [CLI Reference](cli.md) · [Scheduling](scheduling.md)
---
## How the Cache Works
The cache manifest lives at `cache/manifest.json` (inside the install directory) and records every exported conversation: its title, project, `updated_at` timestamp, output file path, and (after Joplin sync) the Joplin note ID.
On every `export` run:
1. Fetch the full conversation list from the provider
2. Compare each conversation's `updated_at` against the manifest
3. Export only conversations that are new or have been updated
4. Write each successfully exported conversation to the manifest **immediately** (not batched)
On every `joplin` run:
1. Read the manifest to find conversations not yet synced to Joplin, or re-exported since last sync
2. Push each pending Markdown file to Joplin (create or update)
3. Store the Joplin note ID in the manifest so subsequent runs update rather than duplicate
**This design makes every run inherently resumable.** If the tool is interrupted for any reason — rate limit, network drop, Ctrl+C, crash — simply re-run the same command. It will skip already-processed conversations and continue from where it stopped.
A conversation whose assets failed transiently also carries `pending_media` (the
asset refs still missing) and `pending_media_attempts`. While `pending_media` is
set and the attempt cap is not reached, step 2 treats the conversation as stale
so the next run re-fetches those assets — see
[Embedded images and media](#embedded-images-and-media). A run that recovers them
clears the key.
To force a full re-export: `ai-chat-exporter cache --clear` then re-run export.
To re-export a single conversation, delete its entry from `cache/manifest.json`
and re-run. (There is deliberately no `cache --reset --conversation` flag; see
`FUTURE.md`.)
---
## Troubleshooting
### `Authentication failed` (401, or 403 from Claude)
Your session token has expired.
- Run `ai-chat-exporter auth` to get a new token interactively
- Or manually copy a fresh cookie value into your `.env` file
Note: neither token's expiry can be read client-side. Claude's `sessionKey` is an opaque string, and claude.ai reports an invalid one as **403** "Invalid authorization" (`account_session_invalid`), not 401. ChatGPT's token is an encrypted JWE; `doctor` reads the `error` field of `/api/auth/session` instead. See [When Tokens Expire](providers.md#when-tokens-expire).
### `429 Rate Limited`
The tool automatically pauses, saves progress, and exits with a clear message showing how many conversations were exported vs remaining. Just re-run the same export command to resume — the cache picks up exactly where it left off.
### `HTTP 500` / `503` on an asset download
The provider's asset storage, not your setup. Retries and the cross-run recovery
described in [Embedded images and media](#embedded-images-and-media) handle this;
the run reports `media failed` and the next run re-fetches. Measured 2026-10-20:
two assets lost this way to a 500 and a 503 both downloaded cleanly an hour later.
### Joplin: "JOPLIN_API_TOKEN is not set"
You need to configure the token before running the `joplin` command:
1. Open Joplin desktop
2. Go to Tools → Options → Web Clipper
3. Enable the Web Clipper service
4. Copy the Authorization token shown on that page
5. Add `JOPLIN_API_TOKEN=<token>` to your `.env` file
### Joplin: "Joplin is not responding"
Joplin desktop must be running when you run the `joplin` command. The Web Clipper service shuts down when Joplin is closed.
### Joplin: "Joplin rejected the API token (HTTP 401)"
The token in `JOPLIN_API_TOKEN` doesn't match what Joplin expects. Get a fresh token from Joplin → Tools → Options → Web Clipper → Authorization token.
### Joplin: note timed out
If you see a timeout error, Joplin took longer than `JOPLIN_REQUEST_TIMEOUT` seconds (default: 30) to respond. Possible causes:
- The conversation is very large and Joplin is slow to index it
- Joplin is busy syncing or loading a large library
- Joplin has frozen — try restarting it
To increase the timeout: add `JOPLIN_REQUEST_TIMEOUT=60` to your `.env`.
### ChatGPT project conversations not appearing
Make sure you've added the project IDs to `CHATGPT_PROJECT_IDS` in your `.env`. See [ChatGPT Projects](providers.md#chatgpt-projects) for how to find them. Project conversations are not included in the default conversation listing — they must be fetched separately.
### Schema warnings in logs (`Unexpected API response shape`)
The provider's internal API may have changed. Run with `--debug`, sanitize the output (remove any personal content), and check the project's GitHub Issues for known fixes.
### Non-text content warnings
Since v0.4.0, rich content is preserved as typed blocks in the export. ChatGPT voice transcripts render as text and audio assets as `📎 File attached` placeholders with size and duration metadata. Anything the extractor doesn't recognise renders as a visible `> ⚠️ Unsupported content` block naming the type and observed keys, *and* increments a counter in the post-export summary so you can tell whether real content is being silently skipped.
### Embedded images and media
Since v0.6.0, image attachments are downloaded by default into a `media/` folder beside each export and inlined as `![](media/…)`. Audio and other files download only with `EXPORTER_DOWNLOAD_MEDIA=all`. On the next `joplin` run these are uploaded as Joplin resources so they render inside the note. Set `EXPORTER_DOWNLOAD_MEDIA=off` to skip downloads entirely.
A failed asset **never fails the export** — the conversation is archived with its
text placeholder, and the failure is tallied as `media failed` in the run summary
and in the push notification. What happens next depends on *why* it failed:
| Bucket | Meaning | Retried? |
|---|---|---|
| `expired-or-missing` | 404 — the upload was deleted server-side | No. The bytes are gone. |
| `forbidden` | 403 — metadata survives, bytes do not (see the 2026-08-17 investigation in `CHANGELOG.md`) | No. |
| `download-error` | 5xx, a timeout, a network blip | **Yes** — within the run, then on up to 3 later runs |
| `skipped-provider-unwell` | the run's circuit breaker was open | **Yes**, on a later run |
| `unparseable-ref` / `write-error` | local problem, not the provider's | No |
**Within a run**, an asset download is two requests — one for a signed URL, one
for the bytes — and each is retried on a 5xx: immediately once, then after about
2, 4, 8 and 16 seconds with jitter, honouring `Retry-After` when the server sends
one. Retries for a single asset are capped by a wall-clock budget so a hanging
endpoint cannot consume the run's whole execution window.
**Across runs**, an asset that failed transiently is recorded against its
conversation in the cache manifest, and the next run re-exports that conversation
to re-fetch it. Only the missing asset is fetched — media already on disk is
reused, and Joplin resource IDs are keyed by path, so the re-sync uploads just
the new file. After 3 unsuccessful runs the asset is treated as lost and the
conversation stops being re-exported.
**Circuit breaker.** If 5 transient failures land back to back, asset downloads
stop for the rest of the run and the remaining assets are recorded for a later
attempt. A provider's storage having a bad day should cost one quiet morning, not
a run that overruns its two-hour scheduled window. Permanent failures never count
toward this — a deleted upload says nothing about the provider's health.
### Tool output / hidden context collapsed
Since v0.6.0, content that was invisible in the ChatGPT web UI is collapsed by default to one-line placeholders like `> 🔧 Tool output — file_search (15.0 KB) — omitted`. This is ChatGPT's file-retrieval tool re-injecting your attached files on every run — it can be 90% of a conversation's bytes while containing none of the dialogue. Custom Instructions collapse the same way (`> ℹ️ Hidden context`). The post-export summary lists every collapsed message by origin with the total KB omitted. To keep everything, set `EXPORTER_HIDDEN_CONTENT=full` in `.env` or pass `--hidden-content full`.
### Empty export / all conversations skipped
No new or updated conversations since your last run. To verify: `ai-chat-exporter cache --show`. To force a full re-export: `ai-chat-exporter cache --clear`.
### Filing a bug report
1. Run with `--debug`: `ai-chat-exporter export --debug 2>&1 | tee debug.log`
2. Remove any personal conversation content from `debug.log`
3. Open a GitHub Issue with the sanitized log and the exact command you ran
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ai-chat-exporter" name = "ai-chat-exporter"
version = "0.9.0" version = "1.0.0"
description = "Archive ChatGPT, Claude, Claude Code and Codex conversation history to Markdown for personal backup in Joplin" description = "Archive ChatGPT, Claude, Claude Code and Codex conversation history to Markdown for personal backup in Joplin"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+16 -15
View File
@@ -35,6 +35,7 @@ $ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot $repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo 'ai-chat-exporter.cmd' $launcher = Join-Path $repo 'ai-chat-exporter.cmd'
$wrapper = Join-Path $PSScriptRoot 'run-sync.ps1'
if ($Unregister) { if ($Unregister) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
@@ -42,22 +43,22 @@ if ($Unregister) {
return return
} }
if (-not (Test-Path $launcher)) { foreach ($f in $launcher, $wrapper) {
throw "Launcher not found at $launcher" if (-not (Test-Path $f)) { throw "Not found: $f" }
} }
# A single action looping over the providers, rather than one action each. # One action running run-sync.ps1, rather than one action per provider: Task
# Task Scheduler runs multiple actions in order but reports only the last one's # Scheduler runs multiple actions in order but reports only the last one's
# result, so a failure in an earlier provider would be invisible. The loop keeps # result, so a failure in an earlier provider would be invisible. The script
# going after a failure and propagates a non-zero exit code. # keeps going after a failure, exits non-zero if any provider failed, and pushes
# a FAILED notification for any run that died without sending its own.
# #
# /v:on and !RC! are required, not stylistic: cmd expands every %VAR% on a # Providers go as separate arguments: under -File, "chatgpt,claude" would arrive
# command line *before* running any of it, so "exit /b %RC%" would report the # as one string. -WindowStyle Hidden because the output now goes to
# value RC had before the loop ever ran - i.e. always success. Delayed expansion # cache\logs\scheduled-sync.log, so the console window would only ever be blank.
# reads it at the point of use. $taskArgs = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden " +
$loop = ($Provider | ForEach-Object { "`"$launcher`" sync --provider $_ --joplin-optional || set RC=1" }) -join ' & ' "-File `"$wrapper`" $($Provider -join ' ')"
$taskArgs = "/v:on /c set RC=0 & $loop & exit /b !RC!" $actions = New-ScheduledTaskAction -Execute 'powershell.exe' `
$actions = New-ScheduledTaskAction -Execute 'cmd.exe' `
-Argument $taskArgs ` -Argument $taskArgs `
-WorkingDirectory $repo -WorkingDirectory $repo
@@ -82,12 +83,12 @@ Register-ScheduledTask -TaskName $TaskName `
Write-Host "Registered '$TaskName' - daily at $Time for: $($Provider -join ', ')" Write-Host "Registered '$TaskName' - daily at $Time for: $($Provider -join ', ')"
Write-Host '' Write-Host ''
Write-Host 'Command the task will run:' Write-Host 'Command the task will run:'
Write-Host " cmd.exe $taskArgs" Write-Host " powershell.exe $taskArgs"
Write-Host " (working directory: $repo)" Write-Host " (working directory: $repo)"
Write-Host '' Write-Host ''
Write-Host 'Next steps:' Write-Host 'Next steps:'
Write-Host " * Run it once now: Start-ScheduledTask -TaskName $TaskName" Write-Host " * Run it once now: Start-ScheduledTask -TaskName $TaskName"
Write-Host " * Check the result: Get-ScheduledTaskInfo -TaskName $TaskName" Write-Host " * Check the result: Get-ScheduledTaskInfo -TaskName $TaskName"
Write-Host " * Read the log: Get-Content '$repo\cache\logs\exporter.log' -Tail 50" Write-Host " * Read the run output: Get-Content '$repo\cache\logs\scheduled-sync.log' -Tail 50"
Write-Host ' * The terms-of-service notice must have been acknowledged' Write-Host ' * The terms-of-service notice must have been acknowledged'
Write-Host ' interactively once on this machine, or the task exits 1.' Write-Host ' interactively once on this machine, or the task exits 1.'
+8 -8
View File
@@ -44,10 +44,12 @@ fi
[ ${#PROVIDERS[@]} -eq 0 ] && PROVIDERS=("all") [ ${#PROVIDERS[@]} -eq 0 ] && PROVIDERS=("all")
if [ ! -x "$REPO/ai-chat-exporter" ]; then for f in "$REPO/ai-chat-exporter" "$REPO/scheduling/run-sync.sh"; do
echo "error: $REPO/ai-chat-exporter is missing or not executable." >&2 if [ ! -x "$f" ]; then
echo "error: $f is missing or not executable." >&2
exit 1 exit 1
fi fi
done
mkdir -p "$UNIT_DIR" mkdir -p "$UNIT_DIR"
@@ -65,12 +67,10 @@ mkdir -p "$UNIT_DIR"
echo "Type=oneshot" echo "Type=oneshot"
echo "WorkingDirectory=$REPO" echo "WorkingDirectory=$REPO"
echo "Environment=AI_CHAT_EXPORTER_QUIET_CWD=1" echo "Environment=AI_CHAT_EXPORTER_QUIET_CWD=1"
# One ExecStart per provider would stop at the first failure, silently echo "Environment=AICHAT_SYNC_UNIT=$NAME"
# skipping the rest — an expired ChatGPT token would mean codex never runs. # run-sync.sh attempts every provider even after one fails, and pushes a
# Loop instead, so every provider is attempted and the unit still reports # FAILED notification for any run that died without sending its own.
# failure if any of them failed. echo "ExecStart=$REPO/scheduling/run-sync.sh ${PROVIDERS[*]}"
printf 'ExecStart=/bin/sh -c '\''rc=0; for p in %s; do "$0" sync --provider "$p" --joplin-optional || rc=1; done; exit $rc'\'' %s\n' \
"${PROVIDERS[*]}" "$REPO/ai-chat-exporter"
} > "$UNIT_DIR/$NAME.service" } > "$UNIT_DIR/$NAME.service"
# Persistent=true runs a missed schedule at the next boot — the machine being # Persistent=true runs a missed schedule at the next boot — the machine being
+150
View File
@@ -0,0 +1,150 @@
<#
.SYNOPSIS
Run `ai-chat-exporter sync` once per provider - the action of the scheduled
task that Register-AiChatSyncTask.ps1 creates.
.DESCRIPTION
The Windows counterpart to run-sync.sh, and there for the same reason.
Every provider is attempted even after one fails, and the exit code is
non-zero if any failed - Task Scheduler reports only the last action's
result, so one action per provider would hide an earlier failure.
The app pushes its own ntfy result, but only from the end of a run it
finished. A crash, a non-zero exit before the sync starts (the terms-of-
service gate, a cache error) or a launcher that can't build its venv sends
nothing, and because each provider pushes separately, the providers that did
succeed still send "OK" - so a broken one looks like a quiet day. This
script pushes a FAILED notification for any run that exited non-zero without
the app having reported it. (Its "Sync completed with failures" banner
prints right after its push.)
The push carries the provider, the exit code and, for a crash, the
exception's class name - never its message. Same counts-only rule as
src/notify.py: on a public ntfy topic anyone who guesses the name can read
it, and exception text can carry conversation titles.
Task Scheduler keeps no output, so each run's output is appended to
cache\logs\scheduled-sync.log (rolled over to .1 past 1 MB). That is where
the traceback is.
.EXAMPLE
powershell -NoProfile -ExecutionPolicy Bypass -File .\scheduling\run-sync.ps1 chatgpt claude
#>
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Provider
)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$launcher = Join-Path $repo 'ai-chat-exporter.cmd'
$envFile = Join-Path $repo '.env'
$logDir = Join-Path $repo 'cache\logs'
$runLog = Join-Path $logDir 'scheduled-sync.log'
# -File passes "chatgpt,claude" as one string; accept it as well as a list.
$Provider = @($Provider | ForEach-Object { $_ -split ',' } | Where-Object { $_ })
if ($Provider.Count -eq 0) { $Provider = @('all') }
foreach ($p in $Provider) {
# Each name is spliced into a cmd.exe command line below.
if ($p -notmatch '^[A-Za-z0-9_-]+$') { throw "Invalid provider name: '$p'" }
}
# Under Task Scheduler stdout is a file, not a console, so Python would encode
# it in the ANSI code page and die on the first character outside it.
$env:PYTHONIOENCODING = 'utf-8'
# NTFY_* as the app resolves them: the environment wins, then .env.
function Get-EnvValue([string]$Name) {
$value = [Environment]::GetEnvironmentVariable($Name)
if (-not $value -and (Test-Path -LiteralPath $envFile)) {
foreach ($line in Get-Content -LiteralPath $envFile) {
if ($line -match "^\s*$Name\s*=\s*(.*)$") { $value = $Matches[1] }
}
if ($value) { $value = ($value -replace '\s+#.*$', '').Trim().Trim('"', "'") }
}
return "$value"
}
function Send-FailurePush([string]$Body) {
$topic = Get-EnvValue 'NTFY_TOPIC'
$policy = (Get-EnvValue 'NTFY_NOTIFY').ToLower()
if (-not $topic -or $policy -eq 'off') { return }
$server = Get-EnvValue 'NTFY_SERVER'
if (-not $server) { $server = 'https://ntfy.sh' }
$token = Get-EnvValue 'NTFY_TOKEN'
# The same short name src/notify.py puts in its titles.
$machine = [System.Net.Dns]::GetHostName().Split('.')[0]
$headers = @{
Title = "AI archive FAILED - $machine"
Tags = 'rotating_light'
Priority = 'high'
}
if ($token) { $headers.Authorization = "Bearer $token" }
try {
# Windows PowerShell 5.1 on an older .NET may not offer TLS 1.2 by default.
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 15 `
-Uri "$($server.TrimEnd('/'))/$topic" -Headers $headers `
-ContentType 'text/plain; charset=utf-8' `
-Body ([Text.Encoding]::UTF8.GetBytes($Body)) | Out-Null
} catch {
Write-Warning "run-sync: could not send the failure notification ($($_.Exception.Message))"
}
}
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
if ((Test-Path -LiteralPath $runLog) -and (Get-Item -LiteralPath $runLog).Length -gt 1MB) {
Move-Item -LiteralPath $runLog -Destination "$runLog.1" -Force
}
$rc = 0
foreach ($p in $Provider) {
$out = [IO.Path]::GetTempFileName()
try {
# cmd.exe does the redirect, so stdout and stderr stay interleaved and
# PowerShell's native-command stderr handling never sees either. /s strips
# exactly the outer pair of quotes, leaving the quoted paths inside intact.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = if ($env:ComSpec) { $env:ComSpec } else { 'cmd.exe' }
$psi.Arguments = "/d /s /c `"`"$launcher`" sync --provider $p --joplin-optional > `"$out`" 2>&1`""
$psi.WorkingDirectory = $repo
$psi.UseShellExecute = $false
$proc = [Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
$status = $proc.ExitCode
$text = [IO.File]::ReadAllText($out, [Text.Encoding]::UTF8)
Write-Host $text
Add-Content -LiteralPath $runLog -Encoding UTF8 -Value (
"===== $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') sync --provider $p (exit $status) =====`r`n$text")
if ($status -ne 0) {
$rc = 1
if (-not $text.Contains('Sync completed with failures')) {
$crash = [regex]::Matches($text, '(?m)^[A-Za-z_][A-Za-z0-9_.]*(Error|Exception)\b') |
Select-Object -Last 1
if ($crash) {
$reason = "crashed ($($crash.Value))"
} else {
$reason = "exited $status before reporting a result"
}
Send-FailurePush "${p}: $reason`ncache\logs\scheduled-sync.log"
}
}
} catch {
# This script failing is a run that reports nothing, too.
$rc = 1
Write-Warning "run-sync: $p - $($_.Exception.Message)"
Send-FailurePush "${p}: run-sync.ps1 failed ($($_.Exception.GetType().Name))"
} finally {
Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue
}
}
exit $rc
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Run `ai-chat-exporter sync` once per provider — the ExecStart of the systemd
# unit that install-systemd-timer.sh writes.
#
# ./scheduling/run-sync.sh claude-code codex
#
# Every provider is attempted even after one fails, and the exit code is
# non-zero if any failed — one ExecStart per provider would stop at the first.
#
# The app pushes its own ntfy result, but only from the end of a run it
# finished. A crash, a non-zero exit before the sync starts (the terms-of-service
# gate, a cache error) or a launcher that can't build its venv sends nothing, and
# because each provider pushes separately, the providers that did succeed still
# send "OK" — so a broken one looks like a quiet day. This script pushes a FAILED
# notification for any run that exited non-zero without the app having reported
# it. (Its "Sync completed with failures" banner prints right after its push.)
#
# The push carries the provider, the exit code and, for a crash, the exception's
# class name — never its message. Same counts-only rule as src/notify.py: on a
# public ntfy topic anyone who guesses the name can read it, and exception text
# can carry conversation titles. The full traceback is in the journal.
set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
LAUNCHER="$REPO/ai-chat-exporter"
# NTFY_* as the app resolves them: the environment wins, then .env.
env_value() {
local name=$1 value=${!1:-}
if [ -z "$value" ] && [ -f "$REPO/.env" ]; then
value=$(sed -n "s/^[[:space:]]*$name[[:space:]]*=[[:space:]]*//p" "$REPO/.env" | tail -n 1)
value=${value%%[[:space:]]#*}
value=${value%"${value##*[![:space:]]}"}
value=${value#[\"\']}
value=${value%[\"\']}
fi
printf '%s' "$value"
}
push_failure() {
local body=$1 topic server token policy
topic=$(env_value NTFY_TOPIC)
policy=$(env_value NTFY_NOTIFY | tr '[:upper:]' '[:lower:]')
if [ -z "$topic" ] || [ "$policy" = "off" ]; then
return 0
fi
server=$(env_value NTFY_SERVER)
server=${server:-https://ntfy.sh}
token=$(env_value NTFY_TOKEN)
local args=(-fsS --max-time 15 -o /dev/null
-H "Title: AI archive FAILED - $(hostname -s)"
-H "Tags: rotating_light" -H "Priority: high"
--data-binary "$body")
[ -n "$token" ] && args+=(-H "Authorization: Bearer $token")
curl "${args[@]}" "${server%/}/$topic" \
|| echo "run-sync: could not send the failure notification" >&2
}
[ $# -eq 0 ] && set -- all
rc=0
for provider in "$@"; do
out=$(mktemp)
"$LAUNCHER" sync --provider "$provider" --joplin-optional 2>&1 | tee "$out"
status=${PIPESTATUS[0]}
if [ "$status" -ne 0 ]; then
rc=1
if ! grep -q "Sync completed with failures" "$out"; then
crash=$(grep -oE '^[A-Za-z_][A-Za-z0-9_.]*(Error|Exception)\b' "$out" | tail -n 1)
if [ -n "$crash" ]; then
reason="crashed ($crash)"
else
reason="exited $status before reporting a result"
fi
push_failure "$provider: $reason
journalctl --user -u ${AICHAT_SYNC_UNIT:-aichat-sync} -n 100"
fi
fi
rm -f "$out"
done
exit "$rc"
+43
View File
@@ -10,6 +10,12 @@ from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# How many runs may re-export a conversation to re-fetch a transiently failed
# asset before giving up on it. Three mornings is enough to ride out a storage
# incident; past that the asset is treated as lost and the conversation stops
# being re-exported (the record of which assets were lost is kept).
MAX_MEDIA_RETRY_RUNS = 3
MANIFEST_VERSION = 1 MANIFEST_VERSION = 1
@@ -57,6 +63,33 @@ class Cache:
logger.debug("[cache] MISS %s/%s", provider, conv_id[:8]) logger.debug("[cache] MISS %s/%s", provider, conv_id[:8])
return False return False
# Assets the provider fumbled transiently (a 5xx or timeout on the
# signed URL) are worth asking for again: the conversation itself is
# up to date, but it is missing bytes that almost certainly still
# exist. Re-exporting is cheap — media already on disk is reused and
# Joplin resources are keyed by path — so only the missing asset is
# fetched. Capped, because an asset that fails this way every single
# day would otherwise re-export and re-sync its conversation forever.
pending = entry.get("pending_media")
if pending:
attempts = int(entry.get("pending_media_attempts") or 0)
if attempts <= MAX_MEDIA_RETRY_RUNS:
logger.debug(
"[cache] RETRY-MEDIA %s/%s (%d asset(s), retry %d/%d)",
provider,
conv_id[:8],
len(pending),
attempts,
MAX_MEDIA_RETRY_RUNS,
)
return False
logger.debug(
"[cache] HIT (media retries exhausted) %s/%s — %d asset(s) lost",
provider,
conv_id[:8],
len(pending),
)
cached_updated = entry.get("updated_at", "") cached_updated = entry.get("updated_at", "")
if not cached_updated or not updated_at: if not cached_updated or not updated_at:
logger.debug("[cache] HIT (no date comparison) %s/%s", provider, conv_id[:8]) logger.debug("[cache] HIT (no date comparison) %s/%s", provider, conv_id[:8])
@@ -102,6 +135,16 @@ class Cache:
if carried in prev: if carried in prev:
entry[carried] = prev[carried] entry[carried] = prev[carried]
# Assets that failed transiently this run. Deliberately NOT carried
# forward from `prev`: a run that resolved them must clear the flag,
# which is what makes "pending" mean "still missing" rather than
# "was missing once". The attempt counter does carry, so repeated
# failure eventually stops costing a daily re-export.
pending = [str(r) for r in (metadata.get("pending_media") or [])]
if pending:
entry["pending_media"] = pending
entry["pending_media_attempts"] = int(prev.get("pending_media_attempts") or 0) + 1
self._data[provider][conv_id] = entry self._data[provider][conv_id] = entry
self._data["last_run"] = datetime.now(tz=timezone.utc).isoformat() self._data["last_run"] = datetime.now(tz=timezone.utc).isoformat()
self._save() self._save()
+18 -4
View File
@@ -258,7 +258,10 @@ def _auth_claude(os_name: str) -> None:
console.print("2. Press [bold]F12[/bold] to open DevTools → Application tab.") console.print("2. Press [bold]F12[/bold] to open DevTools → Application tab.")
console.print("3. Expand [bold]Cookies[/bold] → [bold]https://claude.ai[/bold]") console.print("3. Expand [bold]Cookies[/bold] → [bold]https://claude.ai[/bold]")
console.print("4. Find [bold]sessionKey[/bold] → copy the Value.") console.print("4. Find [bold]sessionKey[/bold] → copy the Value.")
console.print(" (Note: Claude tokens expire after ~30 days; a 401 error is the only signal.)") console.print(
" (Note: Claude tokens expire after ~30 days; an API rejection — 403 "
"'Invalid authorization' — is the only signal.)"
)
console.print("5. Paste it below (input is hidden).\n") console.print("5. Paste it below (input is hidden).\n")
key = click.prompt("Claude session key", hide_input=True, default="", show_default=False).strip() key = click.prompt("Claude session key", hide_input=True, default="", show_default=False).strip()
@@ -869,8 +872,13 @@ def export(
# Single LossReport tracks data-loss visibility across all providers in this run. # Single LossReport tracks data-loss visibility across all providers in this run.
loss_report = LossReport() loss_report = LossReport()
# One breaker for the whole run: a provider's asset storage being unwell is
# a run-level fact, not a per-conversation one.
from src.media import MediaCircuit
media_circuit = MediaCircuit()
for prov_name, prov_instance in providers_to_run: for prov_name, prov_instance in providers_to_run:
summary[prov_name] = {"exported": 0, "skipped": 0, "failed": 0} summary[prov_name] = {"exported": 0, "skipped": 0, "failed": 0, "media_failed": 0}
console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan] Fetching conversation list…") console.print(f"\n[bold cyan][{prov_name.upper()}][/bold cyan] Fetching conversation list…")
@@ -955,16 +963,19 @@ def export(
# Download assets (images by default) so the renderers # Download assets (images by default) so the renderers
# can inline them. Failures keep placeholders; never fatal. # can inline them. Failures keep placeholders; never fatal.
from src.media import MediaOutcome, resolve_media
media_outcome = MediaOutcome()
if cfg.download_media != "off": if cfg.download_media != "off":
from src.media import resolve_media media_outcome = resolve_media(
resolve_media(
normalized, normalized,
prov_instance, prov_instance,
export_base, export_base,
structure, structure,
cfg.download_media, cfg.download_media,
loss_report, loss_report,
circuit=media_circuit,
) )
summary[prov_name]["media_failed"] += media_outcome.failed
exported_path: Path | None = None exported_path: Path | None = None
if md_exporter: if md_exporter:
@@ -979,6 +990,9 @@ def export(
"created_at": normalized.get("created_at", ""), "created_at": normalized.get("created_at", ""),
"updated_at": normalized.get("updated_at", ""), "updated_at": normalized.get("updated_at", ""),
"file_path": str(exported_path) if exported_path else "", "file_path": str(exported_path) if exported_path else "",
# Empty clears the flag, so a run that recovered the
# asset stops the conversation being re-exported.
"pending_media": media_outcome.retryable_refs,
}) })
summary[prov_name]["exported"] += 1 summary[prov_name]["exported"] += 1
progress.advance(task) progress.advance(task)
+131 -13
View File
@@ -12,13 +12,28 @@ Policy (``EXPORTER_DOWNLOAD_MEDIA``):
are already in the exports as text) are already in the exports as text)
off — leave placeholders untouched off — leave placeholders untouched
Failures (expired assets, network) keep the existing placeholder and are Failures keep the existing placeholder and are counted in the run summary —
counted in the run summary — never fatal to the export. never fatal to the export. They split two ways, and the split is what decides
whether the asset is ever seen again:
permanent — ``expired-or-missing`` (404) and ``forbidden`` (403). The bytes
are gone server-side; retrying achieves nothing. Investigated
exhaustively 2026-08-17, see ``_classify_failure``.
transient — everything else: a 5xx from the signed URL, a timeout, a
network blip. The asset almost certainly still exists, so its
ref is handed back for the cache to remember and the next
scheduled run re-attempts it.
A run also carries a ``MediaCircuit``: once enough *transient* failures land
back to back, asset downloads stop for the rest of the run. A storage incident
fails every asset in the archive, and a patient retry ladder times that many
failures over is how a scheduled run exceeds its execution window.
""" """
import logging import logging
import os import os
import tempfile import tempfile
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from src.blocks import BLOCK_TYPE_FILE_PLACEHOLDER, BLOCK_TYPE_IMAGE_PLACEHOLDER from src.blocks import BLOCK_TYPE_FILE_PLACEHOLDER, BLOCK_TYPE_IMAGE_PLACEHOLDER
@@ -33,6 +48,19 @@ MEDIA_ALL = "all"
MEDIA_OFF = "off" MEDIA_OFF = "off"
VALID_MEDIA_POLICIES = {MEDIA_IMAGES, MEDIA_ALL, MEDIA_OFF} VALID_MEDIA_POLICIES = {MEDIA_IMAGES, MEDIA_ALL, MEDIA_OFF}
# Failure buckets whose asset is worth asking for again on a later run. The
# permanent buckets are deliberately excluded: this archive holds 19 assets
# whose bytes OpenAI lost in a 2026-07-14 Library migration, and retrying
# those every morning forever would be pure noise — and would trip the
# circuit breaker below on a server that is perfectly healthy.
TRANSIENT_FAILURE_BUCKETS = frozenset({"download-error"})
# Consecutive transient failures before asset downloads stop for this run.
MEDIA_CIRCUIT_THRESHOLD = 5
# Bucket recorded for assets skipped because the breaker had already tripped.
CIRCUIT_OPEN_BUCKET = "skipped-provider-unwell"
_EXT_BY_MIME = { _EXT_BY_MIME = {
"image/png": ".png", "image/png": ".png",
"image/jpeg": ".jpg", "image/jpeg": ".jpg",
@@ -62,6 +90,64 @@ def resolve_media_policy() -> str:
return value return value
@dataclass
class MediaOutcome:
"""What one conversation's media pass achieved.
``retryable_refs`` is the part that matters beyond the summary line: the
caller records it against the conversation in the cache manifest, which is
what lets a later run re-attempt an asset the provider fumbled. Without
it a transient 5xx lost the asset permanently, because the conversation was
marked exported and never looked at again.
"""
downloaded: int = 0
failed: int = 0
retryable_refs: list[str] = field(default_factory=list)
class MediaCircuit:
"""Run-scoped breaker that stops asset downloads when the provider is unwell.
One run-level object, shared by every conversation in the run. Counts
*consecutive transient* failures: a permanent failure (a deleted upload)
says nothing about the provider's health and must not trip it, and any
success resets the count, so ordinary scattered losses never trip it either.
Once tripped it stays tripped for the run. The alternative — keep trying
every asset through a storage incident — is what pushes a scheduled run
past its execution window (two hours, in the Windows task definition).
Assets skipped while open are still reported as retryable, so the next run
picks them up rather than losing them.
"""
def __init__(self, threshold: int = MEDIA_CIRCUIT_THRESHOLD) -> None:
self.threshold = threshold
self.consecutive = 0
self.tripped = False
self.skipped = 0
def record_success(self) -> None:
self.consecutive = 0
def record_failure(self, bucket: str) -> None:
"""Count a failure; only transient ones count toward tripping."""
if bucket not in TRANSIENT_FAILURE_BUCKETS:
return
self.consecutive += 1
if not self.tripped and self.consecutive >= self.threshold:
self.tripped = True
logger.error(
"[media] %d consecutive transient asset failures — skipping "
"further downloads this run. Affected assets are recorded and "
"will be re-attempted on the next run.",
self.consecutive,
)
def record_skip(self) -> None:
self.skipped += 1
def resolve_media( def resolve_media(
normalized: dict, normalized: dict,
provider, provider,
@@ -69,19 +155,28 @@ def resolve_media(
structure: str, structure: str,
policy: str, policy: str,
report: LossReport, report: LossReport,
) -> int: circuit: "MediaCircuit | None" = None,
"""Download assets for one normalized conversation. Returns download count. ) -> MediaOutcome:
"""Download assets for one normalized conversation.
Mutates placeholder blocks in place (adds ``local_path``). Idempotent: Mutates placeholder blocks in place (adds ``local_path``). Idempotent:
an asset already on disk is annotated without an API call. an asset already on disk is annotated without an API call, which is also
what makes a re-export cheap when only one asset needs re-fetching.
Returns:
A ``MediaOutcome``. ``retryable_refs`` is non-empty when an asset
failed transiently (or was skipped by an open circuit) and should be
re-attempted on a later run.
""" """
outcome = MediaOutcome()
if policy == MEDIA_OFF: if policy == MEDIA_OFF:
return 0 return outcome
download = getattr(provider, "download_asset", None) download = getattr(provider, "download_asset", None)
if download is None: if download is None:
# Provider has no remote assets (e.g. claude-code local transcripts). # Provider has no remote assets (e.g. claude-code local transcripts).
return 0 return outcome
wanted_types = {BLOCK_TYPE_IMAGE_PLACEHOLDER} wanted_types = {BLOCK_TYPE_IMAGE_PLACEHOLDER}
if policy == MEDIA_ALL: if policy == MEDIA_ALL:
@@ -94,7 +189,7 @@ def resolve_media(
if block.get("type") in wanted_types and block.get("ref") if block.get("type") in wanted_types and block.get("ref")
] ]
if not targets: if not targets:
return 0 return outcome
# Same path computation the exporter uses, so media/ lands beside the .md. # Same path computation the exporter uses, so media/ lands beside the .md.
filename = generate_filename( filename = generate_filename(
@@ -112,12 +207,12 @@ def resolve_media(
).parent ).parent
media_dir = conv_dir / "media" media_dir = conv_dir / "media"
downloaded = 0
for block in targets: for block in targets:
ref = block["ref"] ref = block["ref"]
file_id = _safe_asset_name(provider, ref) file_id = _safe_asset_name(provider, ref)
if not file_id: if not file_id:
report.record_media_failed("unparseable-ref") report.record_media_failed("unparseable-ref")
outcome.failed += 1
continue continue
existing = _find_existing(media_dir, file_id) existing = _find_existing(media_dir, file_id)
@@ -125,13 +220,32 @@ def resolve_media(
block["local_path"] = f"media/{existing.name}" block["local_path"] = f"media/{existing.name}"
continue continue
# Breaker open: do not touch the network, but keep the ref so the
# asset is re-attempted rather than silently abandoned.
if circuit is not None and circuit.tripped:
circuit.record_skip()
report.record_media_failed(CIRCUIT_OPEN_BUCKET)
outcome.failed += 1
outcome.retryable_refs.append(ref)
continue
try: try:
content, mime, file_name = download(ref) content, mime, file_name = download(ref)
except ProviderError as e: except ProviderError as e:
bucket = _classify_failure(e)
logger.warning( logger.warning(
"[media] Could not download %s: %s", ref[:60], e.original "[media] Could not download %s: %s (%s%s)",
ref[:60],
e.original,
bucket,
", will retry next run" if bucket in TRANSIENT_FAILURE_BUCKETS else "",
) )
report.record_media_failed(_classify_failure(e)) report.record_media_failed(bucket)
outcome.failed += 1
if bucket in TRANSIENT_FAILURE_BUCKETS:
outcome.retryable_refs.append(ref)
if circuit is not None:
circuit.record_failure(bucket)
continue continue
ext = _pick_extension(mime, file_name) ext = _pick_extension(mime, file_name)
@@ -141,13 +255,17 @@ def resolve_media(
except OSError as e: except OSError as e:
logger.error("[media] Could not write %s: %s", target, e) logger.error("[media] Could not write %s: %s", target, e)
report.record_media_failed("write-error") report.record_media_failed("write-error")
outcome.failed += 1
continue continue
block["local_path"] = f"media/{target.name}" block["local_path"] = f"media/{target.name}"
report.record_media_downloaded(len(content)) report.record_media_downloaded(len(content))
downloaded += 1 outcome.downloaded += 1
if circuit is not None:
circuit.record_success()
return outcome
return downloaded
def _classify_failure(error: ProviderError) -> str: def _classify_failure(error: ProviderError) -> str:
+8
View File
@@ -148,10 +148,18 @@ def format_summary(
exported = counts.get("exported", 0) exported = counts.get("exported", 0)
skipped = counts.get("skipped", 0) skipped = counts.get("skipped", 0)
failed = counts.get("failed", 0) failed = counts.get("failed", 0)
media_failed = counts.get("media_failed", 0)
total_exported += exported total_exported += exported
line = f"{prov}: {exported} exported, {skipped} up to date" line = f"{prov}: {exported} exported, {skipped} up to date"
if failed: if failed:
line += f", {failed} FAILED" line += f", {failed} FAILED"
# Media failures do not fail the run — the transcript is archived and a
# transient asset is re-attempted on a later run — but they were
# invisible here until 2026-10-20, when two assets were lost in a run
# that pushed a clean result. A count is enough to prompt a log check;
# asset ids stay out, like conversation titles (see module docstring).
if media_failed:
line += f", {media_failed} media failed"
lines.append(line) lines.append(line)
synced = sum( synced = sum(
+159 -42
View File
@@ -41,6 +41,19 @@ MAX_RETRIES = 3
BACKOFF_BASE = 2.0 BACKOFF_BASE = 2.0
BACKOFF_MAX = 60.0 BACKOFF_MAX = 60.0
# Asset (media) download retry policy. The asset endpoints flake independently
# of the conversation API: measured 2026-10-20 in one scheduled run, a signed
# URL returned 500 on one image and 503 on another, while the metadata hop for
# the same assets succeeded. They therefore get a more patient ladder than the
# conversation API — an immediate retry, then ~2/4/8/16s with jitter — bounded
# by a wall-clock budget, because the other failure mode is a 40s timeout and
# an unbounded ladder over those would eat a scheduled run's whole window.
MEDIA_MAX_RETRIES = 5
MEDIA_TIME_BUDGET = 60.0
# Shorter read timeout than REQUEST_TIMEOUT: an asset that has not started
# sending bytes in 20s is not about to, and the budget has retries to fund.
MEDIA_REQUEST_TIMEOUT = (10, 20)
# EXPORTER_HIDDEN_CONTENT policy — what to do with content that was invisible # EXPORTER_HIDDEN_CONTENT policy — what to do with content that was invisible
# in the provider's UI (retrieval dumps, hidden-flagged context, agent tool # in the provider's UI (retrieval dumps, hidden-flagged context, agent tool
# traffic). Shared by all providers. # traffic). Shared by all providers.
@@ -316,35 +329,73 @@ class BaseProvider(ABC):
return all_convs return all_convs
def _make_request( def _send_with_retries(
self, self,
method: str, method: str,
url: str, url: str,
debug_log_body: bool = False, max_retries: int | None = None,
immediate_first_retry: bool = False,
time_budget: float | None = None,
**kwargs: Any, **kwargs: Any,
) -> dict | list: ) -> Any:
"""Make an authenticated HTTP request with retry logic and unified error handling. """Send one request with retry/backoff, returning the raw response.
The transport half of ``_make_request``, split out so callers that want
**bytes** rather than JSON get identical retry behaviour. Before this
existed, the signed-URL hop of an asset download was a bare
``session.request`` with no 5xx retry at all, and its timeouts escaped
as ``OSError`` (curl_cffi's ``RequestException`` subclasses it) to be
mis-reported by the export loop as "File write failed".
Args: Args:
method: HTTP method ("GET", "POST", etc.) method: HTTP method ("GET", "POST", etc.)
url: Full URL. url: Full URL.
debug_log_body: If True, log redacted response body at DEBUG level. max_retries: Retry ceiling; defaults to ``MAX_RETRIES``.
immediate_first_retry: Retry a 5xx once with no wait before backing
off. A single bad backend node is the most common shape of a
transient 5xx, and a zero-cost retry catches it.
time_budget: Seconds after which no further attempt starts. The last
attempt's read timeout is clamped to what remains, so the real
bound is the budget plus the connect timeout. Used to keep an
endpoint that is timing out (40s a go, measured 2026-10-20) from
consuming a scheduled run's whole execution window.
**kwargs: Passed to requests.Session.request(). **kwargs: Passed to requests.Session.request().
Returns: Returns:
Parsed JSON response body. The response object, for the caller to read as JSON or bytes.
Raises: Raises:
ProviderError: On 401, exhausted retries, or unrecoverable errors. ProviderError: On an authentication failure, exhausted retries, or
unrecoverable errors.
""" """
ceiling = MAX_RETRIES if max_retries is None else max_retries
kwargs.setdefault("timeout", REQUEST_TIMEOUT) kwargs.setdefault("timeout", REQUEST_TIMEOUT)
base_timeout = kwargs["timeout"]
started = time.monotonic()
def _remaining() -> float | None:
if time_budget is None:
return None
return time_budget - (time.monotonic() - started)
def _budget_exhausted(wait: float = 0.0) -> bool:
left = _remaining()
return left is not None and left - wait <= 0
attempt = 0 attempt = 0
last_exc: Exception | None = None last_exc: Exception | None = None
while attempt <= MAX_RETRIES: while attempt <= ceiling:
attempt += 1 attempt += 1
self._pace() self._pace()
# Clamp the read timeout to the remaining budget so the final
# attempt cannot overrun it by a whole timeout period.
left = _remaining()
if left is not None and isinstance(base_timeout, tuple):
connect_to, read_to = base_timeout
kwargs["timeout"] = (connect_to, max(1.0, min(read_to, left)))
start = time.monotonic() start = time.monotonic()
try: try:
@@ -361,30 +412,35 @@ class BaseProvider(ABC):
elapsed_ms, elapsed_ms,
) )
# ── 401: token expired / invalid ────────────────────────── # ── Auth failure: token expired / invalid ──────────────────
if response.status_code == 401: # Not keyed on 401 alone: a provider is free to answer an
self._handle_401() # invalid session with some other status, and claude.ai does
# _handle_401 raises ProviderError — this line never runs # (403). Ask the provider rather than assuming.
if self._is_auth_failure(response):
self._handle_auth_failure(response)
# _handle_auth_failure raises — this line never runs
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
f"{method} {url}", f"{method} {url}",
RuntimeError("401 Unauthorized"), RuntimeError(
f"HTTP {response.status_code} — not authenticated"
),
) )
# ── 429: rate limited ────────────────────────────────────── # ── 429: rate limited ──────────────────────────────────────
if response.status_code == 429: if response.status_code == 429:
if attempt > MAX_RETRIES: wait = self._backoff_wait(response, attempt)
if attempt > ceiling or _budget_exhausted(wait):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
f"{method} {url}", f"{method} {url}",
RuntimeError("429 Rate Limited — max retries exhausted"), RuntimeError("429 Rate Limited — max retries exhausted"),
) )
wait = self._backoff_wait(response, attempt)
logger.warning( logger.warning(
"[%s] Rate limited. Retry %d/%d in %.1fs", "[%s] Rate limited. Retry %d/%d in %.1fs",
self.provider_name, self.provider_name,
attempt, attempt,
MAX_RETRIES, ceiling,
wait, wait,
) )
time.sleep(wait) time.sleep(wait)
@@ -392,23 +448,26 @@ class BaseProvider(ABC):
# ── 5xx: server error ────────────────────────────────────── # ── 5xx: server error ──────────────────────────────────────
if response.status_code >= 500: if response.status_code >= 500:
if attempt > MAX_RETRIES: wait = self._backoff_wait(
response, attempt, immediate_first=immediate_first_retry
)
if attempt > ceiling or _budget_exhausted(wait):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
f"{method} {url}", f"{method} {url}",
RuntimeError( RuntimeError(
f"HTTP {response.status_code} after {MAX_RETRIES} retries" f"HTTP {response.status_code} after {attempt - 1} retries"
), ),
) )
wait = self._backoff_wait(None, attempt)
logger.warning( logger.warning(
"[%s] HTTP %d. Retry %d/%d in %.1fs", "[%s] HTTP %d. Retry %d/%d in %.1fs",
self.provider_name, self.provider_name,
response.status_code, response.status_code,
attempt, attempt,
MAX_RETRIES, ceiling,
wait, wait,
) )
if wait:
time.sleep(wait) time.sleep(wait)
continue continue
@@ -427,41 +486,32 @@ class BaseProvider(ABC):
), ),
) )
# ── Success ──────────────────────────────────────────────── return response
body = response.json()
if debug_log_body:
logger.debug(
"[%s] Response body (redacted): %s",
self.provider_name,
redact_secrets(body),
)
return body
except ProviderError: except ProviderError:
raise raise
except (requests.ConnectionError, requests.Timeout, _CurlConnectionError, _CurlTimeout) as e: except (requests.ConnectionError, requests.Timeout, _CurlConnectionError, _CurlTimeout) as e:
last_exc = e last_exc = e
if attempt > MAX_RETRIES: wait = self._backoff_wait(None, attempt, immediate_first=immediate_first_retry)
if attempt > ceiling or _budget_exhausted(wait):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
f"{method} {url}", f"{method} {url}",
RuntimeError( RuntimeError(
f"Network error after {MAX_RETRIES} retries: {e}. " f"Network error after {attempt - 1} retries: {e}. "
"Check your internet connection." "Check your internet connection."
), ),
) from e ) from e
wait = self._backoff_wait(None, attempt)
logger.warning( logger.warning(
"[%s] Network error (attempt %d/%d): %s. Retrying in %.1fs", "[%s] Network error (attempt %d/%d): %s. Retrying in %.1fs",
self.provider_name, self.provider_name,
attempt, attempt,
MAX_RETRIES, ceiling,
e, e,
wait, wait,
) )
if wait:
time.sleep(wait) time.sleep(wait)
except (requests.HTTPError, _CurlHTTPError) as e: except (requests.HTTPError, _CurlHTTPError) as e:
@@ -476,11 +526,59 @@ class BaseProvider(ABC):
last_exc or RuntimeError("Unknown error"), last_exc or RuntimeError("Unknown error"),
) )
def _handle_401(self) -> None: def _make_request(
"""Log a clear human-readable message for a 401 and raise ProviderError.""" self,
method: str,
url: str,
debug_log_body: bool = False,
**kwargs: Any,
) -> dict | list:
"""Make an authenticated HTTP request and return its parsed JSON body.
Retry, backoff and error handling live in ``_send_with_retries``; this
adds JSON parsing and optional body logging.
Args:
method: HTTP method ("GET", "POST", etc.)
url: Full URL.
debug_log_body: If True, log redacted response body at DEBUG level.
**kwargs: Passed through (see ``_send_with_retries``).
Returns:
Parsed JSON response body.
Raises:
ProviderError: On an authentication failure, exhausted retries, or
unrecoverable errors.
"""
response = self._send_with_retries(method, url, **kwargs)
body = response.json()
if debug_log_body:
logger.debug(
"[%s] Response body (redacted): %s",
self.provider_name,
redact_secrets(body),
)
return body
def _is_auth_failure(self, response: Any) -> bool:
"""Whether this response means the stored credential is not valid.
Defaults to 401. Providers whose API reports an invalid session with a
different status override this — claude.ai returns 403, so keying auth
handling on 401 alone reports an expired key as a generic permission
error and never tells the user to refresh it.
"""
return bool(response.status_code == 401)
def _handle_auth_failure(self, response: Any) -> None:
"""Log a clear human-readable message for an auth failure and raise."""
# Subclasses override to include provider-specific cookie name # Subclasses override to include provider-specific cookie name
msg = ( msg = (
f"[{self.provider_name}] Authentication failed (401 Unauthorized). " f"[{self.provider_name}] Authentication failed "
f"(HTTP {response.status_code}). "
"Your session token has likely expired. " "Your session token has likely expired. "
"Run 'ai-chat-exporter auth' to refresh your token." "Run 'ai-chat-exporter auth' to refresh your token."
) )
@@ -488,12 +586,28 @@ class BaseProvider(ABC):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
"authentication", "authentication",
RuntimeError("401 Unauthorized — token expired"), RuntimeError(
f"HTTP {response.status_code} — session token expired or invalid"
),
) )
@staticmethod @staticmethod
def _backoff_wait(response: requests.Response | None, attempt: int) -> float: def _backoff_wait(
"""Compute exponential backoff with jitter.""" response: requests.Response | None,
attempt: int,
immediate_first: bool = False,
) -> float:
"""Compute exponential backoff with jitter.
``Retry-After`` wins when the server sends one — including ahead of an
immediate retry, because a server naming a time knows better than we do
(503s commonly carry it).
``immediate_first`` makes the first retry free and shifts the ladder
down one step, giving 0, 2, 4, 8, 16s rather than 2, 4, 8, 16, 32s.
Jitter is never dropped: a storage incident fails many assets at once,
and un-jittered retries would hit a recovering backend in lockstep.
"""
if response is not None: if response is not None:
retry_after = response.headers.get("Retry-After") retry_after = response.headers.get("Retry-After")
if retry_after: if retry_after:
@@ -501,7 +615,10 @@ class BaseProvider(ABC):
return float(retry_after) return float(retry_after)
except ValueError: except ValueError:
pass pass
base = min(BACKOFF_BASE ** attempt, BACKOFF_MAX) if immediate_first and attempt <= 1:
return 0.0
exponent = attempt - 1 if immediate_first else attempt
base = min(BACKOFF_BASE ** exponent, BACKOFF_MAX)
jitter = random.uniform(0, base * 0.3) jitter = random.uniform(0, base * 0.3)
return base + jitter return base + jitter
+33 -8
View File
@@ -51,6 +51,9 @@ from src.providers.base import (
HIDDEN_CONTENT_FULL, HIDDEN_CONTENT_FULL,
HIDDEN_CONTENT_OMIT, HIDDEN_CONTENT_OMIT,
HIDDEN_CONTENT_PLACEHOLDER, HIDDEN_CONTENT_PLACEHOLDER,
MEDIA_MAX_RETRIES,
MEDIA_REQUEST_TIMEOUT,
MEDIA_TIME_BUDGET,
ProviderError, ProviderError,
REQUEST_TIMEOUT, REQUEST_TIMEOUT,
VALID_HIDDEN_CONTENT_POLICIES, VALID_HIDDEN_CONTENT_POLICIES,
@@ -307,9 +310,9 @@ class ChatGPTProvider(BaseProvider):
) )
return access_token return access_token
def _handle_401(self) -> None: def _handle_auth_failure(self, response: Any) -> None:
msg = ( msg = (
"[chatgpt] Authentication failed (401 Unauthorized). " f"[chatgpt] Authentication failed (HTTP {response.status_code}). "
"Your __Secure-next-auth.session-token has likely expired (~7 day lifetime). " "Your __Secure-next-auth.session-token has likely expired (~7 day lifetime). "
"The session token is used to obtain a short-lived access token via /api/auth/session. " "The session token is used to obtain a short-lived access token via /api/auth/session. "
"To refresh: open chatgpt.com in Chrome → F12 → Application → Cookies " "To refresh: open chatgpt.com in Chrome → F12 → Application → Cookies "
@@ -320,7 +323,9 @@ class ChatGPTProvider(BaseProvider):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
"authentication", "authentication",
RuntimeError("401 Unauthorized — ChatGPT token expired"), RuntimeError(
f"HTTP {response.status_code} — ChatGPT session token expired"
),
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -762,14 +767,34 @@ class ChatGPTProvider(BaseProvider):
RuntimeError(f"No download_url in response: {meta.get('detail') or meta}"), RuntimeError(f"No download_url in response: {meta.get('detail') or meta}"),
) )
self._pace() # The signed URL is a second hop that flakes on its own account.
resp = self._session.request("GET", download_url, timeout=REQUEST_TIMEOUT) # Measured 2026-10-20 in one scheduled run: a 500 here *after* the
if resp.status_code != 200: # metadata hop had recovered on its 4th try, and a 503 on a different
# asset whose metadata hop was clean first time. It therefore goes
# through the shared retry path rather than being a bare request —
# which also means a timeout arrives as ProviderError, so resolve_media
# placeholders the asset, instead of as the OSError that previously
# escaped and aborted the whole conversation.
#
# No _pace() here: _send_with_retries paces every attempt itself, and
# pacing twice would add REQUEST_DELAY to every asset in the archive.
try:
resp = self._send_with_retries(
"GET",
download_url,
max_retries=MEDIA_MAX_RETRIES,
immediate_first_retry=True,
time_budget=MEDIA_TIME_BUDGET,
timeout=MEDIA_REQUEST_TIMEOUT,
)
except ProviderError as e:
# Keep "which hop" in the message: that distinction is what made
# this diagnosable, since the two hops fail for different reasons.
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
f"download_asset({file_id})", f"download_asset({file_id})",
RuntimeError(f"Signed URL returned HTTP {resp.status_code}"), RuntimeError(f"Signed URL: {e.original}"),
) ) from e
mime = resp.headers.get("content-type") or None mime = resp.headers.get("content-type") or None
file_name = meta.get("file_name") or None file_name = meta.get("file_name") or None
logger.debug( logger.debug(
+40 -5
View File
@@ -2,6 +2,7 @@
import logging import logging
import os import os
from typing import Any
from curl_cffi import requests as curl_requests from curl_cffi import requests as curl_requests
@@ -38,7 +39,9 @@ class ClaudeProvider(BaseProvider):
Cloudflare's bot detection (same issue as chatgpt.com). Cloudflare's bot detection (same issue as chatgpt.com).
Authentication: sessionKey cookie (~30 day lifetime, opaque string). Authentication: sessionKey cookie (~30 day lifetime, opaque string).
Expiry cannot be decoded client-side — a 401 is the only signal. Expiry cannot be decoded client-side, so an API rejection is the only
signal — and claude.ai sends 403 permission_error / account_session_invalid
for an invalid session, not 401. See ``_is_auth_failure``.
""" """
provider_name = "claude" provider_name = "claude"
@@ -71,11 +74,41 @@ class ClaudeProvider(BaseProvider):
self._org_id: str | None = None # cached per session self._org_id: str | None = None # cached per session
logger.debug("[claude] Session initialised with Chrome TLS impersonation (key: [REDACTED])") logger.debug("[claude] Session initialised with Chrome TLS impersonation (key: [REDACTED])")
def _handle_401(self) -> None: # claude.ai answers an invalid or expired sessionKey with 403
# permission_error / account_session_invalid — never 401. Verified live
# 2026-09-20 against GET /api/organizations: a valid key returns 200, while
# an expired key, a garbage key and no cookie at all return byte-identical
# 403s carrying this code. Matching on the code rather than on the bare
# status keeps a genuine permission problem (which would carry a different
# code) reported as itself.
_SESSION_INVALID_CODE = "account_session_invalid"
def _is_auth_failure(self, response: Any) -> bool:
if response.status_code == 401:
return True
if response.status_code != 403:
return False
try:
body = response.json()
except Exception:
return False
if not isinstance(body, dict):
return False
error = body.get("error")
if not isinstance(error, dict):
return False
details = error.get("details")
if not isinstance(details, dict):
return False
return details.get("error_code") == self._SESSION_INVALID_CODE
def _handle_auth_failure(self, response: Any) -> None:
msg = ( msg = (
"[claude] Authentication failed (401 Unauthorized). " f"[claude] Authentication failed (HTTP {response.status_code}). "
"Your sessionKey has likely expired (~30 day lifetime). " "Your sessionKey has likely expired (~30 day lifetime). "
"Note: Claude session keys are opaque — a 401 is the only expiry signal. " "Note: Claude session keys are opaque, and claude.ai reports an "
"invalid one as 403 'Invalid authorization', not 401 — an API "
"rejection is the only expiry signal. "
"To refresh: open claude.ai in Chrome → F12 → Application → Cookies " "To refresh: open claude.ai in Chrome → F12 → Application → Cookies "
"→ find 'sessionKey' → copy the value. " "→ find 'sessionKey' → copy the value. "
"Then run 'ai-chat-exporter auth' or update CLAUDE_SESSION_KEY in .env." "Then run 'ai-chat-exporter auth' or update CLAUDE_SESSION_KEY in .env."
@@ -84,7 +117,9 @@ class ClaudeProvider(BaseProvider):
raise ProviderError( raise ProviderError(
self.provider_name, self.provider_name,
"authentication", "authentication",
RuntimeError("401 Unauthorized — Claude session key expired"), RuntimeError(
f"HTTP {response.status_code} — Claude session key expired or invalid"
),
) )
def _get_org_id(self) -> str: def _get_org_id(self) -> str:
+15 -1
View File
@@ -118,7 +118,10 @@ _HARNESS_TAG_RE = re.compile(
r"|<command-args>.*?</command-args>" r"|<command-args>.*?</command-args>"
r"|<command-contents>.*?</command-contents>" r"|<command-contents>.*?</command-contents>"
r"|<local-command-stdout>.*?</local-command-stdout>" r"|<local-command-stdout>.*?</local-command-stdout>"
r"|<system-reminder>.*?</system-reminder>", r"|<system-reminder>.*?</system-reminder>"
# A fork subagent's first user turn: generic worker rules ahead of the
# fork's actual "Your directive: …", which is kept.
r"|<fork-boilerplate>.*?</fork-boilerplate>",
re.DOTALL, re.DOTALL,
) )
@@ -530,6 +533,7 @@ def _extract_messages(
policy: str, policy: str,
subagents: dict | None = None, subagents: dict | None = None,
include_sidechain: bool = False, include_sidechain: bool = False,
expanding: frozenset[str] = frozenset(),
) -> list[dict]: ) -> list[dict]:
"""Normalize Claude Code records into messages. """Normalize Claude Code records into messages.
@@ -540,6 +544,13 @@ def _extract_messages(
those recursive subagent passes (subagent records are flagged those recursive subagent passes (subagent records are flagged
``isSidechain``); the top-level pass keeps skipping sidechain records so a ``isSidechain``); the top-level pass keeps skipping sidechain records so a
subagent is never also emitted as a stray top-level turn. subagent is never also emitted as a stray top-level turn.
``expanding`` holds the spawn ids of the subagents being folded around
this pass. A ``fork`` subagent's transcript opens with a copy of the parent
turn that spawned it, its own spawn call included; that copy is dropped,
since the enclosing subagent block already stands for it. Expanding it
again recursed without end (RecursionError, every daily sync from
2026-09-24).
""" """
subagents = subagents or {} subagents = subagents or {}
messages: list[dict] = [] messages: list[dict] = []
@@ -620,6 +631,8 @@ def _extract_messages(
elif item_type == "tool_use": elif item_type == "tool_use":
name = item.get("name") or "tool" name = item.get("name") or "tool"
tool_id = item.get("id") tool_id = item.get("id")
if tool_id in expanding:
continue
if name in _SUBAGENT_TOOL_NAMES and tool_id in subagents: if name in _SUBAGENT_TOOL_NAMES and tool_id in subagents:
# A Task/Agent spawn: fold its separate transcript inline # A Task/Agent spawn: fold its separate transcript inline
# instead of collapsing it. Its own tool traffic is # instead of collapsing it. Its own tool traffic is
@@ -633,6 +646,7 @@ def _extract_messages(
policy, policy,
subagents, subagents,
include_sidechain=True, include_sidechain=True,
expanding=expanding | {tool_id},
) )
blocks.append( blocks.append(
make_subagent_block( make_subagent_block(
+39
View File
@@ -0,0 +1,39 @@
"""Shared test fixtures.
The autouse fixture here exists because of a real incident (2026-08-18): the
`sync` CLI tests invoke the actual command, which calls `load_config()`, which
calls `load_dotenv()` — so the developer's real `.env` was loaded and its
`NTFY_TOPIC` used. Every `pytest` run fired real push notifications at the
developer's phone, including a fabricated "3 conversations failed to export"
from a fixture. Nothing appeared in the log to explain it, because the tests
pass `--no-log-file`.
The lesson generalises past ntfy: any test that exercises a command end to end
inherits whatever is in `.env` unless the environment is neutralised first.
"""
import pytest
@pytest.fixture(autouse=True)
def _no_outbound_side_effects(monkeypatch):
"""Neutralise every environment variable that could reach a real service.
Set to empty/unroutable values rather than deleted: `load_dotenv` is called
with ``override=False``, which only skips keys **already present** in the
environment. Deleting a key would let the developer's `.env` put it back.
Individual tests may still `monkeypatch.setenv` these — that is how the
notification tests point at a dead local port on purpose.
"""
# Notifications: an empty topic disables sending outright (`is_configured`
# strips and checks truthiness), and the server is pointed at a closed port
# so even a test that sets its own topic cannot reach the internet.
monkeypatch.setenv("NTFY_TOPIC", "")
monkeypatch.setenv("NTFY_SERVER", "http://127.0.0.1:9")
monkeypatch.setenv("NTFY_TOKEN", "")
# Joplin: a test that reached the developer's running desktop instance would
# create or overwrite real notes in their archive.
monkeypatch.setenv("JOPLIN_API_URL", "http://127.0.0.1:9")
monkeypatch.setenv("JOPLIN_API_TOKEN", "")
+94
View File
@@ -254,3 +254,97 @@ class TestReexportPreservesJoplinLink:
pending = tmp_cache.get_joplin_pending("chatgpt") pending = tmp_cache.get_joplin_pending("chatgpt")
assert len(pending) == 1 assert len(pending) == 1
assert pending[0][1]["joplin_note_id"] == "note-123" assert pending[0][1]["joplin_note_id"] == "note-123"
# ---------------------------------------------------------------------------
# Cross-run media retry.
#
# Before this, a transient asset failure was permanent: resolve_media kept a
# placeholder, mark_exported ran anyway, and the conversation was never looked
# at again. Two assets were lost that way on 2026-10-20 to a 500 and a 503 —
# both recoverable. The manifest now remembers which refs are still missing.
# ---------------------------------------------------------------------------
class TestPendingMediaRetry:
UPDATED = "2026-10-20T09:00:00Z"
def test_pending_media_forces_a_re_export(self, tmp_cache):
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False
def test_no_pending_media_is_an_ordinary_hit(self, tmp_cache):
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
def test_a_resolved_asset_clears_the_flag(self, tmp_cache):
"""The run that recovers the asset must stop the daily re-export."""
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
assert "pending_media" not in entry
def test_retries_are_capped_so_it_does_not_re_export_forever(self, tmp_cache):
"""An asset that 5xxs every morning must stop costing a re-export."""
from src.cache import MAX_MEDIA_RETRY_RUNS
for run in range(MAX_MEDIA_RETRY_RUNS):
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is False, (
f"run {run + 1} should still retry"
)
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
assert tmp_cache.is_cached("chatgpt", "conv-1", self.UPDATED) is True
def test_the_lost_refs_are_kept_as_a_record(self, tmp_cache):
from src.cache import MAX_MEDIA_RETRY_RUNS
for _ in range(MAX_MEDIA_RETRY_RUNS + 1):
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
assert entry["pending_media"] == ["sediment://file_x"]
def test_joplin_link_survives_a_media_re_export(self, tmp_cache):
"""Re-exporting to recover an asset must not orphan the Joplin note."""
tmp_cache.mark_exported("chatgpt", "conv-1", {"updated_at": self.UPDATED})
tmp_cache.mark_joplin_synced("chatgpt", "conv-1", "note-123")
tmp_cache.set_joplin_resources("chatgpt", "conv-1", {"media/a.png": "res-1"})
tmp_cache.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
entry = tmp_cache.get_all_entries("chatgpt")["conv-1"]
assert entry["joplin_note_id"] == "note-123"
# Resource ids are the idempotency record: without them the re-sync
# would re-upload every image in the note, not just the new one.
assert entry["joplin_resources"] == {"media/a.png": "res-1"}
def test_pending_media_survives_a_manifest_round_trip(self, tmp_path):
from src.cache import Cache
c = Cache(tmp_path)
c.mark_exported(
"chatgpt", "conv-1",
{"updated_at": self.UPDATED, "pending_media": ["sediment://file_x"]},
)
assert Cache(tmp_path).is_cached("chatgpt", "conv-1", self.UPDATED) is False
+53
View File
@@ -370,6 +370,59 @@ class TestSubagentFold:
] ]
assert "stray sidechain" not in top_text assert "stray sidechain" not in top_text
def test_fork_containing_its_own_spawn_call(self, tmp_path):
# The shape Claude Code writes for a `fork` subagent: a context-ref
# record, a copy of the parent turn holding the fork's own spawn call,
# then the directive behind <fork-boilerplate>. Folding that copied
# call recursed until RecursionError.
fork_records = [
{"type": "fork-context-ref", "agentId": "a1", "parentLastUuid": "u0"},
{
"type": "assistant", "isSidechain": True,
"message": {"role": "assistant", "content": [
{"type": "tool_use", "id": "toolu_sub1", "name": "Agent",
"input": {"description": "research"}},
]},
},
{
"type": "user", "isSidechain": True,
"timestamp": "2026-05-01T10:00:06.000Z",
"message": {"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_sub1",
"content": [{"type": "text", "text": "Fork started"}]},
{"type": "text", "text":
"<fork-boilerplate>\nYou are a worker fork.\n"
"</fork-boilerplate>\n\nYour directive: research X"},
]},
},
{
"type": "assistant", "isSidechain": True,
"timestamp": "2026-05-01T10:00:07.000Z",
"message": {"role": "assistant", "content": [
{"type": "text", "text": "Fork result."},
]},
},
]
f = _write_session(tmp_path, _parent_with_task(), name="parent-3")
_write_subagent(
f, "toolu_sub1", fork_records,
agent_type="fork", description="research X",
)
p = self._provider(tmp_path)
p.fetch_all_conversations()
conv = p.normalize_conversation(p.get_conversation("parent-3"))
subs = [
b for m in conv["messages"] for b in m["blocks"] if b["type"] == "subagent"
]
assert len(subs) == 1
inner = [bb for m in subs[0]["messages"] for bb in m["blocks"]]
assert not any(b["type"] == "subagent" for b in inner)
inner_text = [b.get("text") for b in inner]
assert "Your directive: research X" in inner_text
assert "Fork result." in inner_text
assert not any("worker fork" in (t or "") for t in inner_text)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Repo tags in title (Part C) # Repo tags in title (Part C)
+100
View File
@@ -624,3 +624,103 @@ class TestNotifySend:
monkeypatch.setenv("NTFY_TOPIC", "unit-test-topic") monkeypatch.setenv("NTFY_TOPIC", "unit-test-topic")
monkeypatch.setenv("NTFY_NOTIFY", "off") monkeypatch.setenv("NTFY_NOTIFY", "off")
assert notify_mod.is_configured() is False assert notify_mod.is_configured() is False
class TestNoRealNotificationsDuringTests:
"""Regression guard for the 2026-08-18 incident: the suite pushed to the
developer's real ntfy topic because `sync` loads `.env` via `load_dotenv`.
Asserts the conftest neutralisation holds even though a real `.env` with a
live NTFY_TOPIC sits beside the tests.
"""
def test_notifications_are_disabled(self):
from src import notify as notify_mod
assert notify_mod.is_configured() is False
def test_dotenv_cannot_reintroduce_a_topic(self):
"""`load_dotenv(override=False)` skips keys already present — including
empty ones. Deleting the var instead of emptying it would reopen this."""
import os
from dotenv import load_dotenv
from src import notify as notify_mod
load_dotenv(override=False)
assert os.getenv("NTFY_TOPIC", "").strip() == ""
assert notify_mod.is_configured() is False
def test_send_cannot_reach_the_public_server(self, monkeypatch):
"""Even a test that sets its own topic is pinned to a dead local port."""
import os
from src import notify as notify_mod
monkeypatch.setenv("NTFY_TOPIC", "some-topic")
assert "127.0.0.1" in os.getenv("NTFY_SERVER", "")
assert notify_mod.send("t", "m") is False
# ---------------------------------------------------------------------------
# Media failures in the push notification.
#
# On 2026-10-20 a scheduled run lost two assets and pushed a clean "OK" with
# no hint of it — the counts were in the log file only, which is a place you
# have to think to go and look. Counts only, never asset ids: a topic on
# public ntfy.sh is readable by anyone who knows its name.
# ---------------------------------------------------------------------------
class TestMediaFailuresInNotification:
def test_media_failures_appear_in_the_message(self):
from src.notify import format_summary
_, message, _, _ = format_summary(
{"chatgpt": {"exported": 24, "skipped": 826, "failed": 0, "media_failed": 2}},
None,
None,
)
assert "2 media failed" in message
def test_absent_when_there_are_none(self):
from src.notify import format_summary
_, message, _, _ = format_summary(
{"chatgpt": {"exported": 5, "skipped": 1, "failed": 0, "media_failed": 0}},
None,
None,
)
assert "media failed" not in message
def test_media_failures_alone_do_not_fail_the_run(self):
"""The transcript was archived and a transient asset retries later."""
from src.notify import format_summary
title, _, tags, priority = format_summary(
{"chatgpt": {"exported": 1, "skipped": 0, "failed": 0, "media_failed": 3}},
None,
None,
)
assert title.startswith("AI archive OK")
assert priority == "default"
assert tags == "white_check_mark"
def test_no_asset_ids_leak_into_the_payload(self):
from src.notify import format_summary
_, message, _, _ = format_summary(
{"chatgpt": {"exported": 1, "skipped": 0, "failed": 0, "media_failed": 1}},
None,
None,
)
assert "sediment" not in message and "file_" not in message
def test_older_summaries_without_the_key_still_render(self):
from src.notify import format_summary
_, message, _, _ = format_summary(
{"codex": {"exported": 0, "skipped": 29, "failed": 0}}, None, None
)
assert "codex: 0 exported, 29 up to date" in message
+163 -6
View File
@@ -100,7 +100,7 @@ class TestResolveMedia:
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
assert n == 1 assert n.downloaded == 1
assert report.media_downloaded == 1 assert report.media_downloaded == 1
assert block["local_path"] == "media/file_img1.png" assert block["local_path"] == "media/file_img1.png"
rendered = render_blocks_to_markdown([block]) rendered = render_blocks_to_markdown([block])
@@ -117,7 +117,7 @@ class TestResolveMedia:
report = LossReport() report = LossReport()
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
assert n == 0 assert n.downloaded == 0
assert "local_path" not in block assert "local_path" not in block
assert provider.calls == [] assert provider.calls == []
@@ -129,7 +129,7 @@ class TestResolveMedia:
report = LossReport() report = LossReport()
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "all", report) n = resolve_media(conv, provider, tmp_path, "provider/project/year", "all", report)
assert n == 1 assert n.downloaded == 1
assert block["local_path"] == "media/file_audio1.wav" assert block["local_path"] == "media/file_audio1.wav"
rendered = render_blocks_to_markdown([block]) rendered = render_blocks_to_markdown([block])
assert "media/file_audio1.wav" in rendered and rendered.startswith("> 📎") assert "media/file_audio1.wav" in rendered and rendered.startswith("> 📎")
@@ -139,7 +139,9 @@ class TestResolveMedia:
block = make_image_placeholder(ref="sediment://file_x", source="user_upload") block = make_image_placeholder(ref="sediment://file_x", source="user_upload")
conv = _conv_with([block]) conv = _conv_with([block])
report = LossReport() report = LossReport()
assert resolve_media(conv, provider, tmp_path, "provider/project/year", "off", report) == 0 assert resolve_media(
conv, provider, tmp_path, "provider/project/year", "off", report
).downloaded == 0
assert provider.calls == [] assert provider.calls == []
def test_idempotent_uses_disk(self, tmp_path): def test_idempotent_uses_disk(self, tmp_path):
@@ -164,7 +166,7 @@ class TestResolveMedia:
report = LossReport() report = LossReport()
n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report) n = resolve_media(conv, provider, tmp_path, "provider/project/year", "images", report)
assert n == 0 assert n.downloaded == 0
assert "local_path" not in block assert "local_path" not in block
assert report.media_failed["expired-or-missing"] == 1 assert report.media_failed["expired-or-missing"] == 1
# Still renders as a placeholder, not a broken image link # Still renders as a placeholder, not a broken image link
@@ -192,7 +194,9 @@ class TestResolveMedia:
pass pass
block = make_image_placeholder(ref="sediment://file_x", source="user_upload") block = make_image_placeholder(ref="sediment://file_x", source="user_upload")
conv = _conv_with([block]) conv = _conv_with([block])
assert resolve_media(conv, NoDownload(), tmp_path, "provider/project/year", "images", LossReport()) == 0 assert resolve_media(
conv, NoDownload(), tmp_path, "provider/project/year", "images", LossReport()
).downloaded == 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -262,3 +266,156 @@ class TestUploadMediaAndRewrite:
new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {}) new_body, res_map = upload_media_and_rewrite(body, tmp_path, client, {})
assert new_body == body assert new_body == body
assert res_map == {} assert res_map == {}
# ---------------------------------------------------------------------------
# Transient vs permanent failures, and the cross-run retry they feed.
#
# Measured 2026-10-20 in one scheduled run: a signed URL returned 500 on one
# asset and 503 on another. Both are recoverable, yet the conversation was
# marked exported, so the assets were lost for good. Permanent failures must
# stay out of the retry set — 19 assets in this archive 403/404 because their
# bytes were lost in a 2026-07-14 Library migration, and retrying those every
# morning would be noise that also trips the circuit breaker.
# ---------------------------------------------------------------------------
class TestTransientFailureRetry:
def _run(self, failure, tmp_path, circuit=None):
from src.media import resolve_media
ref = "sediment://file_x"
provider = _FakeProvider(fail_refs={ref: RuntimeError(failure)})
conv = _conv_with([make_image_placeholder(ref, "user_upload")])
report = LossReport()
outcome = resolve_media(
conv, provider, tmp_path, "provider/project/year", "images", report,
circuit=circuit,
)
return outcome, report, conv
def test_server_error_is_retryable(self, tmp_path):
outcome, report, conv = self._run("Signed URL: HTTP 500 after 5 retries", tmp_path)
assert outcome.failed == 1
assert outcome.retryable_refs == ["sediment://file_x"]
assert report.media_failed["download-error"] == 1
# Placeholder survives, so the ref is still in the Markdown.
assert "local_path" not in conv["messages"][0]["blocks"][0]
def test_service_unavailable_is_retryable(self, tmp_path):
outcome, _, _ = self._run("Signed URL: HTTP 503 after 5 retries", tmp_path)
assert outcome.retryable_refs == ["sediment://file_x"]
def test_timeout_is_retryable(self, tmp_path):
outcome, _, _ = self._run(
"Network error after 5 retries: Operation timed out", tmp_path
)
assert outcome.retryable_refs == ["sediment://file_x"]
def test_deleted_asset_is_not_retryable(self, tmp_path):
outcome, report, _ = self._run(
"Asset no longer exists — HTTP 404 'File not found'", tmp_path
)
assert outcome.failed == 1
assert outcome.retryable_refs == []
assert report.media_failed["expired-or-missing"] == 1
def test_forbidden_asset_is_not_retryable(self, tmp_path):
outcome, report, _ = self._run("HTTP 403 — detail: Forbidden", tmp_path)
assert outcome.retryable_refs == []
assert report.media_failed["forbidden"] == 1
def test_success_reports_nothing_retryable(self, tmp_path):
from src.media import resolve_media
ref = "sediment://file_x"
provider = _FakeProvider(assets={ref: (b"png", "image/png", None)})
conv = _conv_with([make_image_placeholder(ref, "user_upload")])
outcome = resolve_media(
conv, provider, tmp_path, "provider/project/year", "images", LossReport()
)
assert (outcome.downloaded, outcome.failed, outcome.retryable_refs) == (1, 0, [])
class TestMediaCircuit:
def test_trips_after_consecutive_transient_failures(self):
from src.media import MediaCircuit
c = MediaCircuit(threshold=3)
for _ in range(2):
c.record_failure("download-error")
assert not c.tripped
c.record_failure("download-error")
assert c.tripped
def test_success_resets_the_streak(self):
from src.media import MediaCircuit
c = MediaCircuit(threshold=3)
c.record_failure("download-error")
c.record_failure("download-error")
c.record_success()
c.record_failure("download-error")
assert not c.tripped, "scattered failures must not trip the breaker"
def test_permanent_failures_never_trip_it(self):
"""A deleted upload says nothing about the provider's health."""
from src.media import MediaCircuit
c = MediaCircuit(threshold=2)
for _ in range(10):
c.record_failure("expired-or-missing")
c.record_failure("forbidden")
assert not c.tripped
def test_open_circuit_skips_downloads_but_keeps_refs(self, tmp_path):
from src.media import CIRCUIT_OPEN_BUCKET, MediaCircuit, resolve_media
ref = "sediment://file_y"
provider = _FakeProvider(assets={ref: (b"png", "image/png", None)})
conv = _conv_with([make_image_placeholder(ref, "user_upload")])
circuit = MediaCircuit(threshold=1)
circuit.record_failure("download-error")
assert circuit.tripped
report = LossReport()
outcome = resolve_media(
conv, provider, tmp_path, "provider/project/year", "images", report,
circuit=circuit,
)
assert provider.calls == [], "must not touch the network while open"
assert outcome.downloaded == 0
assert outcome.retryable_refs == [ref], "skipped assets must not be abandoned"
assert report.media_failed[CIRCUIT_OPEN_BUCKET] == 1
assert circuit.skipped == 1
def test_already_downloaded_asset_served_while_open(self, tmp_path):
"""An open circuit must not stop on-disk media being linked."""
from src.media import MediaCircuit, resolve_media
ref = "sediment://file_z"
conv = _conv_with([make_image_placeholder(ref, "user_upload")])
# Derive the directory the way the exporter does rather than
# hardcoding it — the layout is "<provider>/<project>.<year>/".
from src.utils import build_export_path, generate_filename
media_dir = build_export_path(
tmp_path,
"chatgpt",
None,
"2026-05-20T00:00:00+00:00",
generate_filename("Has Media", "conv-1", "2026-05-20T00:00:00+00:00"),
"provider/project/year",
).parent / "media"
media_dir.mkdir(parents=True)
(media_dir / "file_z.png").write_bytes(b"already here")
circuit = MediaCircuit(threshold=1)
circuit.record_failure("download-error")
provider = _FakeProvider()
resolve_media(
conv, provider, tmp_path, "provider/project/year", "images", LossReport(),
circuit=circuit,
)
assert provider.calls == []
assert conv["messages"][0]["blocks"][0]["local_path"] == "media/file_z.png"
+362
View File
@@ -1184,6 +1184,133 @@ class TestErrorBodyDiagnostics:
assert "[REDACTED]" in described assert "[REDACTED]" in described
# ---------------------------------------------------------------------------
# Auth failure detection: claude.ai reports an invalid/expired sessionKey as
# 403 permission_error / account_session_invalid, never 401. Verified live
# 2026-09-20 against GET /api/organizations — a valid key returned 200, while
# an expired key, a garbage key and no cookie at all returned byte-identical
# 403s. Keying auth handling on 401 alone hid the refresh instructions behind
# a raw JSON dump.
# ---------------------------------------------------------------------------
class TestAuthFailureDetection:
class _Resp:
reason = ""
headers: dict = {}
def __init__(self, status, payload=None):
self.status_code = status
self.ok = 200 <= status < 400
self._payload = payload
self.text = ""
def json(self):
if self._payload is None:
raise ValueError("not json")
return self._payload
@staticmethod
def _bare(cls, response):
p = cls.__new__(cls)
p._request_delay = 0
p._last_request_at = None
p._session = type("S", (), {"request": lambda *a, **k: response})()
return p
@staticmethod
def _session_invalid(message="Invalid authorization"):
return {
"type": "error",
"error": {
"type": "permission_error",
"message": message,
"details": {
"error_code": "account_session_invalid",
"error_visibility": "user_facing",
},
},
}
def test_claude_403_session_invalid_is_an_auth_failure(self):
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, self._session_invalid())
prov = self._bare(ClaudeProvider, resp)
assert prov._is_auth_failure(resp) is True
with pytest.raises(ProviderError) as exc:
prov._make_request("GET", "https://claude.ai/api/organizations")
# The actionable message, not a dump of the response body.
assert exc.value.operation == "authentication"
assert "session key expired or invalid" in str(exc.value.original)
def test_claude_403_auth_failure_tells_the_user_how_to_refresh(self, caplog):
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, self._session_invalid())
with caplog.at_level(logging.ERROR):
with pytest.raises(ProviderError):
self._bare(ClaudeProvider, resp)._make_request(
"GET", "https://claude.ai/api/organizations"
)
logged = caplog.text
assert "sessionKey" in logged
assert "CLAUDE_SESSION_KEY" in logged
# States the status it actually saw, rather than claiming 401.
assert "403" in logged
assert "401 Unauthorized" not in logged
def test_claude_403_with_another_error_code_is_not_an_auth_failure(self):
"""A genuine permission problem must stay reported as itself."""
from src.providers.base import ProviderError
from src.providers.claude import ClaudeProvider
payload = {
"type": "error",
"error": {
"type": "permission_error",
"message": "Organization access denied",
"details": {"error_code": "org_access_denied"},
},
}
resp = self._Resp(403, payload)
prov = self._bare(ClaudeProvider, resp)
assert prov._is_auth_failure(resp) is False
with pytest.raises(ProviderError) as exc:
prov._make_request("GET", "https://claude.ai/api/organizations")
assert exc.value.operation != "authentication"
assert "Organization access denied" in str(exc.value.original)
def test_claude_403_with_unparseable_body_is_not_an_auth_failure(self):
from src.providers.claude import ClaudeProvider
resp = self._Resp(403, None)
assert self._bare(ClaudeProvider, resp)._is_auth_failure(resp) is False
def test_claude_401_is_still_an_auth_failure(self):
from src.providers.claude import ClaudeProvider
resp = self._Resp(401, {})
assert self._bare(ClaudeProvider, resp)._is_auth_failure(resp) is True
def test_chatgpt_403_is_not_an_auth_failure(self):
"""Guards the deleted-asset path: a media 403 is not an expired token."""
from src.providers.chatgpt import ChatGPTProvider
resp = self._Resp(403, {"detail": "Forbidden"})
assert self._bare(ChatGPTProvider, resp)._is_auth_failure(resp) is False
def test_chatgpt_401_is_an_auth_failure(self):
from src.providers.chatgpt import ChatGPTProvider
resp = self._Resp(401, {})
assert self._bare(ChatGPTProvider, resp)._is_auth_failure(resp) is True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Deleted assets: ChatGPT's download endpoint answers a missing upload with # Deleted assets: ChatGPT's download endpoint answers a missing upload with
# 403 Forbidden, not 404. Measured 2026-08-17 over 18 such assets — every one # 403 Forbidden, not 404. Measured 2026-08-17 over 18 such assets — every one
@@ -1272,6 +1399,8 @@ class TestDeletedAssetReporting:
"""The probe costs a request — it must not fire on a good download.""" """The probe costs a request — it must not fire on a good download."""
class _Bytes: class _Bytes:
status_code = 200 status_code = 200
ok = True
reason = "OK"
content = b"data" content = b"data"
headers = {"content-type": "image/png"} headers = {"content-type": "image/png"}
@@ -1379,3 +1508,236 @@ class TestProjectAttribution:
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
p.normalize_conversation(self._raw(gizmo_id="g-p-known")) p.normalize_conversation(self._raw(gizmo_id="g-p-known"))
assert not [r for r in caplog.records if "not in CHATGPT_PROJECT_IDS" in r.message] assert not [r for r in caplog.records if "not in CHATGPT_PROJECT_IDS" in r.message]
# ---------------------------------------------------------------------------
# Asset download retries. Measured 2026-10-20 in one scheduled run:
#
# 09:04:05 .../files/<id>/download → 500 Retry 1/3 in 2.2s
# 09:04:17 → 500 Retry 2/3 in 4.3s
# 09:04:32 → 500 Retry 3/3 in 9.1s
# 09:04:42 → 200 ← metadata hop recovered
# 09:04:47 WARNING Signed URL returned HTTP 500 ← second hop, no retry
#
# The metadata hop went through _make_request and won; the signed-URL hop was
# a bare request and lost on its first try. A second asset lost the same way
# to a 503, and a third timed out — and because curl_cffi's RequestException
# subclasses OSError, that timeout escaped resolve_media's `except
# ProviderError` and was reported by the export loop as "File write failed",
# aborting a conversation that had already downloaded ~20 images.
# ---------------------------------------------------------------------------
class TestAssetDownloadRetries:
class _Resp:
def __init__(self, status=200, payload=None, content=b"", headers=None):
self.status_code = status
self.ok = 200 <= status < 400
self.reason = ""
self.content = content
self.text = ""
self.headers = headers or {}
self._payload = payload
def json(self):
if self._payload is None:
raise ValueError("not json")
return self._payload
class _ScriptedSession:
"""Returns scripted responses in order; an Exception entry is raised."""
def __init__(self, script):
self.script = list(script)
self.requests = []
self.headers = {}
def request(self, method, url, **kwargs):
self.requests.append((method, url, kwargs))
item = self.script.pop(0)
if isinstance(item, BaseException):
raise item
return item
def _provider(self, script):
from src.providers.chatgpt import ChatGPTProvider
p = ChatGPTProvider.__new__(ChatGPTProvider)
p._request_delay = 0
p._last_request_at = None
p._session = self._ScriptedSession(script)
return p
def _meta(self):
"""Hop 1: metadata response carrying the signed download_url."""
return self._Resp(payload={"download_url": "https://files.example/signed"})
def test_signed_url_5xx_is_retried_and_recovers(self, monkeypatch):
"""The exact 2026-10-20 failure: hop 2 500s once, then succeeds."""
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = self._provider([
self._meta(),
self._Resp(500),
self._Resp(200, content=b"PNGDATA", headers={"content-type": "image/png"}),
])
content, mime, _ = p.download_asset("sediment://file_x")
assert content == b"PNGDATA"
assert mime == "image/png"
assert len(p._session.requests) == 3, "hop 2 must be retried, not abandoned"
def test_signed_url_503_is_retried(self, monkeypatch):
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = self._provider([
self._meta(),
self._Resp(503),
self._Resp(200, content=b"OK", headers={"content-type": "image/png"}),
])
assert p.download_asset("sediment://file_x")[0] == b"OK"
def test_signed_url_timeout_raises_provider_error_not_oserror(self, monkeypatch):
"""The conversation-killer: a hop-2 timeout must be a ProviderError.
curl_cffi's RequestException is an OSError subclass, so an un-wrapped
timeout here bypassed resolve_media's handler and was caught by the
export loop's `except OSError` as "File write failed".
"""
from curl_cffi.requests.exceptions import Timeout
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
assert issubclass(Timeout, OSError), "premise of this test"
p = self._provider([self._meta()] + [Timeout("timed out")] * 8)
with pytest.raises(ProviderError) as exc:
p.download_asset("sediment://file_x")
assert "Signed URL" in str(exc.value.original), "must name the failing hop"
def test_which_hop_failed_stays_in_the_message(self, monkeypatch):
"""Hop 1 vs hop 2 fail for different reasons; the message must say which."""
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = self._provider([self._meta()] + [self._Resp(500)] * 8)
with pytest.raises(ProviderError) as exc:
p.download_asset("sediment://file_x")
detail = str(exc.value.original)
assert detail.startswith("Signed URL:")
assert "500" in detail
def test_hop_two_is_paced_once_per_attempt_not_twice(self, monkeypatch):
"""Pacing twice per asset would add REQUEST_DELAY to every image."""
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = self._provider([
self._meta(),
self._Resp(200, content=b"X", headers={"content-type": "image/png"}),
])
calls = []
real_pace = p._pace
monkeypatch.setattr(p, "_pace", lambda: calls.append(1) or real_pace())
p.download_asset("sediment://file_x")
assert len(calls) == len(p._session.requests) == 2
class TestRetryLadder:
def test_immediate_first_retry_costs_nothing(self):
from src.providers.base import BaseProvider
assert BaseProvider._backoff_wait(None, 1, immediate_first=True) == 0.0
def test_ladder_shifts_down_so_it_reads_0_2_4_8_16(self):
from src.providers.base import BaseProvider
for attempt, lo in ((2, 2), (3, 4), (4, 8), (5, 16)):
wait = BaseProvider._backoff_wait(None, attempt, immediate_first=True)
assert lo <= wait <= lo * 1.3, f"attempt {attempt} → {wait}"
def test_jitter_is_never_dropped(self):
"""A storage incident fails many assets; un-jittered retries sync up."""
from src.providers.base import BaseProvider
waits = {
BaseProvider._backoff_wait(None, 3, immediate_first=True)
for _ in range(25)
}
assert len(waits) > 1
def test_conversation_api_ladder_is_unchanged(self):
from src.providers.base import BaseProvider
assert 2 <= BaseProvider._backoff_wait(None, 1) <= 2.6
assert 4 <= BaseProvider._backoff_wait(None, 2) <= 5.2
def test_retry_after_wins_over_the_immediate_retry(self):
"""A server naming a time knows better than we do; 503s often do."""
from src.providers.base import BaseProvider
class _R:
headers = {"Retry-After": "7"}
assert BaseProvider._backoff_wait(_R(), 1, immediate_first=True) == 7.0
class TestMediaTimeBudget:
def test_budget_refuses_a_wait_it_cannot_afford(self, monkeypatch):
"""Bounds the timeout case, which costs 40s a go and has no cheap end."""
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = TestAssetDownloadRetries()._provider([
TestAssetDownloadRetries._Resp(500) for _ in range(9)
])
# First backoff is 2-2.6s, which does not fit in a 1s budget.
with pytest.raises(ProviderError):
p._send_with_retries(
"GET", "https://files.example/signed",
max_retries=5, time_budget=1.0,
)
assert len(p._session.requests) == 1
def test_budget_still_allows_the_free_immediate_retry(self, monkeypatch):
"""The 0s retry costs nothing, so a small budget can still fund it."""
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = TestAssetDownloadRetries()._provider([
TestAssetDownloadRetries._Resp(500) for _ in range(9)
])
with pytest.raises(ProviderError):
p._send_with_retries(
"GET", "https://files.example/signed",
max_retries=5, immediate_first_retry=True, time_budget=1.0,
)
# Attempt 1, free retry, then the 2s backoff exceeds what is left.
assert len(p._session.requests) == 2
def test_read_timeout_is_clamped_to_the_remaining_budget(self, monkeypatch):
"""So a final attempt cannot overrun the budget by a whole timeout."""
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = TestAssetDownloadRetries()._provider([
TestAssetDownloadRetries._Resp(500) for _ in range(9)
])
with pytest.raises(ProviderError):
p._send_with_retries(
"GET", "https://files.example/signed",
max_retries=5, time_budget=3.0, timeout=(10, 20),
)
_, _, kwargs = p._session.requests[0]
connect_to, read_to = kwargs["timeout"]
assert connect_to == 10
assert read_to <= 3.0, "read timeout must not exceed the budget"
def test_without_a_budget_the_full_ladder_runs(self, monkeypatch):
from src.providers.base import ProviderError
monkeypatch.setattr("src.providers.base.time.sleep", lambda s: None)
p = TestAssetDownloadRetries()._provider([
TestAssetDownloadRetries._Resp(500) for _ in range(9)
])
with pytest.raises(ProviderError):
p._send_with_retries(
"GET", "https://files.example/signed",
max_retries=5, immediate_first_retry=True,
)
assert len(p._session.requests) == 6, "5 retries = 6 attempts"