scholarcli 1.26__tar.gz → 1.28__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 (97) hide show
  1. {scholarcli-1.26/src/scholarcli.egg-info → scholarcli-1.28}/PKG-INFO +63 -3
  2. {scholarcli-1.26 → scholarcli-1.28}/README.md +59 -2
  3. {scholarcli-1.26 → scholarcli-1.28}/pyproject.toml +7 -1
  4. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/__init__.py +6 -0
  5. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/cache.py +41 -8
  6. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/cli.py +392 -113
  7. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/crossref.py +10 -18
  8. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/enrich.py +12 -14
  9. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/llm_review.py +165 -75
  10. scholarcli-1.28/src/scholar/network.py +226 -0
  11. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/notes.py +147 -30
  12. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/pdf.py +162 -54
  13. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/providers.py +589 -178
  14. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/ratelimit.py +3 -0
  15. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/review.py +600 -144
  16. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/scholar.py +94 -2
  17. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/tui.py +110 -37
  18. scholarcli-1.28/src/scholar/tutorials/enrich-verify-export.md +162 -0
  19. scholarcli-1.28/src/scholar/tutorials/first-search.md +190 -0
  20. scholarcli-1.28/src/scholar/tutorials/llm-synthesis.md +316 -0
  21. scholarcli-1.28/src/scholar/tutorials/reviewing-in-the-tui.md +206 -0
  22. scholarcli-1.28/src/scholar/tutorials/scholar-with-an-agent.md +286 -0
  23. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/unpaywall.py +13 -18
  24. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/utils.py +206 -0
  25. {scholarcli-1.26 → scholarcli-1.28/src/scholarcli.egg-info}/PKG-INFO +63 -3
  26. {scholarcli-1.26 → scholarcli-1.28}/src/scholarcli.egg-info/SOURCES.txt +7 -0
  27. {scholarcli-1.26 → scholarcli-1.28}/src/scholarcli.egg-info/requires.txt +3 -0
  28. scholarcli-1.28/tests/test_cache.py +210 -0
  29. {scholarcli-1.26 → scholarcli-1.28}/tests/test_cli.py +593 -5
  30. {scholarcli-1.26 → scholarcli-1.28}/tests/test_crossref.py +19 -2
  31. {scholarcli-1.26 → scholarcli-1.28}/tests/test_enrich.py +12 -0
  32. {scholarcli-1.26 → scholarcli-1.28}/tests/test_llm_review.py +197 -20
  33. scholarcli-1.28/tests/test_network.py +193 -0
  34. {scholarcli-1.26 → scholarcli-1.28}/tests/test_notes.py +176 -0
  35. {scholarcli-1.26 → scholarcli-1.28}/tests/test_pdf.py +147 -0
  36. {scholarcli-1.26 → scholarcli-1.28}/tests/test_providers.py +796 -37
  37. {scholarcli-1.26 → scholarcli-1.28}/tests/test_ratelimit.py +24 -1
  38. {scholarcli-1.26 → scholarcli-1.28}/tests/test_review.py +708 -9
  39. {scholarcli-1.26 → scholarcli-1.28}/tests/test_scholar.py +123 -1
  40. scholarcli-1.28/tests/test_tui.py +204 -0
  41. {scholarcli-1.26 → scholarcli-1.28}/tests/test_unpaywall.py +24 -3
  42. {scholarcli-1.26 → scholarcli-1.28}/tests/test_utils.py +172 -0
  43. scholarcli-1.26/tests/test_cache.py +0 -98
  44. scholarcli-1.26/tests/test_tui.py +0 -31
  45. {scholarcli-1.26 → scholarcli-1.28}/LICENSE +0 -0
  46. {scholarcli-1.26 → scholarcli-1.28}/setup.cfg +0 -0
  47. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/__main__.py +0 -0
  48. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/preprint.py +0 -0
  49. {scholarcli-1.26 → scholarcli-1.28}/src/scholar/questionary.py +0 -0
  50. {scholarcli-1.26 → scholarcli-1.28}/src/scholarcli.egg-info/dependency_links.txt +0 -0
  51. {scholarcli-1.26 → scholarcli-1.28}/src/scholarcli.egg-info/entry_points.txt +0 -0
  52. {scholarcli-1.26 → scholarcli-1.28}/src/scholarcli.egg-info/top_level.txt +0 -0
  53. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/__init__.py +0 -0
  54. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/__init__.py +0 -0
  55. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/aggregator.py +0 -0
  56. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/arxiv.py +0 -0
  57. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/base.py +0 -0
  58. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/crossref.py +0 -0
  59. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/google_scholar.py +0 -0
  60. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/openalex.py +0 -0
  61. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/opencitations.py +0 -0
  62. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/apis/semantic_scholar.py +0 -0
  63. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/cli.py +0 -0
  64. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/exporters/__init__.py +0 -0
  65. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/exporters/bibtex.py +0 -0
  66. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/exporters/csv_exporter.py +0 -0
  67. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/exporters/tikz.py +0 -0
  68. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/filters/__init__.py +0 -0
  69. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/filters/filter_engine.py +0 -0
  70. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/models.py +0 -0
  71. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/paper_utils.py +0 -0
  72. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/parsers/__init__.py +0 -0
  73. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/parsers/pdf_parser.py +0 -0
  74. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/scoring/__init__.py +0 -0
  75. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/scoring/base.py +0 -0
  76. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/scoring/llm_scorer.py +0 -0
  77. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/scoring/tfidf_scorer.py +0 -0
  78. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/services.py +0 -0
  79. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/snowballing.py +0 -0
  80. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/storage/__init__.py +0 -0
  81. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/storage/json_storage.py +0 -0
  82. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/tui/__init__.py +0 -0
  83. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/tui/app.py +0 -0
  84. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/tui/dialogs.py +0 -0
  85. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/tui/setup.py +0 -0
  86. {scholarcli-1.26 → scholarcli-1.28}/src/snowball/visualization.py +0 -0
  87. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/__init__.py +0 -0
  88. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/analysis.py +0 -0
  89. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/cli.py +0 -0
  90. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/clustering.py +0 -0
  91. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/database.py +0 -0
  92. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/grobid.py +0 -0
  93. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/logging.py +0 -0
  94. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/models.py +0 -0
  95. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/project.py +0 -0
  96. {scholarcli-1.26 → scholarcli-1.28}/src/tuxedo/tui.py +0 -0
  97. {scholarcli-1.26 → scholarcli-1.28}/tests/test_preprint.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scholarcli
