defossil 0.0.1__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 (65) hide show
  1. defossil-0.0.1/LICENSE +21 -0
  2. defossil-0.0.1/PKG-INFO +66 -0
  3. defossil-0.0.1/README.md +45 -0
  4. defossil-0.0.1/pyproject.toml +87 -0
  5. defossil-0.0.1/pyproject.toml.orig +126 -0
  6. defossil-0.0.1/src/defossil/__init__.py +1 -0
  7. defossil-0.0.1/src/defossil/__main__.py +38 -0
  8. defossil-0.0.1/src/defossil/core/__init__.py +1 -0
  9. defossil-0.0.1/src/defossil/core/core.py +68 -0
  10. defossil-0.0.1/src/defossil/core/db.py +55 -0
  11. defossil-0.0.1/src/defossil/core/errors.py +17 -0
  12. defossil-0.0.1/src/defossil/core/features/__init__.py +1 -0
  13. defossil-0.0.1/src/defossil/core/features/ai/__init__.py +1 -0
  14. defossil-0.0.1/src/defossil/core/features/ai/backends.py +85 -0
  15. defossil-0.0.1/src/defossil/core/features/ai/models.py +63 -0
  16. defossil-0.0.1/src/defossil/core/features/ai/prompts/explain.md +9 -0
  17. defossil-0.0.1/src/defossil/core/features/ai/prompts/report.md +63 -0
  18. defossil-0.0.1/src/defossil/core/features/ai/prompts/review.md +18 -0
  19. defossil-0.0.1/src/defossil/core/features/ai/service.py +178 -0
  20. defossil-0.0.1/src/defossil/core/features/correction/__init__.py +1 -0
  21. defossil-0.0.1/src/defossil/core/features/correction/models.py +182 -0
  22. defossil-0.0.1/src/defossil/core/features/correction/service.py +217 -0
  23. defossil-0.0.1/src/defossil/core/features/message/__init__.py +1 -0
  24. defossil-0.0.1/src/defossil/core/features/message/models.py +58 -0
  25. defossil-0.0.1/src/defossil/core/features/message/service.py +139 -0
  26. defossil-0.0.1/src/defossil/core/features/message/sources/__init__.py +1 -0
  27. defossil-0.0.1/src/defossil/core/features/message/sources/claude_code.py +43 -0
  28. defossil-0.0.1/src/defossil/core/features/message/sources/codex.py +64 -0
  29. defossil-0.0.1/src/defossil/core/features/report/__init__.py +1 -0
  30. defossil-0.0.1/src/defossil/core/features/report/models.py +43 -0
  31. defossil-0.0.1/src/defossil/core/features/report/service.py +123 -0
  32. defossil-0.0.1/src/defossil/core/features/setting/__init__.py +1 -0
  33. defossil-0.0.1/src/defossil/core/features/setting/models.py +32 -0
  34. defossil-0.0.1/src/defossil/core/features/setting/service.py +22 -0
  35. defossil-0.0.1/src/defossil/core/migrations.py +64 -0
  36. defossil-0.0.1/src/defossil/core/pipeline.py +141 -0
  37. defossil-0.0.1/src/defossil/core/service.py +26 -0
  38. defossil-0.0.1/src/defossil/web/__init__.py +1 -0
  39. defossil-0.0.1/src/defossil/web/app.py +53 -0
  40. defossil-0.0.1/src/defossil/web/routers/__init__.py +1 -0
  41. defossil-0.0.1/src/defossil/web/routers/ai.py +46 -0
  42. defossil-0.0.1/src/defossil/web/routers/correction.py +92 -0
  43. defossil-0.0.1/src/defossil/web/routers/message.py +55 -0
  44. defossil-0.0.1/src/defossil/web/routers/report.py +56 -0
  45. defossil-0.0.1/src/defossil/web/routers/setting.py +37 -0
  46. defossil-0.0.1/src/defossil/web/routers/system.py +94 -0
  47. defossil-0.0.1/src/defossil/web/static/favicon.svg +5 -0
  48. defossil-0.0.1/src/defossil/web/static/logo.svg +8 -0
  49. defossil-0.0.1/src/defossil/web/static/marked.min.js +69 -0
  50. defossil-0.0.1/src/defossil/web/static/purify.min.js +3 -0
  51. defossil-0.0.1/src/defossil/web/static/styles.css +266 -0
  52. defossil-0.0.1/src/defossil/web/templates/ai_call.html +31 -0
  53. defossil-0.0.1/src/defossil/web/templates/ai_calls.html +42 -0
  54. defossil-0.0.1/src/defossil/web/templates/base.html +46 -0
  55. defossil-0.0.1/src/defossil/web/templates/corrections.html +199 -0
  56. defossil-0.0.1/src/defossil/web/templates/developer.html +20 -0
  57. defossil-0.0.1/src/defossil/web/templates/message.html +24 -0
  58. defossil-0.0.1/src/defossil/web/templates/messages.html +43 -0
  59. defossil-0.0.1/src/defossil/web/templates/pipeline.html +44 -0
  60. defossil-0.0.1/src/defossil/web/templates/prompts.html +10 -0
  61. defossil-0.0.1/src/defossil/web/templates/report.html +25 -0
  62. defossil-0.0.1/src/defossil/web/templates/reports.html +27 -0
  63. defossil-0.0.1/src/defossil/web/templates/settings.html +69 -0
  64. defossil-0.0.1/src/defossil/web/templates/system_base.html +10 -0
  65. defossil-0.0.1/src/defossil/web/templating.py +66 -0
