unread 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 (191) hide show
  1. unread-0.1.0/.claude/skills/unread-dev/SKILL.md +418 -0
  2. unread-0.1.0/.claude/skills/unread-usage/SKILL.md +594 -0
  3. unread-0.1.0/.env.example +9 -0
  4. unread-0.1.0/.github/workflows/ci.yml +63 -0
  5. unread-0.1.0/.github/workflows/release.yml +49 -0
  6. unread-0.1.0/.gitignore +230 -0
  7. unread-0.1.0/.releaserc.json +52 -0
  8. unread-0.1.0/CLAUDE.md +428 -0
  9. unread-0.1.0/LICENSE +21 -0
  10. unread-0.1.0/PKG-INFO +1197 -0
  11. unread-0.1.0/README.md +1144 -0
  12. unread-0.1.0/ROADMAP.md +240 -0
  13. unread-0.1.0/config.toml.example +182 -0
  14. unread-0.1.0/docs/superpowers/plans/2026-04-24-unified-chat-run-pipeline.md +2243 -0
  15. unread-0.1.0/docs/superpowers/plans/2026-04-26-ask-wizard-and-followup.md +1406 -0
  16. unread-0.1.0/docs/superpowers/specs/2026-04-24-unified-chat-run-pipeline-design.md +453 -0
  17. unread-0.1.0/docs/superpowers/specs/2026-04-26-ask-wizard-and-followup-design.md +127 -0
  18. unread-0.1.0/presets/README.md +96 -0
  19. unread-0.1.0/presets/en/_base.md +83 -0
  20. unread-0.1.0/presets/en/_reduce.md +22 -0
  21. unread-0.1.0/presets/en/action_items.md +52 -0
  22. unread-0.1.0/presets/en/broad.md +53 -0
  23. unread-0.1.0/presets/en/decisions.md +50 -0
  24. unread-0.1.0/presets/en/digest.md +46 -0
  25. unread-0.1.0/presets/en/highlights.md +59 -0
  26. unread-0.1.0/presets/en/links.md +60 -0
  27. unread-0.1.0/presets/en/multichat.md +77 -0
  28. unread-0.1.0/presets/en/questions.md +58 -0
  29. unread-0.1.0/presets/en/quotes.md +56 -0
  30. unread-0.1.0/presets/en/reactions.md +66 -0
  31. unread-0.1.0/presets/en/single_msg.md +62 -0
  32. unread-0.1.0/presets/en/summary.md +94 -0
  33. unread-0.1.0/presets/en/video.md +87 -0
  34. unread-0.1.0/presets/en/website.md +93 -0
  35. unread-0.1.0/presets/ru/_base.md +85 -0
  36. unread-0.1.0/presets/ru/_reduce.md +22 -0
  37. unread-0.1.0/presets/ru/action_items.md +53 -0
  38. unread-0.1.0/presets/ru/broad.md +52 -0
  39. unread-0.1.0/presets/ru/decisions.md +49 -0
  40. unread-0.1.0/presets/ru/digest.md +45 -0
  41. unread-0.1.0/presets/ru/highlights.md +57 -0
  42. unread-0.1.0/presets/ru/links.md +58 -0
  43. unread-0.1.0/presets/ru/multichat.md +80 -0
  44. unread-0.1.0/presets/ru/questions.md +57 -0
  45. unread-0.1.0/presets/ru/quotes.md +55 -0
  46. unread-0.1.0/presets/ru/reactions.md +68 -0
  47. unread-0.1.0/presets/ru/single_msg.md +62 -0
  48. unread-0.1.0/presets/ru/summary.md +93 -0
  49. unread-0.1.0/presets/ru/video.md +91 -0
  50. unread-0.1.0/presets/ru/website.md +89 -0
  51. unread-0.1.0/pyproject.toml +75 -0
  52. unread-0.1.0/reports/.gitkeep +0 -0
  53. unread-0.1.0/storage/.gitkeep +0 -0
  54. unread-0.1.0/storage/media/.gitkeep +0 -0
  55. unread-0.1.0/tests/__init__.py +0 -0
  56. unread-0.1.0/tests/conftest.py +29 -0
  57. unread-0.1.0/tests/test_analysis_cache_keys.py +79 -0
  58. unread-0.1.0/tests/test_app_settings.py +93 -0
  59. unread-0.1.0/tests/test_ask_enrich.py +296 -0
  60. unread-0.1.0/tests/test_ask_followup_prompt.py +69 -0
  61. unread-0.1.0/tests/test_ask_global_flag.py +50 -0
  62. unread-0.1.0/tests/test_ask_mark_read.py +362 -0
  63. unread-0.1.0/tests/test_ask_mutual_exclusion.py +131 -0
  64. unread-0.1.0/tests/test_ask_ref_resolution.py +64 -0
  65. unread-0.1.0/tests/test_ask_wizard_dispatch.py +193 -0
  66. unread-0.1.0/tests/test_chat_picker_layout.py +348 -0
  67. unread-0.1.0/tests/test_chunker.py +62 -0
  68. unread-0.1.0/tests/test_cli_argv.py +134 -0
  69. unread-0.1.0/tests/test_cli_helpers.py +60 -0
  70. unread-0.1.0/tests/test_content_language_semantics.py +92 -0
  71. unread-0.1.0/tests/test_download_media.py +101 -0
  72. unread-0.1.0/tests/test_enrich_link.py +55 -0
  73. unread-0.1.0/tests/test_enrich_optional_deps.py +165 -0
  74. unread-0.1.0/tests/test_enrich_opts.py +112 -0
  75. unread-0.1.0/tests/test_enrich_preset.py +44 -0
  76. unread-0.1.0/tests/test_estimate_cost_locale.py +42 -0
  77. unread-0.1.0/tests/test_filters.py +63 -0
  78. unread-0.1.0/tests/test_folders.py +166 -0
  79. unread-0.1.0/tests/test_formatter.py +192 -0
  80. unread-0.1.0/tests/test_formatter_enrich.py +83 -0
  81. unread-0.1.0/tests/test_formatter_topics.py +143 -0
  82. unread-0.1.0/tests/test_hasher.py +45 -0
  83. unread-0.1.0/tests/test_i18n.py +45 -0
  84. unread-0.1.0/tests/test_interactive.py +308 -0
  85. unread-0.1.0/tests/test_interactive_ask_mode.py +238 -0
  86. unread-0.1.0/tests/test_interactive_cost_fmt.py +64 -0
  87. unread-0.1.0/tests/test_links.py +121 -0
  88. unread-0.1.0/tests/test_media_no_audio.py +43 -0
  89. unread-0.1.0/tests/test_openai_client.py +207 -0
  90. unread-0.1.0/tests/test_options_payload_locale.py +63 -0
  91. unread-0.1.0/tests/test_prepared_run.py +414 -0
  92. unread-0.1.0/tests/test_pricing.py +96 -0
  93. unread-0.1.0/tests/test_project_quality.py +37 -0
  94. unread-0.1.0/tests/test_prompts.py +185 -0
  95. unread-0.1.0/tests/test_prompts_locale.py +73 -0
  96. unread-0.1.0/tests/test_reactions.py +156 -0
  97. unread-0.1.0/tests/test_regressions_deep_review.py +712 -0
  98. unread-0.1.0/tests/test_repo_cache.py +387 -0
  99. unread-0.1.0/tests/test_repo_datetime.py +52 -0
  100. unread-0.1.0/tests/test_repo_enrichments.py +129 -0
  101. unread-0.1.0/tests/test_repo_msgid.py +72 -0
  102. unread-0.1.0/tests/test_report_header.py +126 -0
  103. unread-0.1.0/tests/test_report_paths.py +177 -0
  104. unread-0.1.0/tests/test_resolver.py +29 -0
  105. unread-0.1.0/tests/test_resolver_fallthrough.py +47 -0
  106. unread-0.1.0/tests/test_run_settings.py +101 -0
  107. unread-0.1.0/tests/test_settings_supported_languages.py +85 -0
  108. unread-0.1.0/tests/test_single_msg.py +105 -0
  109. unread-0.1.0/tests/test_system_prompt_composition.py +107 -0
  110. unread-0.1.0/tests/test_tokens.py +59 -0
  111. unread-0.1.0/tests/test_topic_markers_filter.py +134 -0
  112. unread-0.1.0/tests/test_truncation_banner.py +64 -0
  113. unread-0.1.0/tests/test_website_command.py +207 -0
  114. unread-0.1.0/tests/test_website_content.py +155 -0
  115. unread-0.1.0/tests/test_website_repo.py +130 -0
  116. unread-0.1.0/tests/test_website_urls.py +140 -0
  117. unread-0.1.0/tests/test_with_comments.py +143 -0
  118. unread-0.1.0/tests/test_youtube_command.py +498 -0
  119. unread-0.1.0/tests/test_youtube_repo.py +202 -0
  120. unread-0.1.0/tests/test_youtube_urls.py +97 -0
  121. unread-0.1.0/unread/__init__.py +3 -0
  122. unread-0.1.0/unread/analyzer/__init__.py +1 -0
  123. unread-0.1.0/unread/analyzer/chunker.py +119 -0
  124. unread-0.1.0/unread/analyzer/commands.py +2137 -0
  125. unread-0.1.0/unread/analyzer/filters.py +115 -0
  126. unread-0.1.0/unread/analyzer/formatter.py +413 -0
  127. unread-0.1.0/unread/analyzer/hasher.py +45 -0
  128. unread-0.1.0/unread/analyzer/openai_client.py +192 -0
  129. unread-0.1.0/unread/analyzer/pipeline.py +877 -0
  130. unread-0.1.0/unread/analyzer/prompts.py +535 -0
  131. unread-0.1.0/unread/ask/__init__.py +14 -0
  132. unread-0.1.0/unread/ask/commands.py +985 -0
  133. unread-0.1.0/unread/ask/embeddings.py +205 -0
  134. unread-0.1.0/unread/ask/rerank.py +149 -0
  135. unread-0.1.0/unread/ask/retrieval.py +205 -0
  136. unread-0.1.0/unread/cli.py +2007 -0
  137. unread-0.1.0/unread/config.py +330 -0
  138. unread-0.1.0/unread/core/__init__.py +5 -0
  139. unread-0.1.0/unread/core/paths.py +131 -0
  140. unread-0.1.0/unread/core/pipeline.py +795 -0
  141. unread-0.1.0/unread/core/run.py +85 -0
  142. unread-0.1.0/unread/db/__init__.py +1 -0
  143. unread-0.1.0/unread/db/repo.py +1825 -0
  144. unread-0.1.0/unread/db/schema.sql +268 -0
  145. unread-0.1.0/unread/enrich/__init__.py +13 -0
  146. unread-0.1.0/unread/enrich/audio.py +208 -0
  147. unread-0.1.0/unread/enrich/base.py +117 -0
  148. unread-0.1.0/unread/enrich/document.py +255 -0
  149. unread-0.1.0/unread/enrich/image.py +212 -0
  150. unread-0.1.0/unread/enrich/link.py +280 -0
  151. unread-0.1.0/unread/enrich/pipeline.py +222 -0
  152. unread-0.1.0/unread/enrich/video.py +41 -0
  153. unread-0.1.0/unread/export/__init__.py +1 -0
  154. unread-0.1.0/unread/export/commands.py +935 -0
  155. unread-0.1.0/unread/export/markdown.py +119 -0
  156. unread-0.1.0/unread/i18n.py +1624 -0
  157. unread-0.1.0/unread/interactive.py +2319 -0
  158. unread-0.1.0/unread/media/__init__.py +1 -0
  159. unread-0.1.0/unread/media/commands.py +317 -0
  160. unread-0.1.0/unread/media/download.py +203 -0
  161. unread-0.1.0/unread/models.py +108 -0
  162. unread-0.1.0/unread/runner.py +720 -0
  163. unread-0.1.0/unread/settings/__init__.py +6 -0
  164. unread-0.1.0/unread/settings/commands.py +633 -0
  165. unread-0.1.0/unread/tg/__init__.py +1 -0
  166. unread-0.1.0/unread/tg/client.py +81 -0
  167. unread-0.1.0/unread/tg/commands.py +1287 -0
  168. unread-0.1.0/unread/tg/dialogs.py +238 -0
  169. unread-0.1.0/unread/tg/folders.py +145 -0
  170. unread-0.1.0/unread/tg/links.py +118 -0
  171. unread-0.1.0/unread/tg/resolver.py +252 -0
  172. unread-0.1.0/unread/tg/sync.py +375 -0
  173. unread-0.1.0/unread/tg/topics.py +151 -0
  174. unread-0.1.0/unread/util/__init__.py +1 -0
  175. unread-0.1.0/unread/util/flood.py +99 -0
  176. unread-0.1.0/unread/util/logging.py +54 -0
  177. unread-0.1.0/unread/util/pricing.py +46 -0
  178. unread-0.1.0/unread/util/tokens.py +31 -0
  179. unread-0.1.0/unread/website/__init__.py +1 -0
  180. unread-0.1.0/unread/website/commands.py +332 -0
  181. unread-0.1.0/unread/website/content.py +438 -0
  182. unread-0.1.0/unread/website/metadata.py +25 -0
  183. unread-0.1.0/unread/website/paths.py +51 -0
  184. unread-0.1.0/unread/website/urls.py +135 -0
  185. unread-0.1.0/unread/youtube/__init__.py +1 -0
  186. unread-0.1.0/unread/youtube/commands.py +600 -0
  187. unread-0.1.0/unread/youtube/metadata.py +102 -0
  188. unread-0.1.0/unread/youtube/paths.py +54 -0
  189. unread-0.1.0/unread/youtube/transcript.py +350 -0
  190. unread-0.1.0/unread/youtube/urls.py +86 -0
  191. unread-0.1.0/uv.lock +1384 -0