3
- Version: 1.26
3
+ Version: 1.28
4
4
  Summary: A tool for structured literature searches across bibliographic databases
5
5
  Author-email: Daniel Bosk <dbosk@kth.se>, Ric Glassey <glassey@kth.se>
6
6
  License-Expression: MIT
@@ -14,6 +14,7 @@ Requires-Dist: pyalex>=0.19
14
14
  Requires-Dist: arxiv>=2.1.0
15
15
  Requires-Dist: cachetools>=6.2.4
16
16
  Requires-Dist: platformdirs>=4.5.1
17
+ Requires-Dist: filelock>=3.12
17
18
  Requires-Dist: textual>=6.11.0
18
19
  Requires-Dist: pypandoc>=1.14
19
20
  Requires-Dist: click>=8.0.0
@@ -36,6 +37,8 @@ Requires-Dist: pyyaml>=6.0
36
37
  Requires-Dist: questionary>=2.0.0
37
38
  Requires-Dist: llm-github-copilot>=0.3.1
38
39
  Requires-Dist: llm-mistral<0.16,>=0.15
40
+ Requires-Dist: llm-openai-via-codex>=0.1a0
41
+ Requires-Dist: pytorial>=0.8.1
39
42
  Dynamic: license-file
40
43
 
41
44
  # Scholar
@@ -160,7 +163,8 @@ Search results are cached to avoid redundant API calls:
160
163
 
161
164
  ```bash
162
165
  scholar cache info # Show cache statistics
163
- scholar cache clear # Delete cached results
166
+ scholar cache clear # Delete cached results (keeps paid LLM output;
167
+ # add --include-llm to delete that too)
164
168
  scholar cache path # Print cache directory
165
169
  ```
166
170
 
@@ -227,6 +231,30 @@ scholar search "machine learning privacy"
227
231
  scholar rq "How do LLMs support novice programming?" --count 20
