convolvger 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- convolvger-0.1.0/LICENSE +21 -0
- convolvger-0.1.0/PKG-INFO +86 -0
- convolvger-0.1.0/README.md +58 -0
- convolvger-0.1.0/pyproject.toml +72 -0
- convolvger-0.1.0/pyproject.toml.orig +61 -0
- convolvger-0.1.0/src/convolvger/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/cli/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/cli/main.py +223 -0
- convolvger-0.1.0/src/convolvger/core/__init__.py +33 -0
- convolvger-0.1.0/src/convolvger/core/archive.py +106 -0
- convolvger-0.1.0/src/convolvger/core/errors.py +6 -0
- convolvger-0.1.0/src/convolvger/core/findings.py +83 -0
- convolvger-0.1.0/src/convolvger/core/models.py +107 -0
- convolvger-0.1.0/src/convolvger/core/results.py +39 -0
- convolvger-0.1.0/src/convolvger/core/source.py +14 -0
- convolvger-0.1.0/src/convolvger/providers/__init__.py +7 -0
- convolvger-0.1.0/src/convolvger/providers/base.py +26 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/__init__.py +3 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/_content.py +111 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/_fetch.py +83 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/_parse.py +156 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/_turbostream.py +148 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/_urls.py +30 -0
- convolvger-0.1.0/src/convolvger/providers/chatgpt/provider.py +22 -0
- convolvger-0.1.0/src/convolvger/providers/claude/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/providers/default.py +15 -0
- convolvger-0.1.0/src/convolvger/providers/gemini/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/providers/grok/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/providers/registry.py +33 -0
- convolvger-0.1.0/src/convolvger/py.typed +0 -0
- convolvger-0.1.0/src/convolvger/renderers/__init__.py +4 -0
- convolvger-0.1.0/src/convolvger/renderers/json.py +35 -0
- convolvger-0.1.0/src/convolvger/renderers/markdown.py +175 -0
- convolvger-0.1.0/src/convolvger/storage/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/validation/__init__.py +0 -0
- convolvger-0.1.0/src/convolvger/validation/aspects.py +70 -0
- convolvger-0.1.0/src/convolvger/validation/report.py +53 -0
convolvger-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Fahad Bilal Saleem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: convolvger
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first, provider-independent archival for public AI conversations
|
|
5
|
+
Keywords: archival,chatgpt,cli,conversations,digital-preservation
|
|
6
|
+
Author: Fahad Bilal Saleem
|
|
7
|
+
Author-email: Fahad Bilal Saleem <contact@fahadbilal.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: System :: Archiving
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: httpx>=0.28.1
|
|
19
|
+
Requires-Dist: pydantic>=2.13.5
|
|
20
|
+
Requires-Dist: rich>=15.0.0
|
|
21
|
+
Requires-Dist: typer>=0.27.2
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/bilalmughal1/convolvger
|
|
24
|
+
Project-URL: Repository, https://github.com/bilalmughal1/convolvger
|
|
25
|
+
Project-URL: Issues, https://github.com/bilalmughal1/convolvger/issues
|
|
26
|
+
Project-URL: Changelog, https://github.com/bilalmughal1/convolvger/blob/main/CHANGELOG.md
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Convolvger
|
|
30
|
+
|
|
31
|
+
Local-first, provider-independent archival for public AI conversations.
|
|
32
|
+
|
|
33
|
+
Convolvger takes a public AI conversation share URL, reconstructs the conversation into a canonical internal model, validates the extracted data, and exports it into portable formats such as Markdown and JSON.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
or `pip install convolvger`. Requires Python 3.12 or newer.
|
|
38
|
+
|
|
39
|
+
## Goals
|
|
40
|
+
|
|
41
|
+
- Archive public AI conversations locally
|
|
42
|
+
- Support multiple AI providers through isolated adapters
|
|
43
|
+
- Preserve conversation structure and exposed content
|
|
44
|
+
- Detect incomplete or inconsistent extraction
|
|
45
|
+
- Produce deterministic, provider-independent output
|
|
46
|
+
- Keep conversion entirely local, with no LLM required
|
|
47
|
+
- Avoid account credentials and telemetry
|
|
48
|
+
|
|
49
|
+
## What a snapshot is
|
|
50
|
+
|
|
51
|
+
Convolvger archives *public share snapshots*. A snapshot is what a provider chooses to expose at a given URL at a given moment — not the conversation itself, and not a guarantee.
|
|
52
|
+
|
|
53
|
+
Share pages can change after they are published. While building the ChatGPT adapter we fetched the same share link twice, eight hours apart. Both responses had the same conversation, the same title, and the same 31 message IDs. Four assistant messages addressed to a tool were served the second time with their payload emptied and their content type relabelled from `code` to `text` — while their IDs, roles, recipients, statuses, weights and every metadata key stayed identical. A comparison of anything but the content would have called the two snapshots the same file. Nothing about the conversation had changed; the provider's rendering of it had.
|
|
54
|
+
|
|
55
|
+
That is why every Convolvger export records when it was retrieved, reports every message it could not render, and never silently drops content. An archive is a claim about what was there when you looked. It is only trustworthy if it also says when it looked and what it could not see.
|
|
56
|
+
|
|
57
|
+
Convolvger does not recover private conversations, hidden model state, chain-of-thought, deleted content, or attachments the provider does not publish. If it is not in the snapshot, it is not in the archive.
|
|
58
|
+
|
|
59
|
+
## Supported Providers
|
|
60
|
+
|
|
61
|
+
Initial providers:
|
|
62
|
+
|
|
63
|
+
- ChatGPT
|
|
64
|
+
- Claude
|
|
65
|
+
- Gemini
|
|
66
|
+
- Grok
|
|
67
|
+
|
|
68
|
+
Additional providers will be added as their public sharing formats are supported and tested.
|
|
69
|
+
|
|
70
|
+
## Status
|
|
71
|
+
|
|
72
|
+
Convolvger is under active development and not yet ready for general use.
|
|
73
|
+
|
|
74
|
+
Working today: `convolvger providers`, `convolvger inspect URL`, `convolvger export URL` with `-o PATH` or `-o -` for stdout, `-f md` or `-f json`, plus `--include-hidden` and `--include-inactive`, and `convolvger verify PATH`.
|
|
75
|
+
|
|
76
|
+
ChatGPT share links can be fetched, parsed, and exported. Markdown is a reader-facing document that may omit content and reports every omission in its header; JSON is the complete archival record and omits nothing the model holds. The remaining providers are not implemented yet.
|
|
77
|
+
|
|
78
|
+
Extraction records structured findings, each with a stable code and a level. A note is recorded but does not change the exit status; a warning does. Both are written into the JSON archive either way, so nothing is withheld from the record because it was judged unremarkable.
|
|
79
|
+
|
|
80
|
+
`convolvger verify PATH` reads a JSON archive and reports what it records, without re-fetching or re-parsing anything. It answers two questions separately: whether the provider served everything its own snapshot structure referenced, and whether everything it served could be modelled. Findings that indicate neither are listed in full and counted toward neither answer, so an archive whose messages are mostly empty by design — which is how a public share snapshot normally arrives — is still reported complete. A finding whose code this version does not recognise changes no answer, since an archive may have been written by a later version. An archive declaring a schema version this version cannot read is refused rather than given a verdict.
|
|
81
|
+
|
|
82
|
+
Exit codes: 0 clean, 2 completed with warnings, 1 failed. For `verify`, 2 means a check did not pass, and 1 means the file could not be read as an archive at all.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Convolvger
|
|
2
|
+
|
|
3
|
+
Local-first, provider-independent archival for public AI conversations.
|
|
4
|
+
|
|
5
|
+
Convolvger takes a public AI conversation share URL, reconstructs the conversation into a canonical internal model, validates the extracted data, and exports it into portable formats such as Markdown and JSON.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
or `pip install convolvger`. Requires Python 3.12 or newer.
|
|
10
|
+
|
|
11
|
+
## Goals
|
|
12
|
+
|
|
13
|
+
- Archive public AI conversations locally
|
|
14
|
+
- Support multiple AI providers through isolated adapters
|
|
15
|
+
- Preserve conversation structure and exposed content
|
|
16
|
+
- Detect incomplete or inconsistent extraction
|
|
17
|
+
- Produce deterministic, provider-independent output
|
|
18
|
+
- Keep conversion entirely local, with no LLM required
|
|
19
|
+
- Avoid account credentials and telemetry
|
|
20
|
+
|
|
21
|
+
## What a snapshot is
|
|
22
|
+
|
|
23
|
+
Convolvger archives *public share snapshots*. A snapshot is what a provider chooses to expose at a given URL at a given moment — not the conversation itself, and not a guarantee.
|
|
24
|
+
|
|
25
|
+
Share pages can change after they are published. While building the ChatGPT adapter we fetched the same share link twice, eight hours apart. Both responses had the same conversation, the same title, and the same 31 message IDs. Four assistant messages addressed to a tool were served the second time with their payload emptied and their content type relabelled from `code` to `text` — while their IDs, roles, recipients, statuses, weights and every metadata key stayed identical. A comparison of anything but the content would have called the two snapshots the same file. Nothing about the conversation had changed; the provider's rendering of it had.
|
|
26
|
+
|
|
27
|
+
That is why every Convolvger export records when it was retrieved, reports every message it could not render, and never silently drops content. An archive is a claim about what was there when you looked. It is only trustworthy if it also says when it looked and what it could not see.
|
|
28
|
+
|
|
29
|
+
Convolvger does not recover private conversations, hidden model state, chain-of-thought, deleted content, or attachments the provider does not publish. If it is not in the snapshot, it is not in the archive.
|
|
30
|
+
|
|
31
|
+
## Supported Providers
|
|
32
|
+
|
|
33
|
+
Initial providers:
|
|
34
|
+
|
|
35
|
+
- ChatGPT
|
|
36
|
+
- Claude
|
|
37
|
+
- Gemini
|
|
38
|
+
- Grok
|
|
39
|
+
|
|
40
|
+
Additional providers will be added as their public sharing formats are supported and tested.
|
|
41
|
+
|
|
42
|
+
## Status
|
|
43
|
+
|
|
44
|
+
Convolvger is under active development and not yet ready for general use.
|
|
45
|
+
|
|
46
|
+
Working today: `convolvger providers`, `convolvger inspect URL`, `convolvger export URL` with `-o PATH` or `-o -` for stdout, `-f md` or `-f json`, plus `--include-hidden` and `--include-inactive`, and `convolvger verify PATH`.
|
|
47
|
+
|
|
48
|
+
ChatGPT share links can be fetched, parsed, and exported. Markdown is a reader-facing document that may omit content and reports every omission in its header; JSON is the complete archival record and omits nothing the model holds. The remaining providers are not implemented yet.
|
|
49
|
+
|
|
50
|
+
Extraction records structured findings, each with a stable code and a level. A note is recorded but does not change the exit status; a warning does. Both are written into the JSON archive either way, so nothing is withheld from the record because it was judged unremarkable.
|
|
51
|
+
|
|
52
|
+
`convolvger verify PATH` reads a JSON archive and reports what it records, without re-fetching or re-parsing anything. It answers two questions separately: whether the provider served everything its own snapshot structure referenced, and whether everything it served could be modelled. Findings that indicate neither are listed in full and counted toward neither answer, so an archive whose messages are mostly empty by design — which is how a public share snapshot normally arrives — is still reported complete. A finding whose code this version does not recognise changes no answer, since an archive may have been written by a later version. An archive declaring a schema version this version cannot read is refused rather than given a verdict.
|
|
53
|
+
|
|
54
|
+
Exit codes: 0 clean, 2 completed with warnings, 1 failed. For `verify`, 2 means a check did not pass, and 1 means the file could not be read as an archive at all.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "convolvger"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local-first, provider-independent archival for public AI conversations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = [
|
|
10
|
+
"archival",
|
|
11
|
+
"chatgpt",
|
|
12
|
+
"cli",
|
|
13
|
+
"conversations",
|
|
14
|
+
"digital-preservation",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Intended Audience :: End Users/Desktop",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Topic :: System :: Archiving",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"httpx>=0.28.1",
|
|
28
|
+
"pydantic>=2.13.5",
|
|
29
|
+
"rich>=15.0.0",
|
|
30
|
+
"typer>=0.27.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[[project.authors]]
|
|
34
|
+
name = "Fahad Bilal Saleem"
|
|
35
|
+
email = "contact@fahadbilal.com"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
convolvger = "convolvger.cli.main:app"
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/bilalmughal1/convolvger"
|
|
42
|
+
Repository = "https://github.com/bilalmughal1/convolvger"
|
|
43
|
+
Issues = "https://github.com/bilalmughal1/convolvger/issues"
|
|
44
|
+
Changelog = "https://github.com/bilalmughal1/convolvger/blob/main/CHANGELOG.md"
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = [
|
|
52
|
+
"E4",
|
|
53
|
+
"E7",
|
|
54
|
+
"E9",
|
|
55
|
+
"F",
|
|
56
|
+
"I",
|
|
57
|
+
"UP",
|
|
58
|
+
"B",
|
|
59
|
+
"SIM",
|
|
60
|
+
"RUF",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.mypy]
|
|
64
|
+
strict = true
|
|
65
|
+
|
|
66
|
+
[dependency-groups]
|
|
67
|
+
dev = [
|
|
68
|
+
"mypy>=2.3.1",
|
|
69
|
+
"pytest>=9.1.1",
|
|
70
|
+
"pytest-cov>=7.1.0",
|
|
71
|
+
"ruff>=0.16.7",
|
|
72
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "convolvger"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local-first, provider-independent archival for public AI conversations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Fahad Bilal Saleem", email = "contact@fahadbilal.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = [
|
|
13
|
+
"archival",
|
|
14
|
+
"chatgpt",
|
|
15
|
+
"cli",
|
|
16
|
+
"conversations",
|
|
17
|
+
"digital-preservation",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: End Users/Desktop",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Topic :: System :: Archiving",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [
|
|
30
|
+
"httpx>=0.28.1",
|
|
31
|
+
"pydantic>=2.13.5",
|
|
32
|
+
"rich>=15.0.0",
|
|
33
|
+
"typer>=0.27.2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
convolvger = "convolvger.cli.main:app"
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://github.com/bilalmughal1/convolvger"
|
|
41
|
+
Repository = "https://github.com/bilalmughal1/convolvger"
|
|
42
|
+
Issues = "https://github.com/bilalmughal1/convolvger/issues"
|
|
43
|
+
Changelog = "https://github.com/bilalmughal1/convolvger/blob/main/CHANGELOG.md"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.12.13,<0.13.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint]
|
|
50
|
+
select = ["E4", "E7", "E9", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
strict = true
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = [
|
|
57
|
+
"mypy>=2.3.1",
|
|
58
|
+
"pytest>=9.1.1",
|
|
59
|
+
"pytest-cov>=7.1.0",
|
|
60
|
+
"ruff>=0.16.7",
|
|
61
|
+
]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Command line interface for Convolvger."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from convolvger.core.archive import ArchiveError, load_archive
|
|
11
|
+
from convolvger.core.errors import ConvolvgerError
|
|
12
|
+
from convolvger.core.findings import Finding, Level
|
|
13
|
+
from convolvger.core.results import ParseError, ParseResult
|
|
14
|
+
from convolvger.core.source import RawSource
|
|
15
|
+
from convolvger.providers.default import build_registry
|
|
16
|
+
from convolvger.renderers import render_json, render_markdown
|
|
17
|
+
from convolvger.validation.report import Report
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_FAILED = 1
|
|
21
|
+
EXIT_WARNINGS = 2
|
|
22
|
+
|
|
23
|
+
FORMATS = ("md", "json")
|
|
24
|
+
FORMAT_HELP = f"Output format ({', '.join(FORMATS)})."
|
|
25
|
+
|
|
26
|
+
app = typer.Typer(
|
|
27
|
+
name="convolvger",
|
|
28
|
+
help="Archive public AI conversations into portable, provider-independent formats.",
|
|
29
|
+
no_args_is_help=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _slug(title: str | None, fallback: str) -> str:
|
|
34
|
+
base = (title or fallback).lower()
|
|
35
|
+
base = re.sub(r"[^a-z0-9]+", "-", base).strip("-")
|
|
36
|
+
return base[:60] or fallback
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _warn(message: str) -> None:
|
|
40
|
+
typer.echo(message, err=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _retrieve(source: str) -> tuple[RawSource, ParseResult]:
|
|
44
|
+
"""Detect, fetch, and parse a conversation source."""
|
|
45
|
+
registry = build_registry()
|
|
46
|
+
provider = registry.detect(source)
|
|
47
|
+
raw = provider.fetch(source)
|
|
48
|
+
return raw, provider.parse(raw)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _report_findings(findings: list[Finding]) -> None:
|
|
52
|
+
"""Report every finding, loudest first.
|
|
53
|
+
|
|
54
|
+
Notes are printed as well as warnings: a note does not change the
|
|
55
|
+
exit status, but it is still something the snapshot did not carry
|
|
56
|
+
and the operator should be able to see it.
|
|
57
|
+
"""
|
|
58
|
+
if not findings:
|
|
59
|
+
return
|
|
60
|
+
warnings = [item for item in findings if item.level is Level.WARNING]
|
|
61
|
+
notes = [item for item in findings if item.level is Level.NOTE]
|
|
62
|
+
counts = [
|
|
63
|
+
f"{len(group)} {label}"
|
|
64
|
+
for group, label in ((warnings, "warning(s)"), (notes, "note(s)"))
|
|
65
|
+
if group
|
|
66
|
+
]
|
|
67
|
+
_warn(f"\n{', '.join(counts)}:")
|
|
68
|
+
for item in warnings + notes:
|
|
69
|
+
where = f" ({item.message_id})" if item.message_id else ""
|
|
70
|
+
_warn(f" - [{item.level.value}] {item.code}{where}: {item.message}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fail(error: Exception) -> None:
|
|
74
|
+
_warn(f"Error: {error}")
|
|
75
|
+
if isinstance(error, ParseError) and error.findings:
|
|
76
|
+
_report_findings(error.findings)
|
|
77
|
+
raise typer.Exit(EXIT_FAILED)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def providers() -> None:
|
|
82
|
+
"""List supported conversation providers."""
|
|
83
|
+
for name in build_registry().names():
|
|
84
|
+
typer.echo(name)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def inspect(
|
|
89
|
+
source: Annotated[
|
|
90
|
+
str,
|
|
91
|
+
typer.Argument(help="Public conversation URL or local conversation source."),
|
|
92
|
+
],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Inspect a conversation source without exporting it."""
|
|
95
|
+
try:
|
|
96
|
+
_, result = _retrieve(source)
|
|
97
|
+
except ConvolvgerError as error:
|
|
98
|
+
_fail(error)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
conversation = result.conversation
|
|
102
|
+
typer.echo(f"Title: {conversation.title or '(untitled)'}")
|
|
103
|
+
typer.echo(f"Provider: {conversation.provider}")
|
|
104
|
+
typer.echo(f"Messages: {len(conversation.messages)}")
|
|
105
|
+
typer.echo(f"Visible: {sum(m.visible for m in conversation.messages)}")
|
|
106
|
+
typer.echo(f"Active: {sum(m.active for m in conversation.messages)}")
|
|
107
|
+
typer.echo(f"Blocks: {sum(len(m.content) for m in conversation.messages)}")
|
|
108
|
+
|
|
109
|
+
_report_findings(result.findings)
|
|
110
|
+
raise typer.Exit(EXIT_WARNINGS if result.warned else EXIT_OK)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def export(
|
|
115
|
+
source: Annotated[
|
|
116
|
+
str,
|
|
117
|
+
typer.Argument(help="Public conversation URL or local conversation source."),
|
|
118
|
+
],
|
|
119
|
+
output: Annotated[
|
|
120
|
+
Path | None,
|
|
121
|
+
typer.Option("--output", "-o", help="Output path, or - for stdout."),
|
|
122
|
+
] = None,
|
|
123
|
+
output_format: Annotated[
|
|
124
|
+
str,
|
|
125
|
+
typer.Option("--format", "-f", help=FORMAT_HELP),
|
|
126
|
+
] = "md",
|
|
127
|
+
include_hidden: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
typer.Option("--include-hidden", help="Include provider-hidden messages."),
|
|
130
|
+
] = False,
|
|
131
|
+
include_inactive: Annotated[
|
|
132
|
+
bool,
|
|
133
|
+
typer.Option("--include-inactive", help="Include deactivated branches."),
|
|
134
|
+
] = False,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Export a conversation to a portable archive."""
|
|
137
|
+
if output_format not in FORMATS:
|
|
138
|
+
_warn(f"Error: unsupported format: {output_format}")
|
|
139
|
+
_warn(f"Supported formats: {', '.join(FORMATS)}")
|
|
140
|
+
raise typer.Exit(EXIT_FAILED)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
raw, result = _retrieve(source)
|
|
144
|
+
except ConvolvgerError as error:
|
|
145
|
+
_fail(error)
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
if output_format == "json":
|
|
149
|
+
if include_hidden or include_inactive:
|
|
150
|
+
_warn("Note: include flags are ignored; JSON keeps every message.")
|
|
151
|
+
rendered = render_json(
|
|
152
|
+
result.conversation,
|
|
153
|
+
findings=result.findings,
|
|
154
|
+
fetched_at=raw.fetched_at,
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
rendered = render_markdown(
|
|
158
|
+
result.conversation,
|
|
159
|
+
include_hidden=include_hidden,
|
|
160
|
+
include_inactive=include_inactive,
|
|
161
|
+
findings=result.findings,
|
|
162
|
+
fetched_at=raw.fetched_at,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if output is not None and str(output) == "-":
|
|
166
|
+
sys.stdout.write(rendered)
|
|
167
|
+
else:
|
|
168
|
+
destination = output or Path(
|
|
169
|
+
f"{_slug(result.conversation.title, result.conversation.provider)}.{output_format}"
|
|
170
|
+
)
|
|
171
|
+
destination.write_text(rendered, encoding="utf-8")
|
|
172
|
+
_warn(f"Wrote {destination}")
|
|
173
|
+
|
|
174
|
+
_report_findings(result.findings)
|
|
175
|
+
raise typer.Exit(EXIT_WARNINGS if result.warned else EXIT_OK)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _yes_no(value: bool) -> str:
|
|
179
|
+
return "yes" if value else "no"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
def verify(
|
|
184
|
+
archive: Annotated[
|
|
185
|
+
Path,
|
|
186
|
+
typer.Argument(help="Path to a JSON archive written by convolvger."),
|
|
187
|
+
],
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Report what a JSON archive says about its own integrity.
|
|
190
|
+
|
|
191
|
+
The verdict is derived from the findings the archive already
|
|
192
|
+
carries. Nothing is re-fetched and nothing is re-parsed: this reads
|
|
193
|
+
a file and reports what it records.
|
|
194
|
+
"""
|
|
195
|
+
try:
|
|
196
|
+
envelope = load_archive(archive.read_text(encoding="utf-8"))
|
|
197
|
+
except (ArchiveError, OSError) as error:
|
|
198
|
+
_fail(error)
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
report = Report(findings=envelope.findings)
|
|
202
|
+
conversation = envelope.conversation
|
|
203
|
+
|
|
204
|
+
typer.echo(f"Archive: {archive}")
|
|
205
|
+
typer.echo(f"Schema: {envelope.schema_version}")
|
|
206
|
+
typer.echo(f"Tool: {envelope.tool} {envelope.tool_version}")
|
|
207
|
+
if envelope.retrieved_at is not None:
|
|
208
|
+
typer.echo(f"Retrieved: {envelope.retrieved_at.isoformat()}")
|
|
209
|
+
typer.echo(f"Provider: {conversation.provider}")
|
|
210
|
+
typer.echo(f"Messages: {len(conversation.messages)}")
|
|
211
|
+
typer.echo(f"Complete: {_yes_no(report.complete)}")
|
|
212
|
+
typer.echo(f"Faithful: {_yes_no(report.faithful)}")
|
|
213
|
+
if report.unrecognised:
|
|
214
|
+
typer.echo(f"Unknown: {len(report.unrecognised)} unclassifiable code(s)")
|
|
215
|
+
|
|
216
|
+
_report_findings(envelope.findings)
|
|
217
|
+
raise typer.Exit(
|
|
218
|
+
EXIT_OK if report.complete and report.faithful else EXIT_WARNINGS
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
app()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from convolvger.core.archive import ArchiveEnvelope
|
|
2
|
+
from convolvger.core.errors import ConvolvgerError, ProviderNotFoundError
|
|
3
|
+
from convolvger.core.models import (
|
|
4
|
+
CodeBlock,
|
|
5
|
+
ContentBlock,
|
|
6
|
+
Conversation,
|
|
7
|
+
KnownBlock,
|
|
8
|
+
Message,
|
|
9
|
+
MessageRole,
|
|
10
|
+
ReasoningBlock,
|
|
11
|
+
TextBlock,
|
|
12
|
+
UnknownBlock,
|
|
13
|
+
)
|
|
14
|
+
from convolvger.core.results import ParseError, ParseResult
|
|
15
|
+
from convolvger.core.source import RawSource
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"ArchiveEnvelope",
|
|
19
|
+
"CodeBlock",
|
|
20
|
+
"ContentBlock",
|
|
21
|
+
"Conversation",
|
|
22
|
+
"ConvolvgerError",
|
|
23
|
+
"KnownBlock",
|
|
24
|
+
"Message",
|
|
25
|
+
"MessageRole",
|
|
26
|
+
"ParseError",
|
|
27
|
+
"ParseResult",
|
|
28
|
+
"ProviderNotFoundError",
|
|
29
|
+
"RawSource",
|
|
30
|
+
"ReasoningBlock",
|
|
31
|
+
"TextBlock",
|
|
32
|
+
"UnknownBlock",
|
|
33
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The archival package produced by the JSON export.
|
|
2
|
+
|
|
3
|
+
The Markdown renderer is a dissemination package and may omit content.
|
|
4
|
+
This is the archival package: it omits nothing the canonical model holds.
|
|
5
|
+
|
|
6
|
+
Extraction findings and the retrieval time describe the capture event
|
|
7
|
+
rather than the conversation, so they sit beside the conversation here,
|
|
8
|
+
matching ``ParseResult`` rather than contradicting it. ``conversation``
|
|
9
|
+
alone round-trips back to an equal ``Conversation``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
17
|
+
|
|
18
|
+
from convolvger.core.errors import ConvolvgerError
|
|
19
|
+
from convolvger.core.findings import Finding
|
|
20
|
+
from convolvger.core.models import Conversation
|
|
21
|
+
|
|
22
|
+
SCHEMA_VERSION = 2
|
|
23
|
+
"""Bumped only when a change would stop an older reader loading a file.
|
|
24
|
+
|
|
25
|
+
Version 2 replaced the ``warnings`` list of strings with ``findings``.
|
|
26
|
+
Adding a field is safe for a reader that allows unknown ones; removing
|
|
27
|
+
or renaming one is not.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ArchiveError(ConvolvgerError):
|
|
32
|
+
"""Raised when a file cannot be read as an archive of this version.
|
|
33
|
+
|
|
34
|
+
Lives here rather than in ``errors`` for the same reason
|
|
35
|
+
``ParseError`` lives beside the parser: it belongs to the format it
|
|
36
|
+
guards.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _tool_version() -> str:
|
|
41
|
+
try:
|
|
42
|
+
return version("convolvger")
|
|
43
|
+
except PackageNotFoundError:
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ArchiveEnvelope(BaseModel):
|
|
48
|
+
"""A conversation together with the provenance of its capture.
|
|
49
|
+
|
|
50
|
+
``extra="allow"``, unlike every other model here. An archive is a
|
|
51
|
+
permanent file that a later version of this tool must be able to
|
|
52
|
+
read: forbidding unknown fields would make every future addition a
|
|
53
|
+
breaking change, and ignoring them would drop them on
|
|
54
|
+
re-serialisation. Allowing them preserves a field this version does
|
|
55
|
+
not model. The canonical models keep ``forbid`` -- they guard an
|
|
56
|
+
internal invariant, not a file format.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(extra="allow")
|
|
60
|
+
|
|
61
|
+
schema_version: int = SCHEMA_VERSION
|
|
62
|
+
tool: str = "convolvger"
|
|
63
|
+
tool_version: str = Field(default_factory=_tool_version)
|
|
64
|
+
retrieved_at: datetime | None = None
|
|
65
|
+
findings: list[Finding] = Field(default_factory=list)
|
|
66
|
+
conversation: Conversation
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_archive(text: str) -> ArchiveEnvelope:
|
|
70
|
+
"""Read an archive this version can vouch for, or refuse it.
|
|
71
|
+
|
|
72
|
+
The declared schema version is checked before anything else is
|
|
73
|
+
trusted, because the envelope alone will not refuse a file it cannot
|
|
74
|
+
truthfully represent. A version 1 archive validates quite happily:
|
|
75
|
+
its ``warnings`` list survives as an unknown field and ``findings``
|
|
76
|
+
defaults to empty, so a reader that skipped this check would
|
|
77
|
+
pronounce a file recording real damage entirely clean. Refusal is
|
|
78
|
+
the only honest answer for a format this version cannot read as its
|
|
79
|
+
own.
|
|
80
|
+
|
|
81
|
+
Nothing here touches the filesystem. The caller owns reading the
|
|
82
|
+
file, as it already owns writing one.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
payload = json.loads(text)
|
|
86
|
+
except json.JSONDecodeError as error:
|
|
87
|
+
raise ArchiveError(f"Not JSON: {error}") from error
|
|
88
|
+
|
|
89
|
+
if not isinstance(payload, dict):
|
|
90
|
+
raise ArchiveError(
|
|
91
|
+
f"Not an archive: expected an object, found {type(payload).__name__}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
declared = payload.get("schema_version")
|
|
95
|
+
if not isinstance(declared, int):
|
|
96
|
+
raise ArchiveError("Not an archive: no schema_version")
|
|
97
|
+
if declared != SCHEMA_VERSION:
|
|
98
|
+
raise ArchiveError(
|
|
99
|
+
f"Archive declares schema version {declared}; "
|
|
100
|
+
f"this version reads {SCHEMA_VERSION}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
return ArchiveEnvelope.model_validate(payload)
|
|
105
|
+
except ValidationError as error:
|
|
106
|
+
raise ArchiveError(f"Archive is not a valid envelope: {error}") from error
|