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.
Files changed (37) hide show
  1. convolvger-0.1.0/LICENSE +21 -0
  2. convolvger-0.1.0/PKG-INFO +86 -0
  3. convolvger-0.1.0/README.md +58 -0
  4. convolvger-0.1.0/pyproject.toml +72 -0
  5. convolvger-0.1.0/pyproject.toml.orig +61 -0
  6. convolvger-0.1.0/src/convolvger/__init__.py +0 -0
  7. convolvger-0.1.0/src/convolvger/cli/__init__.py +0 -0
  8. convolvger-0.1.0/src/convolvger/cli/main.py +223 -0
  9. convolvger-0.1.0/src/convolvger/core/__init__.py +33 -0
  10. convolvger-0.1.0/src/convolvger/core/archive.py +106 -0
  11. convolvger-0.1.0/src/convolvger/core/errors.py +6 -0
  12. convolvger-0.1.0/src/convolvger/core/findings.py +83 -0
  13. convolvger-0.1.0/src/convolvger/core/models.py +107 -0
  14. convolvger-0.1.0/src/convolvger/core/results.py +39 -0
  15. convolvger-0.1.0/src/convolvger/core/source.py +14 -0
  16. convolvger-0.1.0/src/convolvger/providers/__init__.py +7 -0
  17. convolvger-0.1.0/src/convolvger/providers/base.py +26 -0
  18. convolvger-0.1.0/src/convolvger/providers/chatgpt/__init__.py +3 -0
  19. convolvger-0.1.0/src/convolvger/providers/chatgpt/_content.py +111 -0
  20. convolvger-0.1.0/src/convolvger/providers/chatgpt/_fetch.py +83 -0
  21. convolvger-0.1.0/src/convolvger/providers/chatgpt/_parse.py +156 -0
  22. convolvger-0.1.0/src/convolvger/providers/chatgpt/_turbostream.py +148 -0
  23. convolvger-0.1.0/src/convolvger/providers/chatgpt/_urls.py +30 -0
  24. convolvger-0.1.0/src/convolvger/providers/chatgpt/provider.py +22 -0
  25. convolvger-0.1.0/src/convolvger/providers/claude/__init__.py +0 -0
  26. convolvger-0.1.0/src/convolvger/providers/default.py +15 -0
  27. convolvger-0.1.0/src/convolvger/providers/gemini/__init__.py +0 -0
  28. convolvger-0.1.0/src/convolvger/providers/grok/__init__.py +0 -0
  29. convolvger-0.1.0/src/convolvger/providers/registry.py +33 -0
  30. convolvger-0.1.0/src/convolvger/py.typed +0 -0
  31. convolvger-0.1.0/src/convolvger/renderers/__init__.py +4 -0
  32. convolvger-0.1.0/src/convolvger/renderers/json.py +35 -0
  33. convolvger-0.1.0/src/convolvger/renderers/markdown.py +175 -0
  34. convolvger-0.1.0/src/convolvger/storage/__init__.py +0 -0
  35. convolvger-0.1.0/src/convolvger/validation/__init__.py +0 -0
  36. convolvger-0.1.0/src/convolvger/validation/aspects.py +70 -0
  37. convolvger-0.1.0/src/convolvger/validation/report.py +53 -0
@@ -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
@@ -0,0 +1,6 @@
1
+ class ConvolvgerError(Exception):
2
+ """Base class for all Convolvger errors."""
3
+
4
+
5
+ class ProviderNotFoundError(ConvolvgerError):
6
+ """Raised when no provider can handle the requested source."""