228
232
  ```
229
233
 
234
+ ### Tutorials
235
+
236
+ Scholar ships interactive tutorials (built on
237
+ [pytorial](https://github.com/dbosk/pytorial)): each step opens a shell,
238
+ you type the command the step asks for, and the transcript is checked
239
+ before the lesson moves on.
240
+
241
+ ```bash
242
+ scholar tutorial list # the lessons and your progress
243
+ scholar tutorial run first-search # start or resume a lesson
244
+ scholar tutorial run --restart first-search
245
+ scholar tutorial review first-search # read back what you typed
246
+ ```
247
+
248
+ The lessons, in order: `first-search` (search, providers, output formats,
249
+ saving a session), `reviewing-in-the-tui` (recording decisions),
250
+ `enrich-verify-export` (abstracts and open access, retractions, CSV and
251
+ LaTeX exports), `llm-synthesis` (setting up the `llm` command, LLM
252
+ classification, a literature overview) and `scholar-with-an-agent` (what
253
+ an agent running the `backing-claims` skill does with Scholar, and how to
254
+ audit it). Every lesson runs in its own workspace with its own session
255
+ store, so practice sessions never appear in your real `scholar sessions
256
+ list`.
257
+
230
258
  ## Installation
231
259
 
232
260
  If you don't use `pipx`, you can install with `pip`:
@@ -270,7 +298,7 @@ Some providers require API keys set as environment variables:
270
298
  | SSRN, bioRxiv, medRxiv, ChemRxiv, Research Square, Preprints.org, IACR | `SCHOLAR_EMAIL` | No | Any email (they search via OpenAlex) |
271
299
  | Web of Science | `WOS_EXPANDED_API_KEY` or `WOS_STARTER_API_KEY` | Yes | [developer.clarivate.com](https://developer.clarivate.com) |
272
300
  | IEEE Xplore | `IEEE_API_KEY` | Yes | [developer.ieee.org](https://developer.ieee.org) |
273
- | Scopus | `SCOPUS_API_KEY` | Yes | [dev.elsevier.com](https://dev.elsevier.com) |
301
+ | Scopus | `SCOPUS_API_KEY` | Yes | [dev.elsevier.com](https://dev.elsevier.com). Searching works from any network, but abstracts, keywords and full author lists come only with Scopus's COMPLETE view, which is bound to the institution's IP range (or `SCOPUS_INST_TOKEN`). scholar asks for it and falls back by itself (`scholar providers check` tells which view you get); `SCOPUS_VIEW=standard` or `complete` overrides. A 400 "Error translating query" on *every* query is an Elsevier-side refusal, not a query error: it has cleared by itself before, so retry later |
274
302
 
275
303
  View provider status:
276
304
 
@@ -278,6 +306,38 @@ View provider status:
278
306
  scholar providers
279
307
  ```
280
308
 