defossil-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pybass
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,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: defossil
3
+ Version: 0.0.1
4
+ Summary: Improve your English by reviewing your own chats with AI coding agents.
5
+ Author: pybass
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3.14
11
+ Classifier: Topic :: Education
12
+ Requires-Dist: fastapi>=0.141.1
13
+ Requires-Dist: jinja2>=3.1.6
14
+ Requires-Dist: markupsafe>=3.0.3
15
+ Requires-Dist: pydantic>=2.13.4
16
+ Requires-Dist: uvicorn>=0.52.3
17
+ Requires-Python: >=3.14
18
+ Project-URL: Repository, https://github.com/pybass/defossil
19
+ Project-URL: Issues, https://github.com/pybass/defossil/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ # defossil
23
+
24
+ **Status: prototype.** No tests yet; anything can change without backward compatibility.
25
+
26
+ Improve your English by reviewing your own chats with AI coding agents. The name comes from "fossilized errors" — recurring mistakes that stick in a learner's language. You already write a lot of English when talking to agents like Claude Code and Codex. defossil collects those messages, reviews them with an LLM, and gives you two things:
27
+
28
+ - **Corrections** — one per mistake: your fragment, the fix, and a short note on how to say it better.
29
+ - **Reports** — the most valuable part. A short lesson over many corrections: the mistakes you repeat, the native-language patterns in your phrasing, shorter ways to say what you keep saying long — real examples from your own text. Fast to read and a realistic picture of your English.
30
+
31
+ ## How it works
32
+
33
+ One background thread — the pipeline — runs the whole chain every 5 minutes: collect → classify → review → report. Nothing else creates corrections or reports, so both tables are append-only and no step races another. The dashboard only shows what the pipeline did.
34
+
35
+ 1. **Collect** — archive every message you typed, verbatim, into SQLite, deduplicated by the source's own key. Only real typed text: tool output, command expansions, and programmatic runs are skipped. Sources: Claude Code and Codex CLI; one module per source.
36
+ 2. **Classify** — stamp each new message `pending` / `non-english` / `too-short` / `no-prose` / `too-long`, once. Only `pending` goes to review; the text itself is never rewritten.
37
+ 3. **Review** — send `pending` messages to the LLM in batches, store what it corrects — real mistakes and style (wordiness, calques, register) — as corrections, and stamp the messages `reviewed`. A message is reviewed once, ever. On the dashboard a correction can be acknowledged, and the explain button asks the LLM for a deeper explanation.
38
+ 4. **Report** — a markdown lesson over each `corrections_per_report` corrections: repeated mistakes, native-language patterns, shorter phrasings, one focus habit until the next report. Reports are stored and never regenerated.
39
+
40
+ ## Architecture
41
+
42
+ `web` → `Core` → feature service → `Db`. `Core` is a container and the lifecycle: it opens the database, builds one service per feature, starts them in order and stops them in reverse. A feature is one job, named after the record it owns: `message` (the archive and its sources), `correction`, `report`, `setting`, and `ai` — every prompt the app sends, plus the `ai_calls` log of what each call cost. Every table has exactly one owner, and only the owner writes SQL against it. Features reach each other through `self.core.services.<other>`.
43
+
44
+ The schema evolves through append-only migrations (`core/migrations.py`, tracked by `PRAGMA user_version`), so it can change without dropping data. Nothing is redone: a message is classified and reviewed once, corrections and reports only accumulate. The archive is the one thing the sources cannot give back (Claude Code deletes transcripts after ~30 days), and nothing drops it.
45
+
46
+ ## Usage
47
+
48
+ Python, FastAPI, SQLite. LLM calls go through the `claude` CLI by default (`claude -p` — works with a Claude subscription, no API key); a setting switches to `codex exec`. Local only: data never leaves the machine except text sent for review.
49
+
50
+ Run `defossil`, open http://127.0.0.1:3677.
51
+
52
+ ## Settings
53
+
54
+ The data root cannot live in the database it locates, so it is the one setting outside it: `~/.local/share/defossil` by default, overridden only by `--data-dir`. Everything else — native language, AI backend, model and effort per prompt category, source roots, batch sizes, page size — lives in the `settings` table, is edited on the dashboard's settings page, and is read at use time, so a change applies without a restart.
55
+
56
+ ## Non-goals
57
+
58
+ Decided against — do not re-propose or implement:
59
+
60
+ - **Exercises** — drills, quizzes, flashcards, spaced repetition built from the stored mistakes. The app shows mistakes and writes reports, nothing more.
61
+ - **Dismissing false positives** — a "not a mistake" flag. Premature: the archive shows no false positives yet.
62
+ - **Fossils page** — a page grouping corrections by category and fragment. Fragments group only when they repeat verbatim, so it adds little over the corrections page and the report.
63
+
64
+ ## License
65
+
66
+ [MIT](LICENSE)
@@ -0,0 +1,45 @@
1
+ # defossil
2
+
3
+ **Status: prototype.** No tests yet; anything can change without backward compatibility.
4
+
5
+ Improve your English by reviewing your own chats with AI coding agents. The name comes from "fossilized errors" — recurring mistakes that stick in a learner's language. You already write a lot of English when talking to agents like Claude Code and Codex. defossil collects those messages, reviews them with an LLM, and gives you two things:
6
+
7
+ - **Corrections** — one per mistake: your fragment, the fix, and a short note on how to say it better.
8
+ - **Reports** — the most valuable part. A short lesson over many corrections: the mistakes you repeat, the native-language patterns in your phrasing, shorter ways to say what you keep saying long — real examples from your own text. Fast to read and a realistic picture of your English.
9
+
10
+ ## How it works
11
+
12
+ One background thread — the pipeline — runs the whole chain every 5 minutes: collect → classify → review → report. Nothing else creates corrections or reports, so both tables are append-only and no step races another. The dashboard only shows what the pipeline did.
13
+
14
+ 1. **Collect** — archive every message you typed, verbatim, into SQLite, deduplicated by the source's own key. Only real typed text: tool output, command expansions, and programmatic runs are skipped. Sources: Claude Code and Codex CLI; one module per source.
15
+ 2. **Classify** — stamp each new message `pending` / `non-english` / `too-short` / `no-prose` / `too-long`, once. Only `pending` goes to review; the text itself is never rewritten.
16
+ 3. **Review** — send `pending` messages to the LLM in batches, store what it corrects — real mistakes and style (wordiness, calques, register) — as corrections, and stamp the messages `reviewed`. A message is reviewed once, ever. On the dashboard a correction can be acknowledged, and the explain button asks the LLM for a deeper explanation.
17
+ 4. **Report** — a markdown lesson over each `corrections_per_report` corrections: repeated mistakes, native-language patterns, shorter phrasings, one focus habit until the next report. Reports are stored and never regenerated.
18
+
19
+ ## Architecture
20
+
21
+ `web` → `Core` → feature service → `Db`. `Core` is a container and the lifecycle: it opens the database, builds one service per feature, starts them in order and stops them in reverse. A feature is one job, named after the record it owns: `message` (the archive and its sources), `correction`, `report`, `setting`, and `ai` — every prompt the app sends, plus the `ai_calls` log of what each call cost. Every table has exactly one owner, and only the owner writes SQL against it. Features reach each other through `self.core.services.<other>`.
22
+
23
+ The schema evolves through append-only migrations (`core/migrations.py`, tracked by `PRAGMA user_version`), so it can change without dropping data. Nothing is redone: a message is classified and reviewed once, corrections and reports only accumulate. The archive is the one thing the sources cannot give back (Claude Code deletes transcripts after ~30 days), and nothing drops it.
24
+
25
+ ## Usage
26
+
27
+ Python, FastAPI, SQLite. LLM calls go through the `claude` CLI by default (`claude -p` — works with a Claude subscription, no API key); a setting switches to `codex exec`. Local only: data never leaves the machine except text sent for review.
28
+
29
+ Run `defossil`, open http://127.0.0.1:3677.
30
+
31
+ ## Settings
32
+
33
+ The data root cannot live in the database it locates, so it is the one setting outside it: `~/.local/share/defossil` by default, overridden only by `--data-dir`. Everything else — native language, AI backend, model and effort per prompt category, source roots, batch sizes, page size — lives in the `settings` table, is edited on the dashboard's settings page, and is read at use time, so a change applies without a restart.
34
+
35
+ ## Non-goals
36
+
37
+ Decided against — do not re-propose or implement:
38
+
39
+ - **Exercises** — drills, quizzes, flashcards, spaced repetition built from the stored mistakes. The app shows mistakes and writes reports, nothing more.
40
+ - **Dismissing false positives** — a "not a mistake" flag. Premature: the archive shows no false positives yet.
41
+ - **Fossils page** — a page grouping corrections by category and fragment. Fragments group only when they repeat verbatim, so it adds little over the corrections page and the report.
42
+
43
+ ## License
44
+
45
+ [MIT](LICENSE)
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "defossil"
3
+ version = "0.0.1"
4
+ description = "Improve your English by reviewing your own chats with AI coding agents."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ classifiers = [
10
+ "Environment :: Web Environment",
11
+ "Intended Audience :: Developers",
12
+ "Programming Language :: Python :: 3.14",
13
+ "Topic :: Education",
14
+ ]
15
+ dependencies = [
16
+ "fastapi>=0.141.1",
17
+ "jinja2>=3.1.6",
18
+ "markupsafe>=3.0.3",
19
+ "pydantic>=2.13.4",
20
+ "uvicorn>=0.52.3",
21
+ ]
22
+
23
+ [[project.authors]]
24
+ name = "pybass"
25
+
26
+ [project.urls]
27
+ Repository = "https://github.com/pybass/defossil"
28
+ Issues = "https://github.com/pybass/defossil/issues"
29
+
30
+ [project.scripts]
31
+ defossil = "defossil.__main__:main"
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.12,<0.13"]
35
+ build-backend = "uv_build"
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "ruff~=0.16.3",
40
+ "mypy~=2.3.1",
41
+ "pip-audit~=2.10.1",
42
+ "deptry~=0.25.1",
43
+ "pre-commit~=4.6.2",
44
+ "watchfiles~=1.2.0",
45
+ ]
46
+
47
+ [tool.uv]
48
+ required-version = ">=0.12"
49
+
50
+ [tool.ruff]
51
+ line-length = 130
52
+ target-version = "py314"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["ALL"]
56
+ ignore = [
57
+ "COM812",
58
+ "D203",
59
+ "D213",
60
+ "PLC0414",
61
+ "RET503",
62
+ "G004",
63
+ "FIX",
64
+ "FBT",
65
+ "EM",
66
+ "CPY",
67
+ "TC",
68
+ "TRY003",
69
+ "C901",
70
+ "PLR0911",
71
+ "PLR0912",
72
+ "PLR0913",
73
+ "PLR2004",
74
+ "ASYNC109",
75
+ ]
76
+
77
+ [tool.ruff.format]
78
+ quote-style = "double"
79
+ indent-style = "space"
80
+ docstring-code-format = true
81
+
82
+ [tool.mypy]
83
+ python_version = "3.14"
84
+ strict = true
85
+
86
+ [tool.deptry.per_rule_ignores]
87
+ DEP002 = ["jinja2"]
@@ -0,0 +1,126 @@
1
+ # ============================================================================
2
+ # Project metadata
3
+ # ============================================================================
4
+ [project]
5
+ name = "defossil"
6
+ version = "0.0.1"
7
+ description = "Improve your English by reviewing your own chats with AI coding agents."
8
+ readme = "README.md"
9
+ authors = [{ name = "pybass" }]
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.14"
13
+ classifiers = [
14
+ "Environment :: Web Environment",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Education",
18
+ ]
19
+ dependencies = [
20
+ "fastapi>=0.141.1",
21
+ "jinja2>=3.1.6",
22
+ "markupsafe>=3.0.3",
23
+ "pydantic>=2.13.4",
24
+ "uvicorn>=0.52.3",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/pybass/defossil"
29
+ Issues = "https://github.com/pybass/defossil/issues"
30
+
31
+ [project.scripts]
32
+ defossil = "defossil.__main__:main"
33
+
34
+ # ============================================================================
35
+ # Build system
36
+ # ============================================================================
37
+ [build-system]
38
+ requires = ["uv_build>=0.12,<0.13"] # pinned: backends live outside uv.lock — an open range breaks reproducible tag builds
39
+ build-backend = "uv_build"
40
+
41
+
42
+ # ============================================================================
43
+ # Development dependencies — grouped by role (mirrors the tool sections below)
44
+ # ============================================================================
45
+ [dependency-groups]
46
+ dev = [
47
+ # Linting & formatting
48
+ "ruff~=0.16.3",
49
+ # Type checking
50
+ "mypy~=2.3.1",
51
+ # Security & vulnerability audit
52
+ "pip-audit~=2.10.1",
53
+ # Dependency hygiene — imports vs declared deps
54
+ "deptry~=0.25.1",
55
+ # Git pre-commit hooks
56
+ "pre-commit~=4.6.2",
57
+ # Dev-server auto-restart (justfile `dev`)
58
+ "watchfiles~=1.2.0",
59
+ ]
60
+
61
+
62
+ # ============================================================================
63
+ # uv
64
+ # ============================================================================
65
+ [tool.uv]
66
+ required-version = ">=0.12" # refuse a uv too old for the lock/sync behaviors the justfile relies on
67
+
68
+
69
+ # ============================================================================
70
+ # Ruff — linting & formatting
71
+ # ============================================================================
72
+ [tool.ruff]
73
+ line-length = 130
74
+ target-version = "py314"
75
+
76
+ [tool.ruff.lint]
77
+ select = ["ALL"]
78
+ ignore = [
79
+ # --- Ecosystem / tool conflicts ---
80
+ "COM812", # redundant and incompatible with ruff format
81
+ "D203", # one-blank-line-before-class — conflicts with D211
82
+ "D213", # multi-line-summary-second-line — conflicts with D212
83
+
84
+ # --- Stylistic preferences ---
85
+ "PLC0414", # useless-import-alias — `import x as x` is our explicit re-export marker
86
+ "RET503", # implicit-return — explicit None returns are noisy
87
+ "G004", # f-strings in logging — accepted
88
+
89
+ # --- Categories that fight our coding style ---
90
+ "FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
91
+ "FBT", # boolean-trap — too restrictive
92
+ "EM", # error-message strings — inline messages accepted
93
+ "CPY", # flake8-copyright — no copyright headers
94
+ "TC", # flake8-type-checking — TYPE_CHECKING blocks save import time we don't care about; cost: import noise + broken runtime annotations (pydantic)
95
+ "TRY003", # raise-vanilla-args — too noisy
96
+ "C901", # complex-structure — judgment call
97
+
98
+ # --- Soft pylint thresholds ---
99
+ "PLR0911", # too-many-return-statements
100
+ "PLR0912", # too-many-branches
101
+ "PLR0913", # too-many-arguments
102
+ "PLR2004", # magic-value-comparison
103
+
104
+ # --- Async edge case ---
105
+ "ASYNC109", # async-function-with-timeout — debatable, off
106
+ ]
107
+
108
+ [tool.ruff.format]
109
+ quote-style = "double"
110
+ indent-style = "space"
111
+ docstring-code-format = true
112
+
113
+
114
+ # ============================================================================
115
+ # Type checking — mypy (strict)
116
+ # ============================================================================
117
+ [tool.mypy]
118
+ python_version = "3.14"
119
+ strict = true
120
+
121
+
122
+ # ============================================================================
123
+ # deptry — imports vs declared deps
124
+ # ============================================================================
125
+ [tool.deptry.per_rule_ignores]
126
+ DEP002 = ["jinja2"] # used only through fastapi.templating (an optional fastapi extra), never imported directly
@@ -0,0 +1 @@
1
+ """defossil — improve your English by reviewing your own chats with AI coding agents."""
@@ -0,0 +1,38 @@
1
+ """Entry point: defossil runs the web dashboard."""
2
+
3
+ import argparse
4
+ import logging
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+
8
+ import uvicorn
9
+
10
+ from defossil.core.core import Core
11
+ from defossil.web.app import create_app
12
+
13
+
14
+ def main() -> None:
15
+ """Parse arguments and run the web dashboard."""
16
+ parser = argparse.ArgumentParser(prog="defossil", description="Improve your English by reviewing your chats with AI agents.")
17
+ parser.add_argument("--version", action="version", version=f"%(prog)s {version('defossil')}")
18
+ parser.add_argument("--port", type=int, default=3677, help="dashboard port on 127.0.0.1")
19
+ parser.add_argument(
20
+ "--data-dir", type=Path, default=Core.DEFAULT_DATA_DIR, help="data root (default: ~/.local/share/defossil)"
21
+ )
22
+ args = parser.parse_args()
23
+ # uvicorn configures its own loggers and leaves the root one bare, which would drop every line the workers log.
24
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
25
+ logging.getLogger("defossil").info(f"dashboard: http://127.0.0.1:{args.port}")
26
+ # log_config=None routes uvicorn's records to our root handler; warnings and errors still show, its chatter does not.
27
+ uvicorn.run(
28
+ create_app(Core(args.data_dir)),
29
+ host="127.0.0.1",
30
+ port=args.port,
31
+ log_config=None,
32
+ log_level="warning",
33
+ access_log=False,
34
+ )
35
+
36
+
37
+ if __name__ == "__main__":
38
+ main()
@@ -0,0 +1 @@
1
+ """The application core: configuration, storage, and one service per feature."""
@@ -0,0 +1,68 @@
1
+ """Composition root — the single object the CLI and the web app work through."""
2
+
3
+ from pathlib import Path
4
+
5
+ from defossil.core.db import Db
6
+ from defossil.core.features.ai.service import AiService
7
+ from defossil.core.features.correction.service import CorrectionService
8
+ from defossil.core.features.message.service import MessageService
9
+ from defossil.core.features.report.service import ReportService
10
+ from defossil.core.features.setting.service import SettingService
11
+ from defossil.core.pipeline import Pipeline
12
+ from defossil.core.service import Service
13
+
14
+
15
+ class Services:
16
+ """Every feature service in one namespace, so `core.*` stays the storage and `core.services.*` the work.
17
+
18
+ Plain fields, listed by hand: a service is added here and nowhere else. The pipeline sits last so it starts
19
+ after every service it drives and stops before them.
20
+ """
21
+
22
+ def __init__(self, core: Core) -> None:
23
+ """Build one service per feature over *core*. None of them may touch another while this runs."""
24
+ self.setting = SettingService(core)
25
+ self.ai = AiService(core)
26
+ self.message = MessageService(core)
27
+ self.correction = CorrectionService(core)
28
+ self.report = ReportService(core)
29
+ self.pipeline = Pipeline(core)
30
+ # Read off the fields above, so a service is still added in one place.
31
+ self._services: list[Service] = [value for value in vars(self).values() if isinstance(value, Service)]
32
+
33
+ def start_all(self) -> None:
34
+ """Start the services in the order they were built."""
35
+ for service in self._services:
36
+ service.on_start()
37
+
38
+ def stop_all(self) -> None:
39
+ """Stop them in the reverse order, so nothing is torn down under a service still using it."""
40
+ for service in reversed(self._services):
41
+ service.on_stop()
42
+
43
+
44
+ class Core:
45
+ """A container, not a layer with behaviour: it owns the storage and the feature services over it.
46
+
47
+ A client builds one Core and calls `core.services.correction.get_corrections(...)`; nothing here forwards that call.
48
+ Core is also the lifecycle: `start` is what lets the pipeline run in the background, and only the server calls it.
49
+ """
50
+
51
+ # Where the data lives without --data-dir. A fixed path, never read from the environment:
52
+ # a variable exported for some other tool must not move the archive.
53
+ DEFAULT_DATA_DIR = Path.home() / ".local" / "share" / "defossil"
54
+
55
+ def __init__(self, data_dir: Path) -> None:
56
+ """Open the database under *data_dir* and build the services over it. Nothing runs yet."""
57
+ data_dir.mkdir(parents=True, exist_ok=True)
58
+ self.db = Db(data_dir / "defossil.db")
59
+ self.services = Services(self)
60
+
61
+ def start(self) -> None:
62
+ """Start every service; nothing runs in the background before this."""
63
+ self.services.start_all()
64
+
65
+ def stop(self) -> None:
66
+ """Stop the services, then close the database — in that order, or the pipeline outlives its connection."""
67
+ self.services.stop_all()
68
+ self.db.close()
@@ -0,0 +1,55 @@
1
+ """SQLite storage: the connection and the migration runner.
2
+
3
+ It runs no query of its own — a table's SQL belongs to its feature, and the schema lives in migrations.py.
4
+ """
5
+
6
+ import sqlite3
7
+ import threading
8
+ from collections.abc import Generator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+ from defossil.core.migrations import MIGRATIONS
13
+
14
+
15
+ class Db:
16
+ """The one connection every feature service runs its own SQL on."""
17
+
18
+ def __init__(self, path: Path) -> None:
19
+ """Open the database and apply pending migrations; the directory must exist (Core makes it)."""
20
+ # autocommit=True gives every lone statement its own transaction, so a write is never left hanging in an
21
+ # implicit one. check_same_thread=False because fastapi serves sync handlers from a threadpool, so a request
22
+ # is not handled by the thread that opened this connection.
23
+ self.conn = sqlite3.connect(path, autocommit=True, check_same_thread=False)
24
+ self.conn.row_factory = sqlite3.Row
25
+ self._migrate()
26
+ self._write_lock = threading.Lock()
27
+
28
+ def close(self) -> None:
29
+ """Close the connection."""
30
+ self.conn.close()
31
+
32
+ def _migrate(self) -> None:
33
+ """Apply pending migrations, tracked via PRAGMA user_version."""
34
+ version = int(self.conn.execute("PRAGMA user_version").fetchone()[0])
35
+ for number, script in enumerate(MIGRATIONS[version:], start=version + 1):
36
+ # The script and the version bump commit together: a crash mid-migration rolls back cleanly, so a
37
+ # migration is either fully applied and recorded, or not at all.
38
+ self.conn.executescript(f"BEGIN;\n{script}\nPRAGMA user_version = {number};\nCOMMIT;")
39
+
40
+ @contextmanager
41
+ def transaction(self) -> Generator[sqlite3.Connection]:
42
+ """Run one or more statements as a single transaction; every write goes through here, reads need not.
43
+
44
+ One at a time, under the lock: the connection is shared by the request threadpool, the pipeline worker and
45
+ the explain pool, so a lone autocommit write could join another thread's open transaction and be rolled
46
+ back with it, and overlapping transactions would nest and commit each other's rows.
47
+ """
48
+ with self._write_lock:
49
+ self.conn.execute("BEGIN")
50
+ try:
51
+ yield self.conn
52
+ except Exception:
53
+ self.conn.execute("ROLLBACK")
54
+ raise
55
+ self.conn.execute("COMMIT")
@@ -0,0 +1,17 @@
1
+ """Errors Core raises on purpose. Each client maps them to its own vocabulary — HTTP status, exit code."""
2
+
3
+
4
+ class DefossilError(Exception):
5
+ """Base of everything Core raises deliberately, so a client can catch the whole family."""
6
+
7
+
8
+ class NotFoundError(DefossilError):
9
+ """A record was asked for by id and does not exist."""
10
+
11
+
12
+ class InvalidOperationError(DefossilError):
13
+ """The operation a caller asked for would break a rule of the model."""
14
+
15
+
16
+ class AiError(DefossilError):
17
+ """A backend call failed or its answer could not be read; the same ask can be retried later."""
@@ -0,0 +1 @@
1
+ """One package per feature: the record it owns, its table, and every operation over both."""
@@ -0,0 +1 @@
1
+ """AI: the backend contract and the CLIs behind it, every prompt the app sends, and the log of what each query cost."""
@@ -0,0 +1,85 @@
1
+ """The backend contract, both CLI implementations, and the one place a name from the settings becomes a backend.
2
+
3
+ A failed call raises to the caller, which decides whether that is recorded or fatal. A missing CLI binary is not
4
+ that: it is the machine being wrong, and it stops the run.
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Protocol
13
+
14
+ from defossil.core.features.ai.models import AiResponse
15
+
16
+
17
+ class AiBackend(Protocol):
18
+ """One prompt in, the answer with its metadata out; what is asked and how it is read is the caller's."""
19
+
20
+ def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
21
+ """Answer *prompt* on *model* at *effort*; a failed call raises to the caller."""
22
+ ...
23
+
24
+
25
+ class ClaudeBackend:
26
+ """Answers a prompt by running the `claude` CLI in print mode."""
27
+
28
+ def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
29
+ """Run `claude -p` over *prompt* with the given model and effort."""
30
+ claude_bin = shutil.which("claude")
31
+ if claude_bin is None:
32
+ raise RuntimeError("claude CLI not found in PATH")
33
+ # Hooks are for interactive sessions: without this they fire on every call (sounds, prompt-injecting hooks).
34
+ argv = [claude_bin, "-p", "--output-format", "json", "--settings", '{"disableAllHooks": true}']
35
+ argv += ["--model", model, "--effort", effort]
36
+ result = subprocess.run( # noqa: S603 -- fixed argv, resolved binary, no shell; nothing user-controlled is executed
37
+ argv, input=prompt, capture_output=True, text=True, check=True
38
+ )
39
+ answer = json.loads(result.stdout)
40
+ usage = answer.get("usage")
41
+ # modelUsage also lists the CLI's internal haiku helper calls; the answer comes from the model that wrote the most.
42
+ model_usage = answer.get("modelUsage") or {}
43
+ return AiResponse(
44
+ reply=str(answer["result"]).strip(),
45
+ model=max(model_usage, key=lambda m: model_usage[m].get("outputTokens") or 0, default=None),
46
+ # Cache writes and reads are counted in: with `claude -p` almost the whole prompt lands there.
47
+ input_tokens=sum(
48
+ usage.get(k) or 0 for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
49
+ )
50
+ if usage
51
+ else None,
52
+ output_tokens=usage.get("output_tokens") if usage else None,
53
+ cost_usd=answer.get("total_cost_usd"),
54
+ )
55
+
56
+
57
+ class CodexBackend:
58
+ """Answers a prompt by running the `codex` CLI non-interactively; the CLI reports no usable usage metadata."""
59
+
60
+ def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
61
+ """Run `codex exec` over *prompt*; tokens and cost stay None — only the machine-readable answer file comes back."""
62
+ codex_bin = shutil.which("codex")
63
+ if codex_bin is None:
64
+ raise RuntimeError("codex CLI not found in PATH")
65
+ # --ephemeral keeps the run out of ~/.codex/sessions, so the collector never re-ingests defossil's own prompts.
66
+ with tempfile.TemporaryDirectory() as tmp:
67
+ answer_file = Path(tmp) / "answer.txt"
68
+ flags = ["--skip-git-repo-check", "--ephemeral", "--sandbox", "read-only", "-o", str(answer_file)]
69
+ flags += ["--model", model, "-c", f"model_reasoning_effort={effort}"]
70
+ argv = [codex_bin, "exec", *flags, "-"]
71
+ subprocess.run( # noqa: S603 -- fixed argv, resolved binary, no shell; the prompt only travels over stdin
72
+ argv, input=prompt, capture_output=True, text=True, check=True
73
+ )
74
+ return AiResponse(reply=answer_file.read_text().strip())
75
+
76
+
77
+ def build_backend(name: str) -> AiBackend:
78
+ """Return the backend *name* names; an unknown name stops the run."""
79
+ match name:
80
+ case "claude":
81
+ return ClaudeBackend()
82
+ case "codex":
83
+ return CodexBackend()
84
+ case _:
85
+ raise ValueError(f"Unknown AI backend {name!r}: use claude or codex")