@@ -0,0 +1,418 @@
1
+ ---
2
+ name: unread-dev
3
+ description: |
4
+ Contribute code to the `unread` Python package (github.com/maxbolgarin/unread):
5
+ the local Python CLI that pulls Telegram chats, transcribes voice, analyzes
6
+ with OpenAI, answers questions over the synced corpus (`unread ask`), and
7
+ manages the local DB (backup/restore, doctor, prune). Use this skill when
8
+ editing files under `unread/`, adding a new CLI command or flag, writing
9
+ / modifying a preset, changing the cache or pipeline, fixing a bug, or
10
+ writing tests in that repo — or when the user says "in unread",
11
+ "the Telegram analyzer repo", or references `unread/cli.py`,
12
+ `analyzer/pipeline.py`, `db/repo.py`, `ask/`, or `interactive.py`.
13
+ ---
14
+
15
+ # unread — contributor guide
16
+
17
+ Python 3.11+, uv, Typer, Telethon, OpenAI, aiosqlite. **Every OpenAI call is
18
+ async; every DB call is async. No synchronous I/O in command paths.**
19
+
20
+ ## 1. First check before writing code
21
+
22
+ ```bash
23
+ cd ~/path/to/unread # config + storage are cwd-relative
24
+ uv sync --extra dev # if not already done
25
+ uv run pytest -q # baseline: should be all green
26
+ uv run ruff check . # lint clean
27
+ uv run ruff format --check . # format clean
28
+ unread doctor # validates Telegram session, OpenAI key, ffmpeg, DB integrity, pricing
29
+ ```
30
+
31
+ Before claiming work is done, run the first three — that's what CI runs on Python 3.11, 3.12, and 3.13.
32
+
33
+ ## 2. Layout map
34
+
35
+ ```
36
+ unread/
37
+ cli.py Typer app — every CLI command + help panels.
38
+ Wraps `_run = asyncio.run`. Lazy imports inside handlers
39
+ keep `unread --help` ~100 ms.
40
+ interactive.py Wizard pickers (questionary + prompt_toolkit).
41
+ Step graph: chat → thread → preset → period → enrich → output → mark_read → confirm.
42
+ `_expand_printable_for_search()` extends `string.printable`
43
+ at module import so non-Latin type-to-filter works.
44
+ config.py Settings loader (.env + config.toml → Pydantic with
45
+ extra="forbid"). `_StrictCfg` base for every block.
46
+ BOM-safe `_load_dotenv`. New: `AskCfg`, `high_impact_reactions`.
47
+ models.py Dataclasses: Message, Chat, Subscription, ResolvedRef…
48
+ core/
49
+ pipeline.py prepare_chat_run() — shared prefix for analyze/dump/download-media
50
+ (resolve → backfill → iter → per-topic unread filter → enrich).
51
+ run.py PreparedRun dataclass (handoff between prefix + consumer).
52
+ paths.py slugify, chat_slug, topic_slug, compute_window (UTC),
53
+ parse_ymd (UTC), derive_internal_id, has_explicit_period.
54
+ db/
55
+ repo.py Single Repo class. All DB access goes through it.
56
+ schema.sql Source of truth; idempotently applied on every Repo.open.
57
+ `Repo._apply_additive_migrations()` adds known missing columns
58
+ for older local DBs; destructive changes still require
59
+ "delete data.sqlite + re-sync".
60
+ tg/
61
+ client.py Telethon client factory (session at storage/session.sqlite.session).
62
+ resolver.py @name / link / fuzzy / numeric id → ResolvedRef.
63
+ sync.py backfill() — incremental fetch + Rich progress bar
64
+ (determinate when from_msg_id+forward, indeterminate otherwise).
65
+ dialogs.py list_unread_dialogs(); correct_forum_unread() helper —
66
+ fixes Telegram's stale dialog-level forum counts via
67
+ GetForumTopicsRequest.
68
+ topics.py folders.py Forum topics + dialog-filter (chat folder) helpers.
69
+ `chat_folder_index(client) -> dict[chat_id, list[folder_titles]]`.
70
+ links.py Telegram link parsing / template building.
71
+ commands.py `unread chats add/list/...` handlers + `cmd_doctor`.
72
+ `cmd_doctor` checks: env files, secrets, ffmpeg, disk,
73
+ DB integrity, Telegram session, OpenAI reachability,
74
+ presets, chat AND audio pricing coverage.
75
+ analyzer/
76
+ prompts.py Preset loader + _base.md composer; BASE_VERSION cache-bust knob.
77
+ name == filename stem enforced; placeholder dry-run validation.
78
+ filters.py FilterOpts(min_msg_chars, include_transcripts, text_only,
79
+ sender_substring, sender_id). filter_messages applies all.
80
+ chunker.py Token-aware chunking. Raises on degenerate budgets
81
+ (< 2000 tokens) instead of silent clamp-to-500.
82
+ formatter.py Render messages for the LLM prompt. `_high_impact_marker`
83
+ prefixes [high-impact] for messages whose reaction sum
84
+ ≥ settings.analyze.high_impact_reactions.
85
+ hasher.py batch_hash / reduce_hash / text_hash — cache keys.
86
+ openai_client.py chat_complete() with retry-on-truncation. Uses
87
+ max_completion_tokens (NOT max_tokens — deprecated).
88
+ pipeline.py run_analysis() — map-reduce orchestration with progress bar.
89
+ Public `estimate_cost(n_messages, preset, settings)` used by
90
+ --max-cost / --dry-run / wizard cost preview.
91
+ `AVG_TOKENS_PER_MSG = 60` (Cyrillic-heavy estimate).
92
+ commands.py `unread analyze` + batch flow + output rendering.
93
+ Helpers: `_expand_citations`, `_self_check`, `_post_to_chat`,
94
+ `_post_to_saved_messages` shim, `_split_for_telegram`,
95
+ `_CITATION_RE`.
96
+ ask/ `unread ask` — Q&A across the synced corpus.
97
+ commands.py cmd_ask + _run_single_turn (one Q→A iteration);
98
+ handles the post-answer follow-up loop / --semantic /
99
+ --rerank / --build-index / --refresh / --show-retrieved /
100
+ --max-cost.
101
+ retrieval.py Keyword LIKE retrieval over text||transcript;
102
+ return_scores=True returns list[(Message, int)].
103
+ rerank.py Cheap-model 1-5 ratings; tolerant `_parse_ratings`.
104
+ On total batch failure, returns keyword-sorted pool.
105
+ embeddings.py OpenAI text-embedding-3-small index (build_index +
106
+ semantic_search). Vectors stored as
107
+ array.array('f').tobytes() in message_embeddings.
108
+ Cosine in pure Python (stdlib `math`, no numpy).
109
+ enrich/ Per-kind enrichers run BEFORE filtering:
110
+ pipeline.py enrich_messages() orchestrator + Rich Progress bar +
111
+ per-doc_id asyncio.Lock to dedup parallel work.
112
+ audio.py voice/videonote/video → Whisper transcript (media_enrichments).
113
+ Logs include chat_id / msg_id / msg_date.
114
+ image.py photo → vision-model description.
115
+ document.py pdf/docx/txt/code → local extraction (no OpenAI).
116
+ link.py URL fetch + filter_model summary (link_enrichments).
117
+ Per-message inline summary, NOT full-article analysis —
118
+ see `unread/website/` for that.
119
+ video.py Thin wrapper over audio.py.
120
+ base.py EnrichOpts / EnrichResult / EnrichStats.
121
+ youtube/ Non-Telegram source: `unread analyze <youtube-url>`.
122
+ urls.py is_youtube_url + extract_video_id (cheap stdlib check).
123
+ metadata.py YoutubeMetadata dataclass + yt-dlp metadata fetch.
124
+ transcript.py captions (VTT) → text + timed cues, OR audio download
125
+ + Whisper segmentation. TranscriptResult shape.
126
+ paths.py reports/youtube/<channel>/<video>-<preset>-<ts>.md.
127
+ commands.py cmd_analyze_youtube — synth-message build, dispatch to
128
+ run_analysis with source_kind="video", link_template
129
+ "?t={msg_id}s" so citations jump to the moment.
130
+ Cache row in `youtube_videos` (metadata + transcript +
131
+ timed_cues_json).
132
+ website/ Non-Telegram source: `unread analyze <website-url>`.
133
+ urls.py is_website_url + is_telegram_url (so t.me/... still
134
+ flows to the resolver) + normalize_url (drops tracking
135
+ params) + page_id (sha256(normalized_url)[:16]) +
136
+ domain_of.
137
+ metadata.py WebsiteMetadata dataclass.
138
+ content.py fetch_page (httpx) + trafilatura extractor with BS4
139
+ fallback (semantic-tag pass, then whole-body get_text).
140
+ _segment_paragraphs splits at blank lines, then
141
+ sentences, then hard-cut. _SPA_MARKERS +
142
+ _explain_empty_extraction produce a tailored error
143
+ for JS-rendered SPAs vs short non-SPA vs large-but-empty.
144
+ paths.py reports/website/<domain>/<page>-<preset>-<ts>.md.
145
+ commands.py cmd_analyze_website — synth-message build, dispatch to
146
+ run_analysis with source_kind="website", link_template
147
+ = page URL (no fragment). Cache row in `website_pages`
148
+ (metadata + paragraphs_json + content_hash).
149
+ media/ Raw media download + `unread download-media` (deprecated;
150
+ folded into `dump --save-media`).
151
+ export/ `unread dump` (md/jsonl/csv).
152
+ util/ flood, logging, pricing, tokens (tiktoken wrapper).
153
+ presets/ Per-language preset trees: presets/<lang>/*.md.
154
+ ru/ RU bodies + _base.md + _reduce.md.
155
+ en/ EN bodies + _base.md + _reduce.md.
156
+ Bundled in each lang: summary, broad, digest, action_items,
157
+ decisions, highlights, links, questions, quotes, reactions,
158
+ single_msg, multichat. Active language picked by
159
+ settings.locale.language (default "en").
160
+ unread/i18n.py User-visible strings keyed by language. `t(key, lang=None)`
161
+ resolves from settings at call time. Add new strings here,
162
+ NOT inline in module code.
163
+ unread/settings/ `unread settings` command. DB-backed overrides for
164
+ commands.py [locale] / [openai] config blocks. Persisted in the
165
+ `app_settings` SQLite table; applied on every
166
+ `open_repo()` via `db/repo.py:_apply_db_overrides`.
167
+ Allow-list of overlay keys: `_OVERRIDE_KEYS`.
168
+ tests/ pytest-asyncio auto mode; all tests live here.
169
+ `test_regressions_deep_review.py` is the "bugs locked in"
170
+ file — every audit / bug-fix lands a regression test there.
171
+ ```
172
+
173
+ ## 3. Invariants — DO NOT VIOLATE
174
+
175
+ These were debugged into existence. Breaking them causes silent data corruption or wasted spend.
176
+
177
+ 1. **Never cache truncated results.** `pipeline._call_cached` gates `cache_put` on `not res.truncated`. The cache row also has a `truncated` column, and `_call_cached` re-runs on a hit with `truncated=1`.
178
+
179
+ 2. **`build_messages` order is `system → static_context → dynamic`.** `openai_client.build_messages` enforces this. OpenAI's prompt cache only hits when the stable prefix leads.
180
+
181
+ 3. **Bump `prompt_version`** in a preset's frontmatter when its body changes. Without the bump, users get stale cached results.
182
+
183
+ 4. **Bump `BASE_VERSION`** (in `analyzer/prompts.py`, currently `"v4"`) when editing `presets/<lang>/_base.md` (any language) or the per-language `_FORUM_CONTEXT` constants or `compose_system_prompt`'s structural behavior. Busts `analysis_cache` for **every** preset at once.
184
+
185
+ 5. **`redact_old_messages` WHERE clause must include "IS NOT NULL".** Otherwise rowcount reports age-matching rows, not rows actually changed. Regression test in `tests/test_repo_cache.py`.
186
+
187
+ 6. **Use `max_completion_tokens`, not `max_tokens`.** Required on gpt-5+ and reasoning models. `openai_client._completion` already does this.
188
+
189
+ 7. **Unread-default anchor is `read_inbox_max_id`.** Batch flow (`_run_no_ref` in analyzer + dump) and `--folder` filter rely on `msg_id > read_inbox_max_id`.
190
+
191
+ 8. **Config + storage are cwd-relative.** `config.py` resolves `.env`, `config.toml`, and `storage/` from `Path.cwd()`. Don't hardcode install-dir paths.
192
+
193
+ 9. **New run-style commands go through `core/pipeline.prepare_chat_run`** *unless* the source isn't a Telegram chat (YouTube, websites, future RSS / eBook / etc.). For non-Telegram sources, dispatch BEFORE `prepare_chat_run` from `cmd_analyze`, build synthetic `Message` rows with `chat_id=0`, and call `run_analysis` directly with `source_kind` + `link_template_override`. Both `youtube/commands.py` and `website/commands.py` follow this pattern — copy that shape, don't re-implement the Telegram prefix.
194
+
195
+ 10. **`AnalysisOptions.options_payload` must include every option that affects analysis output.** Adding a new flag that changes the prompt or the message set without updating `options_payload` silently returns stale cached results. Recent additions: `sender_substring`, `sender_id`, `enrich_options` block, `youtube_video_id`, **`website_page_id` + `website_content_hash`**, `source_kind`. For mutable sources (websites — pages can be edited live), the **content hash** is what makes "fetch unchanged → cache hit, fetch changed → cache miss" work; pure URL keying would silently return stale analyses of edited articles.
196
+
197
+ 11. **`schema.sql` statements use `IF NOT EXISTS`.** Re-applying schema on every connect is the design — `tests/test_regressions_deep_review.py:test_schema_apply_is_idempotent` enforces it. Destructive changes (column type changes, drops) need explicit handling in `_apply_additive_migrations` plus a documented "delete `data.sqlite` + re-sync" path.
198
+
199
+ 12. **`unread ask` semantic scores map to `[0, 100]`.** Cosine `[-1, 1]` is mapped via `(s + 1) * 50` so rerank's "default 0 for unrated" sort works for negative similarities. Don't shortcut to `round(s * 100)`.
200
+
201
+ 13. **Citations regex `_CITATION_RE` matches `[^)]+` for the URL.** The LLM is instructed to emit Telegram URLs (no parens). Wikipedia-style URLs with parens would truncate; that's a documented limitation, not a regression.
202
+
203
+ 14. **Presets live under `presets/<language>/`.** The loader does not fall back across languages — each language is autonomous. Adding a new user-visible string → put English+native entries in `unread/i18n.py:_STRINGS`, never inline in module code. Both `language` and `content_language` must enter `AnalysisOptions.options_payload` and `Repo.put_last_run_args` payload.
204
+
205
+ 15. **`language` and `content_language` have non-overlapping responsibilities.** `language` = UI / saved-report headings only (wizard, `## Sources`, `## Verification`, truncation banner, report metadata). `content_language` = prompts / LLM input (which `presets/<lang>/` directory the loader reads, image+link enricher prompts, ask system prompt, formatter labels in the LLM input, verification audit prompt). When in doubt: anything the LLM sees → `content_language`; anything ONLY the user sees → `language`.
206
+
207
+ 16. **Persistent settings overlay applies via `open_repo`.** Adding a new override key requires extending three places together: `db/repo.py:_OVERRIDE_KEYS`, `db/repo.py:_apply_db_overrides` (with the actual settings mutation), and `unread/settings/commands.py:_KNOWN_KEYS` (so `unread settings set <key>` validates). Don't apply overrides anywhere except `_apply_db_overrides` — keeping the surface small avoids drift.
208
+
209
+ ## 4. Patterns to follow
210
+
211
+ ### Async tests with real SQLite
212
+
213
+ ```python
214
+ from pathlib import Path
215
+ import pytest
216
+ from unread.db.repo import Repo
217
+
218
+ @pytest.fixture
219
+ async def repo(tmp_path: Path) -> Repo:
220
+ r = await Repo.open(tmp_path / "t.sqlite")
221
+ yield r
222
+ await r.close()
223
+
224
+ async def test_x(repo: Repo) -> None:
225
+ ... # pytest-asyncio auto mode; no @pytest.mark.asyncio needed
226
+ ```
227
+
228
+ `pytest-asyncio` auto mode is enabled in `pyproject.toml`. Never introduce in-memory stubs for the DB layer — every repo test uses a real temp SQLite via `tmp_path`.
229
+
230
+ ### Stubbing OpenAI (no network in tests)
231
+
232
+ ```python
233
+ from unread.analyzer import openai_client
234
+
235
+ async def test_retry(monkeypatch):
236
+ async def fake_completion(oai, model, messages, max_tokens, temperature):
237
+ # return a minimal fake response with .choices[0].message.content,
238
+ # .choices[0].finish_reason, and .usage.prompt_tokens/completion_tokens
239
+ ...
240
+ monkeypatch.setattr(openai_client, "_completion", fake_completion)
241
+ # ...call chat_complete and assert
242
+ ```
243
+
244
+ For `ask` rerank tests, stub `unread.ask.rerank.chat_complete` directly — see `test_rerank_total_failure_returns_keyword_sorted_pool`.
245
+
246
+ ### Typer command stub (keeps `unread --help` fast)
247
+
248
+ ```python
249
+ @app.command(rich_help_panel=PANEL_MAIN)
250
+ def newcmd(...) -> None:
251
+ """One-line help."""
252
+ from unread.analyzer.commands import cmd_newcmd # lazy
253
+ _run(cmd_newcmd(...))
254
+ ```
255
+
256
+ For sub-commands, register on the existing Typer instances: `chats_app`, `cache_app`, `reports_app`. Don't import Telethon/OpenAI/heavy modules at module top of `cli.py`.
257
+
258
+ ### Lint exceptions — don't "fix" them
259
+
260
+ `pyproject.toml` intentionally suppresses these (read the comment block there):
261
+
262
+ - `RUF001 / RUF002` — em-dashes and `×` in Russian UX strings
263
+ - `PLC0415` — lazy imports in CLI command bodies
264
+ - `B008` — `typer.Option(...)` in function defaults
265
+ - `PLW0603` — settings singleton uses module-level global
266
+ - `E501` — long lines in SQL strings and log messages
267
+ - `PLR09xx` — intentionally flat functions
268
+
269
+ `RUF003` (ambiguous chars in *comments*) is still enforced. Use `*`, not `×`, in comments.
270
+
271
+ ## 5. Preset format (`presets/<lang>/*.md`)
272
+
273
+ Presets live under per-language directories (`presets/en/`, `presets/ru/`).
274
+ The active language is `settings.locale.language` (default `"en"`); the
275
+ loader does NOT fall back across languages — each language is autonomous.
276
+ Use `prompts.get_presets(language)` to get the dict for a specific
277
+ language; `prompts.PRESETS` is a backward-compat lazy proxy that resolves
278
+ to the active locale's tree at access time.
279
+
280
+ ```
281
+ ---
282
+ name: summary # MUST match filename stem (load-time check)
283
+ prompt_version: v2 # BUMP when prompt body changes (per-language; bump only the language(s) you edited)
284
+ description: One-liner shown by the wizard's preset picker # NEW — optional
285
+ needs_reduce: true # false → skip map-reduce even on long inputs
286
+ filter_model: gpt-5.4-nano # map-phase model
287
+ final_model: gpt-5.4-mini # reduce / single-chunk model (note: default is mini, not flagship)
288
+ output_budget_tokens: 4000 # reduce / single-chunk max_tokens
289
+ map_output_tokens: 1500 # per-chunk map max_tokens
290
+ enrich: [link, image] # optional — declares this preset's enrichment needs
291
+ ---
292
+ <system prompt goes here>
293
+ ---USER---
294
+ <user template — must contain {period} {title} {msg_count} {messages}>
295
+ ```
296
+
297
+ Files starting with `_` (`_reduce.md`, `_base.md`) are helpers, not presets. Missing `---USER---` → whole body becomes system, `DEFAULT_USER_TAIL[language]` is used. Missing standard placeholders are auto-appended; **typo placeholders fail at load time** with a readable error (see `_validate_user_template`). Name vs filename stem mismatch also fails at load time.
298
+
299
+ **Adding a new preset:** drop the `.md` under `presets/<lang>/` with `description:` set in the frontmatter; the wizard picks it up automatically. Translate to other supported languages.
300
+
301
+ **When to bump `output_budget_tokens`:** if a user reports the "⚠ Output truncated" banner. Also bump `prompt_version` (otherwise the cached truncated-first / fine-later mix gets reused).
302
+
303
+ ## 6. Adding a new CLI command — checklist
304
+
305
+ 1. **Handler in `unread/<subpkg>/commands.py`** — `async def cmd_newcmd(...)`, uses `open_repo` + `tg_client` async context managers.
306
+ 2. **If it's a run-style command** (resolve chat → fetch messages → do X): use `core/pipeline.prepare_chat_run`.
307
+ 3. **Typer stub in `cli.py`** — lazy import, match `@app.command(rich_help_panel=PANEL_*)`. Pick the right panel.
308
+ 4. **If the command costs money**: add `--max-cost` (use `analyzer.pipeline.estimate_cost` or `count_tokens + chat_cost`) and `--dry-run`.
309
+ 5. **If the command's flags affect analysis output**: extend `AnalysisOptions.options_payload` so the cache key reflects them.
310
+ 6. **Tests**: unit-test pure logic — don't test Typer wiring itself.
311
+ 7. **Update README.md** if user-facing.
312
+ 8. **Update `unread-usage` skill** so future sessions surface the new command.
313
+ 9. **Update CLAUDE.md** if you've added a new invariant.
314
+
315
+ ## 7. Adding a new top-level config block
316
+
317
+ 1. New `_StrictCfg` subclass in `config.py`.
318
+ 2. Add as a `Field(default_factory=…)` on `Settings`.
319
+ 3. Reference via `get_settings().<section>.<field>`.
320
+ 4. Document in `config.toml.example` (commented-out, showing default).
321
+ 5. `_StrictCfg` inheritance + `Settings.model_config = SettingsConfigDict(extra="forbid")` makes typos fail loudly.
322
+
323
+ ## 8. Rerunning the user's request — `--repeat-last` plumbing
324
+
325
+ `Repo.put_last_run_args(chat_id, thread_id, args_dict)` persists a JSON-safe slice of cmd_analyze's flags after every successful run. `Repo.get_last_run_args(chat_id, thread_id=0)` reads them back. The `--repeat-last` block in `cmd_analyze` overrides default-shaped values with saved ones; explicit CLI flags still win.
326
+
327
+ When adding a new flag that should participate in `--repeat-last`:
328
+ 1. Add to the dict written by `repo.put_last_run_args(...args=...)` in `_run_single`.
329
+ 2. Add a "default-shaped → use saved" branch in the `--repeat-last` overlay in `cmd_analyze`.
330
+ 3. Be explicit about JSON-safety — Path, datetime, EnrichOpts must flatten to scalars.
331
+
332
+ ## 9. Schema additions
333
+
334
+ Add the table at the bottom of `db/schema.sql` with `IF NOT EXISTS`. For columns on existing tables, add to `_apply_additive_migrations` in `db/repo.py` so older DBs catch up. Recent additions:
335
+
336
+ - `chat_last_run_args(chat_id, thread_id, args_json, updated_at)` — backs `--repeat-last`.
337
+ - `message_embeddings(chat_id, msg_id, model, vector BLOB, created_at)` — backs `unread ask --semantic`.
338
+ - `analysis_cache.truncated INTEGER NOT NULL DEFAULT 0` (additive on existing rows).
339
+ - `messages.reactions TEXT` (additive on existing rows).
340
+ - `youtube_videos(video_id, …, transcript, transcript_timed_json, …)` — backs `unread analyze <youtube-url>`.
341
+ - `website_pages(page_id, url, paragraphs_json, content_hash, extractor, …)` — backs `unread analyze <website-url>`. `page_id = sha256(normalized_url)[:16]`; `content_hash = sha256(joined paragraphs)[:32]` flows into `AnalysisOptions.options_payload`.
342
+
343
+ Test idempotency in `test_regressions_deep_review.py:test_schema_apply_is_idempotent` and additive migrations in `test_schema_apply_upgrades_legacy_tables`.
344
+
345
+ ## 10. Cost guard / budget pattern
346
+
347
+ ```python
348
+ from unread.analyzer.pipeline import estimate_cost
349
+
350
+ lo, hi = estimate_cost(n_messages=N, preset=preset, settings=get_settings())
351
+ if hi is not None and hi > max_cost:
352
+ if yes:
353
+ raise typer.Exit(2)
354
+ if not typer.confirm(f"Estimate ${lo:.4f}–${hi:.4f} > ${max_cost}; run anyway?"):
355
+ raise typer.Exit(0)
356
+ ```
357
+
358
+ For `ask`-shape commands (no map-reduce), use `count_tokens(prompt, model) + chat_cost(model, prompt_tokens, 0, max_tokens)` — see `ask/commands.py:_run_single_turn`.
359
+
360
+ ## 11. Citation expansion / audit pattern
361
+
362
+ `_expand_citations(body, *, chat_id, repo, context_n, thread_id, cap=30)` — regex-parses `[#msg_id](url)` citations, looks up surrounding messages via `Repo.get_messages_around`, appends `<details>` blocks. Cap exists to prevent a runaway LLM from 10×-ing the report file. Backticks in body text are escaped to avoid breaking the inline `[ts #msg_id]` code span.
363
+
364
+ `_self_check(result, messages, repo)` — cheap-model audit pass; appends `## Verification` to the report. Best-effort; failure returns empty string and the run continues.
365
+
366
+ Both run between `run_analysis` and `_print_and_write` in `_run_single`. They mutate `result.final_result` so downstream rendering / posting / `--post-saved` see the augmented body.
367
+
368
+ ## 12. Posting analyses to Telegram
369
+
370
+ `_post_to_chat(client, repo, result, *, title, target)` — resolves any chat ref via `tg/resolver.resolve` and sends. `me` / `saved` / empty target → `client.get_me()`. `_split_for_telegram` chunks on paragraph → line → hard-cut to stay under Telegram's 4000-char per-message ceiling. Failures warn but don't crash the run (the report is already on disk).
371
+
372
+ `_post_to_saved_messages` is a back-compat shim that calls `_post_to_chat(target="me")` so legacy `--post-saved` plumbing keeps working without threading `repo` through.
373
+
374
+ ## 13. `unread ask` retrieval pipeline
375
+
376
+ ```
377
+ cmd_ask
378
+ ├─ tokenize (bilingual stop-words)
379
+ ├─ optional --refresh: backfill scoped chats
380
+ ├─ optional --build-index: build embeddings, exit
381
+ ├─ retrieve_messages (keyword) OR semantic_search (cosine)
382
+ ├─ optional rerank (cheap-model 1-5 ratings)
383
+ ├─ format_messages with per-chat link templates
384
+ ├─ count_tokens cost preview / --max-cost guard
385
+ ├─ chat_complete (multi-turn message history during the post-answer follow-up loop)
386
+ └─ render or save
387
+ ```
388
+
389
+ When adding a flag that affects retrieval (limit, scope, model): respect rerank's `keep` logic — `candidate_limit = max(limit, ask_cfg.rerank_top_k)` if rerank is on. Re-sort chronologically after rerank for the LLM context.
390
+
391
+ For the post-answer follow-up loop: `_build_history_messages(system, history, new_user_text)` produces `[system, user, assistant, …, user]`. System prompt stays at index 0 so prompt-cache hits on the first turn are byte-identical to single-shot.
392
+
393
+ Use `phase=ask` in `chat_complete` context for cost attribution.
394
+
395
+ ## 14. Wizard step graph — don't break it
396
+
397
+ Order: `chat → thread (forum) → preset (analyze) → period → enrich → output → mark_read → confirm`. Period before enrich is deliberate — the enrich picker shows period-scoped media counts via `Repo.media_breakdown(...)` keyed by `_period_to_db_filters(period, custom_since, custom_until, custom_from_msg, chat)`.
398
+
399
+ When adding a step:
400
+ - BACK transitions in `output` and `mark_read` go to `enrich` (or to `preset`/`chat` for run-on-all).
401
+ - ESC must be wired via `_bind_escape(question, BACK)` to return BACK; first step uses `None` (cancel).
402
+ - Folder column in pickers comes from `tg/folders.chat_folder_index(client)` — re-fetch when relevant.
403
+
404
+ `_expand_printable_for_search()` runs at module import and extends `string.printable` with Cyrillic / Greek / Arabic / Hebrew / Latin Extended ranges. CJK is excluded on purpose (would register tens of thousands of bindings).
405
+
406
+ ## 15. Before opening a PR — the CI-equivalent local check
407
+
408
+ ```bash
409
+ uv run pytest -q # must be 100% green; no skips without a reason
410
+ uv run ruff check . # must be "All checks passed!"
411
+ uv run ruff format --check . # must report "N files already formatted"
412
+ ```
413
+
414
+ These three are exactly what `.github/workflows/ci.yml` runs on Python 3.11, 3.12, 3.13. CI caches `uv.lock` — **`uv.lock` must be committed** (history shows a CI failure from this exact footgun).
415
+
416
+ Run `unread doctor` after any DB / config / dependency change.
417
+
418
+ When in doubt, check `CLAUDE.md` at the repo root — it's the canonical pointer to invariants and anti-patterns. `ROADMAP.md` tracks deferred work; `tests/test_regressions_deep_review.py` is the running list of bugs locked in by tests.