309
+ ### Network: proxy and User-Agent
310
+
311
+ To reach subscriber-only content from off campus (Scopus's COMPLETE
312
+ view, a publisher that admits the institution's IP range), route
313
+ scholar's requests through a proxy on that network, for example an SSH
314
+ SOCKS tunnel:
315
+
316
+ ```bash
317
+ ssh -D 127.0.0.1:1081 -N you@login.your-university.example &
318
+ scholar --proxy socks5h://127.0.0.1:1081 search "..."
319
+ export SCHOLAR_PROXY=socks5h://127.0.0.1:1081 # or for every command
320
+ scholar providers check --show-ip # route, exit IP, health
321
+ ```
322
+
323
+ `--proxy URL` (a global option, before the command) wins over
324
+ `SCHOLAR_PROXY`; with neither, the standard `HTTP(S)_PROXY`/`ALL_PROXY`
325
+ variables apply as usual. Use `socks5h://` rather than `socks5://` so
326
+ host names are resolved by the proxy, on the far side of the tunnel.
327
+ SOCKS needs the PySocks package (`pipx inject scholarcli PySocks`);
328
+ scholar refuses a SOCKS proxy without it rather than failing every
329
+ request. `scholar providers check` always prints the route; `--show-ip`
330
+ also asks api.ipify.org for the exit address (a third-party request, so
331
+ only on demand).
332
+
333
+ Requests identify themselves as
334
+ `scholar/<version> (+https://github.com/dbosk/scholar)`. Your
335
+ `SCHOLAR_EMAIL` is not in it, so publishers never see the address; only
336
+ the services that ask for one (OpenAlex, Crossref, Unpaywall) get it,
337
+ through their own parameters. Set `SCHOLAR_USER_AGENT` to replace it. Scholar does not imitate a browser: a Cloudflare challenge
338
+ or a login page from a publisher means the PDF is not available to
339
+ scripts, and scholar stops there.
340
+
281
341
  ## Usage Examples
282
342
 
283
343
  ### Basic Search
@@ -120,7 +120,8 @@ Search results are cached to avoid redundant API calls:
120
120
 
121
121
  ```bash
122
122
  scholar cache info # Show cache statistics
123
- scholar cache clear # Delete cached results
123
+ scholar cache clear # Delete cached results (keeps paid LLM output;
124
+ # add --include-llm to delete that too)
124
125
  scholar cache path # Print cache directory
125
126
  ```
126
127
 
@@ -187,6 +188,30 @@ scholar search "machine learning privacy"
187
188
  scholar rq "How do LLMs support novice programming?" --count 20
188
189
  ```
189
190
 
191
+ ### Tutorials
192
+
193
+ Scholar ships interactive tutorials (built on
194
+ [pytorial](https://github.com/dbosk/pytorial)): each step opens a shell,
195
+ you type the command the step asks for, and the transcript is checked
196
+ before the lesson moves on.
197
+
198
+ ```bash
199
+ scholar tutorial list # the lessons and your progress
200
+ scholar tutorial run first-search # start or resume a lesson
201
+ scholar tutorial run --restart first-search
202
+ scholar tutorial review first-search # read back what you typed
203
+ ```
204
+
205
+ The lessons, in order: `first-search` (search, providers, output formats,
206
+ saving a session), `reviewing-in-the-tui` (recording decisions),
207
+ `enrich-verify-export` (abstracts and open access, retractions, CSV and
208
+ LaTeX exports), `llm-synthesis` (setting up the `llm` command, LLM
209
+ classification, a literature overview) and `scholar-with-an-agent` (what
210
+ an agent running the `backing-claims` skill does with Scholar, and how to
211
+ audit it). Every lesson runs in its own workspace with its own session
212
+ store, so practice sessions never appear in your real `scholar sessions
213
+ list`.
214
+
190
215
  ## Installation
191
216
 
192
217
  If you don't use `pipx`, you can install with `pip`:
@@ -230,7 +255,7 @@ Some providers require API keys set as environment variables:
230
255
  | SSRN, bioRxiv, medRxiv, ChemRxiv, Research Square, Preprints.org, IACR | `SCHOLAR_EMAIL` | No | Any email (they search via OpenAlex) |
231
256
  | Web of Science | `WOS_EXPANDED_API_KEY` or `WOS_STARTER_API_KEY` | Yes | [developer.clarivate.com](https://developer.clarivate.com) |
232
257
  | IEEE Xplore | `IEEE_API_KEY` | Yes | [developer.ieee.org](https://developer.ieee.org) |
233
- | Scopus | `SCOPUS_API_KEY` | Yes | [dev.elsevier.com](https://dev.elsevier.com) |
258
+ | Scopus | `SCOPUS_API_KEY` | Yes | [dev.elsevier.com](https://dev.elsevier.com). Searching works from any network, but abstracts, keywords and full author lists come only with Scopus's COMPLETE view, which is bound to the institution's IP range (or `SCOPUS_INST_TOKEN`). scholar asks for it and falls back by itself (`scholar providers check` tells which view you get); `SCOPUS_VIEW=standard` or `complete` overrides. A 400 "Error translating query" on *every* query is an Elsevier-side refusal, not a query error: it has cleared by itself before, so retry later |
234
259
 
235
260
  View provider status:
236
261
 
@@ -238,6 +263,38 @@ View provider status:
238
263
  scholar providers
239
264
  ```
240
265
 
266
+ ### Network: proxy and User-Agent
267
+
268
+ To reach subscriber-only content from off campus (Scopus's COMPLETE
269
+ view, a publisher that admits the institution's IP range), route
270
+ scholar's requests through a proxy on that network, for example an SSH
271
+ SOCKS tunnel:
272
+
273
+ ```bash
274
+ ssh -D 127.0.0.1:1081 -N you@login.your-university.example &
275
+ scholar --proxy socks5h://127.0.0.1:1081 search "..."
276
+ export SCHOLAR_PROXY=socks5h://127.0.0.1:1081 # or for every command
277
+ scholar providers check --show-ip # route, exit IP, health
278
+ ```
279
+
280
+ `--proxy URL` (a global option, before the command) wins over
281
+ `SCHOLAR_PROXY`; with neither, the standard `HTTP(S)_PROXY`/`ALL_PROXY`
282
+ variables apply as usual. Use `socks5h://` rather than `socks5://` so
283
+ host names are resolved by the proxy, on the far side of the tunnel.
284
+ SOCKS needs the PySocks package (`pipx inject scholarcli PySocks`);
285
+ scholar refuses a SOCKS proxy without it rather than failing every
286
+ request. `scholar providers check` always prints the route; `--show-ip`
287
+ also asks api.ipify.org for the exit address (a third-party request, so
288
+ only on demand).
289
+
290
+ Requests identify themselves as
291
+ `scholar/<version> (+https://github.com/dbosk/scholar)`. Your
292
+ `SCHOLAR_EMAIL` is not in it, so publishers never see the address; only
293
+ the services that ask for one (OpenAlex, Crossref, Unpaywall) get it,
294
+ through their own parameters. Set `SCHOLAR_USER_AGENT` to replace it. Scholar does not imitate a browser: a Cloudflare challenge
295
+ or a login page from a publisher means the PDF is not available to
296
+ scripts, and scholar stops there.
297
+
241
298
  ## Usage Examples
242
299
 
243
300
  ### Basic Search
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scholarcli"
3
- version = "1.26"
3
+ version = "1.28"
4
4
  description = "A tool for structured literature searches across bibliographic databases"
5
5
  authors = [{ name = "Daniel Bosk", email = "dbosk@kth.se" },
6
6
  { name = "Ric Glassey", email = "glassey@kth.se" }]
@@ -15,6 +15,7 @@ dependencies = [
15
15
  "arxiv>=2.1.0",
16
16
  "cachetools>=6.2.4",
17
17
  "platformdirs>=4.5.1",
18
+ "filelock>=3.12",
18
19
  "textual>=6.11.0",
19
20
  "pypandoc>=1.14",
20
21
  "click>=8.0.0",
@@ -39,6 +40,8 @@ dependencies = [
39
40
  "questionary>=2.0.0",
40
41
  "llm-github-copilot>=0.3.1",
41
42
  "llm-mistral (>=0.15,<0.16)",
43
+ "llm-openai-via-codex>=0.1a0",
44
+ "pytorial>=0.8.1",
42
45
  ]
43
46
 
44
47
  [project.scripts]
@@ -51,6 +54,9 @@ build-backend = "setuptools.build_meta"
51
54
  [tool.setuptools.packages.find]
52
55
  where = ["src"]
53
56
 
57
+ [tool.setuptools.package-data]
58
+ scholar = ["tutorials/*.md"]
59
+
54
60
  [tool.pytest.ini_options]
55
61
  testpaths = ["tests"]
56
62
  python_files = ["test_*.py"]
@@ -6,9 +6,15 @@ from .scholar import Search, SearchResult, Paper, SearchFilters
6
6
  from .scholar import search, filter_papers, deduplicate_papers
7
7
  from .scholar import deduplicate_versions, merge_version_of
8
8
  from .scholar import get_registry, isolated_registry
9
+ from .scholar import hits_label, provider_outcome, result_outcome
10
+ from .scholar import normalize_venue
9
11
  from .utils import safe_get_nested, ensure_list
10
12
 
11
13
  __all__ = [
14
+ "hits_label",
15
+ "provider_outcome",
16
+ "result_outcome",
17
+ "normalize_venue",
12
18
  "Search",
13
19
  "SearchResult",
14
20
  "Paper",
@@ -15,6 +15,7 @@ import pickle
15
15
  from typing import Any
16
16
  import atexit
17
17
 
18
+ LLM_CACHE_PREFIX = "llm_"
18
19
  # Registry of caches to save on exit: provider_name -> cache dict
19
20
  CACHE_REGISTRY: dict[str, dict[Any, Any]] = {}
20
21
 
@@ -70,22 +71,49 @@ def save_cache(provider_name: str, cache: dict[Any, Any]) -> None:
70
71
  logger.warning(f"Failed to save cache for {provider_name}: {e}")
71
72
 
72
73
 
73
- def clear_cache() -> int:
74
+ def is_llm_cache(name: str) -> bool:
75
+ """Return whether the cache `name` holds language-model output."""
76
+ return name.startswith(LLM_CACHE_PREFIX)
77
+
78
+
79
+ def llm_cache_files() -> list[Path]:
80
+ """Return the LLM cache files in the cache directory."""
81
+ return sorted(
82
+ cache_file
83
+ for cache_file in get_cache_dir().glob("*.pkl")
84
+ if is_llm_cache(cache_file.stem)
85
+ )
86
+
87
+
88
+ def clear_cache(include_llm: bool = False) -> int:
74
89
  """
75
90
  Clear all cached search results.
76
91
 
92
+ The LLM caches (see `is_llm_cache`) hold paid model output and are
93
+ kept unless `include_llm` is true.
94
+
95
+ Also empties the in-memory copy of every deleted cache that is
96
+ registered for saving at exit, so the exit hook cannot write the
97
+ old contents back.
98
+
77
99
  Returns the number of cache files deleted.
78
100
  """
79
101
  cache_dir = get_cache_dir()
80
102
  count = 0
81
103
  logger.info("Clearing all cache files")
82
104
  for cache_file in cache_dir.glob("*.pkl"):
105
+ if is_llm_cache(cache_file.stem) and not include_llm:
106
+ continue
83
107
  try:
84
108
  cache_file.unlink()
85
109
  count += 1
86
110
  logger.debug(f"Deleted cache file: {cache_file.name}")
87
111
  except OSError as e:
88
112
  logger.warning(f"Failed to delete {cache_file.name}: {e}")
113
+ continue
114
+ registered = CACHE_REGISTRY.get(cache_file.stem)
115
+ if registered is not None:
116
+ registered.clear()
89
117
  logger.info(f"Cleared {count} cache file(s)")
90
118
  return count
91
119
 
@@ -97,26 +125,31 @@ def get_cache_stats() -> dict[str, Any]:
97
125
  Returns a dictionary with:
98
126
  - cache_dir: Path to the cache directory
99
127
  - providers: Dict mapping provider names to entry counts
100
- - total_entries: Total number of cached queries
101
- - total_size_bytes: Total size of cache files
128
+ (search and lookup caches, which `clear_cache` removes)
129
+ - llm_caches: Dict mapping LLM cache names to entry counts
130
+ (kept by `clear_cache` unless `include_llm` is true)
131
+ - total_entries: Total number of cached entries in all files
132
+ - total_size_bytes: Total size of all cache files
102
133
  """
103
134
  cache_dir = get_cache_dir()
104
135
  providers: dict[str, int] = {}
136
+ llm_caches: dict[str, int] = {}
105
137
  total_size = 0
106
138
 
107
139
  for cache_file in cache_dir.glob("*.pkl"):
108
- provider_name = cache_file.stem
140
+ name = cache_file.stem
109
141
  total_size += cache_file.stat().st_size
142
+ counts = llm_caches if is_llm_cache(name) else providers
110
143
  try:
111
- cache = load_cache(provider_name)
112
- providers[provider_name] = len(cache)
144
+ counts[name] = len(load_cache(name))
113
145
  except Exception:
114
- providers[provider_name] = 0
146
+ counts[name] = 0
115
147
 
116
148
  return {
117
149
  "cache_dir": str(cache_dir),
118
150
  "providers": providers,
119
- "total_entries": sum(providers.values()),
151
+ "llm_caches": llm_caches,
152
+ "total_entries": sum(providers.values()) + sum(llm_caches.values()),
120
153
  "total_size_bytes": total_size,
121
154
  }
122
155