agent-discogs 0.1.0__tar.gz → 0.2.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 (27) hide show
  1. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/PKG-INFO +54 -4
  2. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/README.md +52 -2
  3. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/pyproject.toml +3 -2
  4. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/pyproject.toml.orig +3 -2
  5. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/__init__.py +31 -2
  6. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/client.py +9 -1
  7. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/errors.py +27 -1
  8. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/pagination.py +6 -0
  9. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/skills/core/SKILL.md +33 -2
  10. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/skills/core/references/commands.md +7 -0
  11. agent_discogs-0.2.0/src/agent_discogs/trace.py +223 -0
  12. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/LICENSE.txt +0 -0
  13. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/__main__.py +0 -0
  14. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/__init__.py +0 -0
  15. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/cache.py +0 -0
  16. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/get.py +0 -0
  17. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/search.py +0 -0
  18. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/skills.py +0 -0
  19. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/commands/status.py +0 -0
  20. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/formatting.py +0 -0
  21. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/json_output.py +0 -0
  22. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/projections.py +0 -0
  23. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/py.typed +0 -0
  24. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/refs.py +0 -0
  25. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/skills/core/references/discogs-domain.md +0 -0
  26. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/skills/core/references/pressings-guide.md +0 -0
  27. {agent_discogs-0.1.0 → agent_discogs-0.2.0}/src/agent_discogs/skills/core/references/search-patterns.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-discogs
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Token-efficient Discogs CLI for AI agents
5
5
  Keywords: ai-agent,cli,discogs,discogs-api,music-database
6
6
  Author: Jean-Marc Fontaine
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.13
15
15
  Classifier: Programming Language :: Python :: 3.14
16
16
  Classifier: Programming Language :: Python :: 3.15
17
17
  Requires-Dist: click>=8.3.1
18
- Requires-Dist: discogs-sdk>=0.4.0
18
+ Requires-Dist: discogs-sdk>=0.5.0
19
19
  Requires-Dist: pydantic>=2.12.5
20
20
  Requires-Python: >=3.10
21
21
  Project-URL: Homepage, https://github.com/jmfontaine/agent-discogs
@@ -173,7 +173,7 @@ Errors under `--json` are a JSON document on stdout with exit code 1:
173
173
  {"error":{"code":"not_found","message":"Master @m... not found.","hint":"Try: agent-discogs search \"<title>\"","status":404}}
174
174
  ```
175
175
 
176
- Codes: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when provided), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
176
+ Codes: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when provided, and `limit`/`remaining` for the current minute when Discogs reports them), `cache_miss` (only under `--cached`), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
177
177
 
178
178
  ## Ref system
179
179
 
@@ -194,6 +194,55 @@ Smart resolution: `get versions @r352665` auto-resolves the release to its maste
194
194
 
195
195
  HTTP responses are cached automatically with a 1-hour TTL. The cache is stored at `~/.cache/agent-discogs/` (or `$XDG_CACHE_HOME/agent-discogs/`). Clear it with `agent-discogs cache clear`.
196
196
 
197
+ Two global flags control it for one command. They precede the subcommand and are mutually exclusive (exit code 2 otherwise):
198
+
199
+ ```bash
200
+ agent-discogs --cached price @r352665 # serve only from the cache; never touches the API
201
+ agent-discogs --fresh get release @r352665 # bypass the cache; go to the API
202
+ ```
203
+
204
+ `--cached` fails with a `cache_miss` error (exit code 1) on any request the cache cannot serve, before any network I/O. With several refs, hits print normally and each miss reports inline, exactly like other per-ref failures; under `--json` the miss item carries `error.code == "cache_miss"`. The check is exact: it uses the real cache key at the real moment, so a command that issues several requests fails at the first one that is not cached. There is no separate "is it cached?" query because the answer depends on the whole request sequence a command issues.
205
+
206
+ `--fresh` fetches every request from the API and does **not** store the response, so a later run without `--fresh` can still be served the older cached entry until its 1-hour TTL expires.
207
+
208
+ ## Budget
209
+
210
+ Discogs throttles by source IP: 60 requests per minute with a token, 25 without, as a moving 60-second window. After any command that talks to the API, stderr ends with one budget line:
211
+
212
+ ```
213
+ api: 3 requests · 43/60 left this minute
214
+ api: 0 requests · cached
215
+ api: 4 requests · 6/60 left this minute ⚠ pause ~60s before uncached calls
216
+ ```
217
+
218
+ The count is what this command spent; `43/60` is what Discogs reported after the last request. `⚠` appears when 10 or fewer requests remain: pause about a minute, or keep to refs that are already cached (`--cached` guarantees zero spend). Without a token the warning suggests setting `DISCOGS_TOKEN` instead. The line is on stderr and never inside `--json` output; commands that make no request (`status`, `skills`, `cache clear`) print nothing.
219
+
220
+ What commands cost:
221
+
222
+ | Command | Requests |
223
+ |---|---|
224
+ | `get <noun>`, `tracks`, `price` | 1 per ref (`price`: up to 3; `versions @r...`: 2) |
225
+ | `search`, `get releases`, `get versions` (server-side pages) | 1 |
226
+ | Client-side scans: `search --release-type official\|unofficial`, `get releases --role`, `get releases @l... --year` | up to 5 |
227
+ | Anything repeated within 1 hour | 0 (cached) |
228
+
229
+ The CLI reports; it never sleeps or throttles on your behalf. When Discogs answers 429, the error says how much of the minute is left (`Rate limited: 0/60 requests left this minute.`), and under `--json` the envelope carries `retry_after`, `limit`, and `remaining`.
230
+
231
+ ## Debugging
232
+
233
+ `--debug` (or `AGENT_DISCOGS_DEBUG=1`) prints a trace panel to stderr after the budget line: one row per request with status and timing (`cache` for cache hits, `×N attempts` when the SDK retried), scan statistics for client-side filters, cache size, and output size with a rough token estimate. The SDK's own log lines (`[sdk] HTTP request ...`, `[sdk] Retrying ... waiting 30s`) stream to stderr in real time, which is the only way to see a rate-limit retry while it is happening. stdout is byte-identical with and without the flag.
234
+
235
+ ```
236
+ api: 2 requests · 58/60 left this minute
237
+ ── debug ───────────────────────────────────────────────
238
+ total 1310ms
239
+ GET /artists/3857 200 338ms
240
+ GET /artists/3857/releases?page=1&per_page=15 200 947ms
241
+ scan 1 API calls, 15 rows fetched, 5 kept, not capped
242
+ cache ~/.cache/agent-discogs 540.0 KB ttl 1h
243
+ output 494 chars, 9 lines, ≈123 tokens
244
+ ```
245
+
197
246
  ## Error handling
198
247
 
199
248
  The CLI maps API errors to recovery-oriented messages with suggested next steps:
@@ -201,7 +250,8 @@ The CLI maps API errors to recovery-oriented messages with suggested next steps:
201
250
  ```
202
251
  ✗ Release 999999999 not found. Try: agent-discogs search "<title>"
203
252
  ✗ Authentication failed. Check your DISCOGS_TOKEN.
204
- ✗ Rate limit exceeded. Wait a moment and retry.
253
+ ✗ Rate limited: 0/60 requests left this minute. Retry in 30s.
254
+ ✗ Not cached: GET /marketplace/price_suggestions/352665 (only under --cached)
205
255
  ✗ Connection error. Check your network and retry.
206
256
  ```
207
257
 
@@ -148,7 +148,7 @@ Errors under `--json` are a JSON document on stdout with exit code 1:
148
148
  {"error":{"code":"not_found","message":"Master @m... not found.","hint":"Try: agent-discogs search \"<title>\"","status":404}}
149
149
  ```
150
150
 
151
- Codes: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when provided), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
151
+ Codes: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when provided, and `limit`/`remaining` for the current minute when Discogs reports them), `cache_miss` (only under `--cached`), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
152
152
 
153
153
  ## Ref system
154
154
 
@@ -169,6 +169,55 @@ Smart resolution: `get versions @r352665` auto-resolves the release to its maste
169
169
 
170
170
  HTTP responses are cached automatically with a 1-hour TTL. The cache is stored at `~/.cache/agent-discogs/` (or `$XDG_CACHE_HOME/agent-discogs/`). Clear it with `agent-discogs cache clear`.
171
171
 
172
+ Two global flags control it for one command. They precede the subcommand and are mutually exclusive (exit code 2 otherwise):
173
+
174
+ ```bash
175
+ agent-discogs --cached price @r352665 # serve only from the cache; never touches the API
176
+ agent-discogs --fresh get release @r352665 # bypass the cache; go to the API
177
+ ```
178
+
179
+ `--cached` fails with a `cache_miss` error (exit code 1) on any request the cache cannot serve, before any network I/O. With several refs, hits print normally and each miss reports inline, exactly like other per-ref failures; under `--json` the miss item carries `error.code == "cache_miss"`. The check is exact: it uses the real cache key at the real moment, so a command that issues several requests fails at the first one that is not cached. There is no separate "is it cached?" query because the answer depends on the whole request sequence a command issues.
180
+
181
+ `--fresh` fetches every request from the API and does **not** store the response, so a later run without `--fresh` can still be served the older cached entry until its 1-hour TTL expires.
182
+
183
+ ## Budget
184
+
185
+ Discogs throttles by source IP: 60 requests per minute with a token, 25 without, as a moving 60-second window. After any command that talks to the API, stderr ends with one budget line:
186
+
187
+ ```
188
+ api: 3 requests · 43/60 left this minute
189
+ api: 0 requests · cached
190
+ api: 4 requests · 6/60 left this minute ⚠ pause ~60s before uncached calls
191
+ ```
192
+
193
+ The count is what this command spent; `43/60` is what Discogs reported after the last request. `⚠` appears when 10 or fewer requests remain: pause about a minute, or keep to refs that are already cached (`--cached` guarantees zero spend). Without a token the warning suggests setting `DISCOGS_TOKEN` instead. The line is on stderr and never inside `--json` output; commands that make no request (`status`, `skills`, `cache clear`) print nothing.
194
+
195
+ What commands cost:
196
+
197
+ | Command | Requests |
198
+ |---|---|
199
+ | `get <noun>`, `tracks`, `price` | 1 per ref (`price`: up to 3; `versions @r...`: 2) |
200
+ | `search`, `get releases`, `get versions` (server-side pages) | 1 |
201
+ | Client-side scans: `search --release-type official\|unofficial`, `get releases --role`, `get releases @l... --year` | up to 5 |
202
+ | Anything repeated within 1 hour | 0 (cached) |
203
+
204
+ The CLI reports; it never sleeps or throttles on your behalf. When Discogs answers 429, the error says how much of the minute is left (`Rate limited: 0/60 requests left this minute.`), and under `--json` the envelope carries `retry_after`, `limit`, and `remaining`.
205
+
206
+ ## Debugging
207
+
208
+ `--debug` (or `AGENT_DISCOGS_DEBUG=1`) prints a trace panel to stderr after the budget line: one row per request with status and timing (`cache` for cache hits, `×N attempts` when the SDK retried), scan statistics for client-side filters, cache size, and output size with a rough token estimate. The SDK's own log lines (`[sdk] HTTP request ...`, `[sdk] Retrying ... waiting 30s`) stream to stderr in real time, which is the only way to see a rate-limit retry while it is happening. stdout is byte-identical with and without the flag.
209
+
210
+ ```
211
+ api: 2 requests · 58/60 left this minute
212
+ ── debug ───────────────────────────────────────────────
213
+ total 1310ms
214
+ GET /artists/3857 200 338ms
215
+ GET /artists/3857/releases?page=1&per_page=15 200 947ms
216
+ scan 1 API calls, 15 rows fetched, 5 kept, not capped
217
+ cache ~/.cache/agent-discogs 540.0 KB ttl 1h
218
+ output 494 chars, 9 lines, ≈123 tokens
219
+ ```
220
+
172
221
  ## Error handling
173
222
 
174
223
  The CLI maps API errors to recovery-oriented messages with suggested next steps:
@@ -176,7 +225,8 @@ The CLI maps API errors to recovery-oriented messages with suggested next steps:
176
225
  ```
177
226
  ✗ Release 999999999 not found. Try: agent-discogs search "<title>"
178
227
  ✗ Authentication failed. Check your DISCOGS_TOKEN.
179
- ✗ Rate limit exceeded. Wait a moment and retry.
228
+ ✗ Rate limited: 0/60 requests left this minute. Retry in 30s.
229
+ ✗ Not cached: GET /marketplace/price_suggestions/352665 (only under --cached)
180
230
  ✗ Connection error. Check your network and retry.
181
231
  ```
182
232
 
@@ -4,7 +4,7 @@ requires = ["uv-build>=0.12.13,<0.13"]
4
4
 
5
5
  [project]
6
6
  name = "agent-discogs"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Token-efficient Discogs CLI for AI agents"
9
9
  readme = "README.md"
10
10
  keywords = [
@@ -28,7 +28,7 @@ classifiers = [
28
28
  ]
29
29
  dependencies = [
30
30
  "click>=8.3.1",
31
- "discogs-sdk>=0.4.0",
31
+ "discogs-sdk>=0.5.0",
32
32
  "pydantic>=2.12.5",
33
33
  ]
34
34
 
@@ -142,6 +142,7 @@ ignore-names = [
142
142
  "_patch_get",
143
143
  "_patch_search",
144
144
  "_reset_singleton",
145
+ "_scoped_client",
145
146
  "format_help",
146
147
  "get_skill",
147
148
  "skill_path",
@@ -4,7 +4,7 @@ requires = [ "uv-build>=0.12.13,<0.13" ]
4
4
 
5
5
  [project]
6
6
  name = "agent-discogs"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Token-efficient Discogs CLI for AI agents"
9
9
  readme = "README.md"
10
10
  keywords = [ "ai-agent", "cli", "discogs", "discogs-api", "music-database" ]
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.14",
22
22
  "Programming Language :: Python :: 3.15",
23
23
  ]
24
- dependencies = [ "click>=8.3.1", "discogs-sdk>=0.4.0", "pydantic>=2.12.5" ]
24
+ dependencies = [ "click>=8.3.1", "discogs-sdk>=0.5.0", "pydantic>=2.12.5" ]
25
25
  urls.Homepage = "https://github.com/jmfontaine/agent-discogs"
26
26
  urls.Issues = "https://github.com/jmfontaine/agent-discogs/issues"
27
27
  urls.Repository = "https://github.com/jmfontaine/agent-discogs"
@@ -125,6 +125,7 @@ ignore-names = [
125
125
  "_patch_get",
126
126
  "_patch_search",
127
127
  "_reset_singleton",
128
+ "_scoped_client",
128
129
  "format_help",
129
130
  "get_skill", # click subcommands are registered by decorator, invisible to deadcode
130
131
  "skill_path",
@@ -7,6 +7,7 @@ from typing import Any
7
7
 
8
8
  import click
9
9
 
10
+ from agent_discogs import client, trace
10
11
  from agent_discogs.commands.cache import cache
11
12
  from agent_discogs.commands.get import get, price, tracks
12
13
  from agent_discogs.commands.search import search
@@ -44,6 +45,11 @@ Refs:
44
45
  @m3719 (master), @l647 (label). Use refs with get commands.
45
46
 
46
47
  Options:
48
+ --cached Serve only from the cache; a miss fails (cache_miss), spends
49
+ nothing. Precedes the command: agent-discogs --cached price @r...
50
+ --fresh Bypass the cache for this command (response is not stored)
51
+ --debug Request rows, timings, retries, scan stats, cache and output
52
+ size on stderr, plus the SDK's own log lines
47
53
  --json JSON output: a compact projection of the text view (search, get)
48
54
  --full With --json: the raw SDK record instead of the projection
49
55
  --limit N Results per page (search, get)
@@ -63,7 +69,14 @@ Options:
63
69
  --help Show this message and exit
64
70
 
65
71
  Environment:
66
- DISCOGS_TOKEN Personal access token (higher rate limit, required for price data)
72
+ DISCOGS_TOKEN Personal access token (60 req/min, required for price data)
73
+ AGENT_DISCOGS_DEBUG Same as --debug
74
+
75
+ Budget:
76
+ After any command that talks to the API, stderr ends with a budget line, e.g.
77
+ 'api: 3 requests · 43/60 left this minute'. ⚠ means 10 or fewer requests
78
+ remain in the moving 60s window: pause ~60s or stick to cached refs. Cache
79
+ hits cost nothing ('api: 0 requests · cached'). Never inside --json output.
67
80
 
68
81
  Price data also needs seller settings filled out on the Discogs account the
69
82
  token belongs to (discogs.com/settings/seller). Without them the API answers
@@ -130,9 +143,25 @@ class AliasGroup(click.Group):
130
143
 
131
144
  @click.group(cls=AliasGroup, invoke_without_command=True)
132
145
  @click.version_option(package_name="agent-discogs", prog_name="agent-discogs")
146
+ @click.option(
147
+ "--debug",
148
+ is_flag=True,
149
+ envvar="AGENT_DISCOGS_DEBUG",
150
+ help="Print request trace and timings to stderr.",
151
+ )
152
+ @click.option("--cached", is_flag=True, help="Serve only from cache; fail on a miss.")
153
+ @click.option("--fresh", is_flag=True, help="Bypass the cache for this command.")
133
154
  @click.pass_context
134
- def cli(ctx: click.Context) -> None:
155
+ def cli(ctx: click.Context, debug: bool, cached: bool, fresh: bool) -> None:
135
156
  """Token-efficient Discogs CLI for AI agents."""
157
+ if cached and fresh:
158
+ msg = "--cached and --fresh are mutually exclusive."
159
+ raise click.UsageError(msg)
160
+ trace.begin(ctx, debug=debug)
161
+ if cached:
162
+ ctx.with_resource(client.get_client().cache_only())
163
+ elif fresh:
164
+ ctx.with_resource(client.get_client().no_cache())
136
165
  if ctx.invoked_subcommand is None:
137
166
  click.echo(ctx.get_help())
138
167
 
@@ -7,6 +7,8 @@ from pathlib import Path
7
7
 
8
8
  from discogs_sdk import Discogs
9
9
 
10
+ from agent_discogs import trace
11
+
10
12
  _client: Discogs | None = None
11
13
 
12
14
  CACHE_DIR = (
@@ -21,7 +23,13 @@ def get_client() -> Discogs:
21
23
  return _client
22
24
 
23
25
  token = os.environ.get("DISCOGS_TOKEN")
24
- _client = Discogs(token=token, cache=True, cache_dir=CACHE_DIR)
26
+ _client = Discogs(
27
+ token=token,
28
+ cache=True,
29
+ cache_dir=CACHE_DIR,
30
+ cache_ttl=trace.CACHE_TTL,
31
+ on_request=trace.record,
32
+ )
25
33
  return _client
26
34
 
27
35
 
@@ -6,6 +6,9 @@ import json
6
6
  import sys
7
7
  from dataclasses import asdict, dataclass
8
8
  from typing import Any, NoReturn
9
+ from urllib.parse import urlsplit
10
+
11
+ from agent_discogs import trace
9
12
 
10
13
 
11
14
  @dataclass(frozen=True)
@@ -18,6 +21,8 @@ class ErrorInfo:
18
21
  hint: str | None = None
19
22
  retry_after: int | None = None
20
23
  status: int | None = None
24
+ limit: int | None = None
25
+ remaining: int | None = None
21
26
 
22
27
 
23
28
  def _api_message(exc: Exception) -> str:
@@ -40,6 +45,7 @@ def classify(exc: Exception, context: str | None = None) -> ErrorInfo:
40
45
  failed, e.g. "Release @r847868", and is used in not-found messages."""
41
46
  from discogs_sdk import (
42
47
  AuthenticationError,
48
+ CacheMissError,
43
49
  DiscogsAPIError,
44
50
  DiscogsConnectionError,
45
51
  ForbiddenError,
@@ -91,12 +97,20 @@ def classify(exc: Exception, context: str | None = None) -> ErrorInfo:
91
97
  wait = (
92
98
  f"Retry in {retry_after}s." if retry_after else "Wait a moment and retry."
93
99
  )
100
+ rl = exc.ratelimit
101
+ message = (
102
+ "Rate limit exceeded."
103
+ if rl is None
104
+ else f"Rate limited: {rl.remaining}/{rl.limit} requests left this minute."
105
+ )
94
106
  return ErrorInfo(
95
107
  "rate_limited",
96
- "Rate limit exceeded.",
108
+ message,
97
109
  f"{wait} 60 req/min with DISCOGS_TOKEN, 25 without.",
98
110
  retry_after=retry_after,
99
111
  status=429,
112
+ limit=None if rl is None else rl.limit,
113
+ remaining=None if rl is None else rl.remaining,
100
114
  )
101
115
 
102
116
  if isinstance(exc, DiscogsAPIError):
@@ -109,6 +123,18 @@ def classify(exc: Exception, context: str | None = None) -> ErrorInfo:
109
123
  "connection_error", "Connection error.", "Check your network and retry."
110
124
  )
111
125
 
126
+ if isinstance(exc, CacheMissError):
127
+ # The SDK emits no request event for a miss, so the footer learns
128
+ # about it here: every miss an agent sees passes through classify().
129
+ trace.note_cache_miss()
130
+ split = urlsplit(exc.url)
131
+ where = split.path + (f"?{split.query}" if split.query else "")
132
+ return ErrorInfo(
133
+ "cache_miss",
134
+ f"Not cached: {exc.method} {where}",
135
+ "Rerun without --cached to fetch it from the API.",
136
+ )
137
+
112
138
  if isinstance(exc, ValueError):
113
139
  return ErrorInfo("invalid_argument", str(exc))
114
140
 
@@ -10,6 +10,8 @@ from typing import Any
10
10
  from discogs_sdk import Discogs
11
11
  from pydantic import BaseModel
12
12
 
13
+ from agent_discogs import trace
14
+
13
15
  MAX_API_CALLS = 5
14
16
  DEFAULT_LIMIT = 5
15
17
 
@@ -152,11 +154,14 @@ def fetch_filtered_page(
152
154
  collected: list[Any] = []
153
155
  next_cursor: str | None = None
154
156
  capped = False
157
+ api_calls = rows = 0
155
158
  result = PageResult(items=[], page=page, total_items=0, total_pages=1)
156
159
 
157
160
  for _ in range(MAX_API_CALLS):
158
161
  fetch_params = {**params, "page": api_page, "per_page": api_per_page}
159
162
  result = fetch_page(client, path, fetch_params, model_cls, items_key)
163
+ api_calls += 1
164
+ rows += len(result.items)
160
165
 
161
166
  for idx in range(offset, len(result.items)):
162
167
  item = result.items[idx]
@@ -179,6 +184,7 @@ def fetch_filtered_page(
179
184
  capped = True
180
185
  next_cursor = f"{page + 1}:{api_page}.0"
181
186
 
187
+ trace.note_scan(trace.ScanNote(api_calls, rows, len(collected), capped))
182
188
  return PageResult(
183
189
  items=collected,
184
190
  page=page,
@@ -94,7 +94,7 @@ agent-discogs get release @r847868 --json -v # adds notes, credits, and
94
94
  agent-discogs get release @r847868 --json --full # raw Discogs record (images, URLs, ~20x larger); only when the projection lacks a field you need
95
95
  ```
96
96
 
97
- With `--json`, errors are a JSON document on stdout and exit code 1: `{"error":{"code":"not_found","message":"Master @m... not found.","hint":"Try: agent-discogs search \"<title>\"","status":404}}`. Branch on `error.code`: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when Discogs sends it), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
97
+ With `--json`, errors are a JSON document on stdout and exit code 1: `{"error":{"code":"not_found","message":"Master @m... not found.","hint":"Try: agent-discogs search \"<title>\"","status":404}}`. Branch on `error.code`: `not_found`, `seller_settings_required`, `auth_required`, `forbidden`, `rate_limited` (with `retry_after` seconds when Discogs sends it, plus `limit`/`remaining` for the current minute), `cache_miss` (only under `--cached`), `api_error`, `connection_error`, `invalid_argument`, `unexpected`.
98
98
 
99
99
  ## Anti-Patterns
100
100
 
@@ -104,6 +104,7 @@ With `--json`, errors are a JSON document on stdout and exit code 1: `{"error":{
104
104
  - **Don't compute page numbers.** Paste the `Next page:` / `Continue scan:` command printed under a list. Filtered lists (the default search, `releases --role`, `releases @l... --year`) continue with an `--after` cursor; `--page` is rejected there and the error says what to use.
105
105
  - **Don't guess IDs.** Always search first to find the right entity.
106
106
  - **Don't use `get versions` on a release ID.** Release rows already show `→ @m...`; use that master ref (smart resolution costs an extra API call).
107
+ - **Don't use `--fresh` by default.** Catalogue data an hour old is fine; reserve `--fresh` for `price` when you need current market numbers.
107
108
 
108
109
  ## Error Recovery
109
110
 
@@ -111,7 +112,8 @@ With `--json`, errors are a JSON document on stdout and exit code 1: `{"error":{
111
112
  - **Auth required** — price suggestions and search require `DISCOGS_TOKEN`. Run `agent-discogs status` to check.
112
113
  - **"Price data requires seller settings"** — not a bad ref. The release exists, but Discogs only serves price suggestions to accounts with seller settings filled out. Nothing to retry: use `get release @r...` for the `num_for_sale`/`lowest_price` summary instead.
113
114
  - **Invalid ref** — refs are Discogs IDs and never expire; there is no session. Check the prefix matches the noun (`@r` release, `@m` master, `@a` artist, `@l` label). A bare `@123` is invalid: either add the type letter or pass the raw number `123`.
114
- - **Rate limited** — wait briefly and retry. Authenticated requests get 60/min; unauthenticated get 25/min.
115
+ - **Rate limited** — the error says what is left (`Rate limited: 0/60 requests left this minute.`, `remaining`/`limit` in JSON). Pause ~60s, or continue with `--cached` on refs you already fetched. Authenticated requests get 60/min; unauthenticated get 25/min.
116
+ - **Unexpected cost or a stall?** Re-run once with `--debug` to see each request, retries and cache hits on stderr. Do not leave it on.
115
117
  - **"Continue scan:" instead of "Next page:"** — the filter was sparse and the scan stopped at 5 API calls before filling the page. The next window may also be short or empty; keep pasting the printed command until it disappears (no footer = nothing left to scan). Counts shown as `≤N` are the unfiltered upper bound.
116
118
 
117
119
  ## Refs
@@ -130,6 +132,35 @@ Refs encode entity type and Discogs ID: `@a3857` (artist), `@r847868` (release),
130
132
 
131
133
  Prefer the most specific command: `tracks @r...` over `get release @r...` when you only need the tracklist, and `price @r...` over `get release @r...` when you only need pricing. This reduces output tokens and avoids unnecessary data.
132
134
 
135
+ ## Budget
136
+
137
+ Discogs allows 60 requests per minute with a token (25 without), as a moving 60s window shared by every process on this machine. After any command that talks to the API, stderr ends with one line:
138
+
139
+ ```
140
+ api: 3 requests · 43/60 left this minute
141
+ api: 0 requests · cached
142
+ api: 4 requests · 6/60 left this minute ⚠ pause ~60s before uncached calls
143
+ ```
144
+
145
+ The count is what this command spent; `43/60` is what Discogs reported after its last request. It is never part of `--json` output. Commands that make no request print nothing.
146
+
147
+ | Command | Requests |
148
+ |---|---|
149
+ | `get <noun>`, `tracks` | 1 per ref |
150
+ | `price` | 3 per ref; `get versions @r...` 2 (resolve, then list) |
151
+ | `search`, `get releases`, `get versions` (server-side pages) | 1 |
152
+ | Client-side scans: `search` with `--release-type official\|unofficial` (the default), `get releases --role`, `get releases @l... --year` | up to 5 |
153
+ | Anything repeated within 1 hour | 0 (cached) |
154
+
155
+ At `⚠` (10 or fewer left): pause ~60s before uncached calls, or spend nothing with `--cached`. Unauthenticated runs get 25/min; setting `DISCOGS_TOKEN` is the fix there.
156
+
157
+ **Try everything with `--cached` first, then spend only on the misses.** `agent-discogs --cached get release @r847868 @r...` serves what the cache has and reports each miss inline as `✗ Not cached: GET ...` (`error.code == "cache_miss"` under `--json`) without touching the API. It matters when several agents share one machine (one cache, one IP budget), and after a `⚠`. The flags precede the command:
158
+
159
+ ```bash
160
+ agent-discogs --cached price @r847868 # zero spend; misses fail with cache_miss
161
+ agent-discogs --fresh price @r847868 # bypass the cache; the response is not stored
162
+ ```
163
+
133
164
  ## Reference Docs
134
165
 
135
166
  | Document | Content |
@@ -2,11 +2,18 @@
2
2
 
3
3
  ## Global Flags
4
4
 
5
+ Global flags precede the command (`agent-discogs --cached price @r847868`).
6
+
5
7
  | Flag | Description |
6
8
  |------|-------------|
9
+ | `--cached` | Serve only from the response cache. A request the cache cannot serve fails with error code `cache_miss` (exit 1, per ref when several are given) before any network I/O. Never spends a request. |
10
+ | `--fresh` | Bypass the cache for this command. Every request goes to the API; the response is not stored, so a later run without `--fresh` may still see the older cached entry until its 1h TTL expires. Mutually exclusive with `--cached` (exit 2). |
11
+ | `--debug` | Trace panel on stderr after the budget line: one row per request (path, status, timing or `cache`, `×N attempts` on retry), scan statistics, cache size, output size with a token estimate, plus the SDK's own log lines in real time. `AGENT_DISCOGS_DEBUG=1` does the same. stdout is unchanged. |
7
12
  | `--help` | Show help |
8
13
  | `--version` | Show version |
9
14
 
15
+ After any command that talks to the API, stderr ends with a budget line such as `api: 3 requests · 43/60 left this minute` (`⚠` when 10 or fewer remain). See the Budget section of the core skill.
16
+
10
17
 
11
18
  ## search
12
19
 
@@ -0,0 +1,223 @@
1
+ """Per-invocation request trace: the budget footer and the --debug panel.
2
+
3
+ One process-wide collector, reset by `begin()` at the top of every invocation
4
+ and drained by `finish()` when the click context closes. `record()` is the
5
+ SDK's `on_request` hook; `note_scan()` is called by client-side scans.
6
+ Everything printed here goes to stderr so stdout stays a pure data channel.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import sys
13
+ import time
14
+ from collections.abc import Sequence
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+ from urllib.parse import urlsplit
19
+
20
+ if TYPE_CHECKING:
21
+ import click
22
+ from discogs_sdk import RequestEvent
23
+
24
+ # Remaining requests at which the footer warns. One client-side scan costs up
25
+ # to MAX_API_CALLS (5) requests; two back-to-back scans can spend 10.
26
+ LOW_BUDGET = 10
27
+ # Passed to Discogs(cache_ttl=...) and shown by --debug.
28
+ CACHE_TTL = 3600.0
29
+
30
+ _SDK_LOGGER = "discogs_sdk"
31
+
32
+
33
+ @dataclass
34
+ class ScanNote:
35
+ """What one `fetch_filtered_page` call spent and kept."""
36
+
37
+ api_calls: int
38
+ rows: int
39
+ kept: int
40
+ capped: bool
41
+
42
+
43
+ class _CountingWriter:
44
+ """Proxy around a text stream that counts what was written to it."""
45
+
46
+ def __init__(self, target: Any) -> None:
47
+ self.target: Any = target
48
+ self.chars: int = 0
49
+ self.lines: int = 0
50
+
51
+ def write(self, s: str) -> int:
52
+ n = self.target.write(s)
53
+ self.chars += len(s)
54
+ self.lines += s.count("\n")
55
+ return n
56
+
57
+ def flush(self) -> None:
58
+ self.target.flush()
59
+
60
+ def __getattr__(self, name: str) -> Any:
61
+ return getattr(self.target, name)
62
+
63
+
64
+ @dataclass
65
+ class Trace:
66
+ events: list[RequestEvent] = field(default_factory=list)
67
+ scans: list[ScanNote] = field(default_factory=list)
68
+ started: float = 0.0
69
+ debug: bool = False
70
+ cache_misses: int = 0
71
+ stdout: _CountingWriter | None = None
72
+ log_handler: logging.Handler | None = None
73
+ log_level: int = logging.NOTSET
74
+
75
+
76
+ _trace = Trace()
77
+
78
+
79
+ def begin(ctx: click.Context, *, debug: bool) -> None:
80
+ """Reset the trace for this invocation and arrange for `finish()` to run
81
+ when `ctx` closes, i.e. after the subcommand, success or failure."""
82
+ global _trace # noqa: PLW0603 # one invocation per process; reset for tests
83
+ _trace = Trace(started=time.perf_counter(), debug=debug)
84
+ if debug:
85
+ _trace.stdout = _CountingWriter(sys.stdout)
86
+ sys.stdout = _trace.stdout # type: ignore[assignment]
87
+ handler = logging.StreamHandler(sys.stderr)
88
+ handler.setLevel(logging.DEBUG)
89
+ handler.setFormatter(logging.Formatter("[sdk] %(message)s"))
90
+ logger = logging.getLogger(_SDK_LOGGER)
91
+ _trace.log_level = logger.level
92
+ logger.setLevel(logging.DEBUG)
93
+ logger.addHandler(handler)
94
+ _trace.log_handler = handler
95
+ ctx.call_on_close(finish)
96
+
97
+
98
+ def record(event: RequestEvent) -> None:
99
+ """SDK `on_request` hook: one event per request, cache hit or network."""
100
+ _trace.events.append(event)
101
+
102
+
103
+ def note_scan(note: ScanNote) -> None:
104
+ """Record the cost of one client-side filtered scan."""
105
+ _trace.scans.append(note)
106
+
107
+
108
+ def note_cache_miss() -> None:
109
+ """Record a `--cached` lookup the cache could not serve. The SDK emits no
110
+ event for it, so this is the only trace such a request leaves."""
111
+ _trace.cache_misses += 1
112
+
113
+
114
+ def render_footer(
115
+ events: Sequence[RequestEvent], *, authenticated: bool, cache_misses: int = 0
116
+ ) -> str | None:
117
+ """The budget line, or None when nothing was requested.
118
+
119
+ Counts network requests only; the budget comes from the last network
120
+ response that reported rate-limit headers, so cache hits never move it.
121
+ A `--cached` miss counts as a request that cost nothing.
122
+ """
123
+ if not events and not cache_misses:
124
+ return None
125
+ network = [e for e in events if e.source == "network"]
126
+ n = len(network)
127
+ if n == 0:
128
+ return "api: 0 requests · cached"
129
+ line = f"api: {n} request" + ("" if n == 1 else "s")
130
+ rl = next((e.ratelimit for e in reversed(network) if e.ratelimit), None)
131
+ if rl is None:
132
+ return line
133
+ line += f" · {rl.remaining}/{rl.limit} left this minute"
134
+ if rl.remaining <= LOW_BUDGET:
135
+ line += (
136
+ " ⚠ pause ~60s before uncached calls"
137
+ if authenticated
138
+ else " ⚠ set DISCOGS_TOKEN for 60/min, or pause ~60s"
139
+ )
140
+ return line
141
+
142
+
143
+ def _request_path(url: str) -> str:
144
+ split = urlsplit(url)
145
+ return split.path + (f"?{split.query}" if split.query else "")
146
+
147
+
148
+ _KB = 1024
149
+
150
+
151
+ def _cache_size(cache_dir: Path) -> str:
152
+ # SQLite may leave -wal/-shm beside the database file.
153
+ size = sum(p.stat().st_size for p in cache_dir.glob("cache.db*"))
154
+ if size < _KB:
155
+ return f"{size} B"
156
+ if size < _KB * _KB:
157
+ return f"{size / _KB:.1f} KB"
158
+ return f"{size / (_KB * _KB):.1f} MB"
159
+
160
+
161
+ def _tilde(path: Path) -> str:
162
+ home = Path.home()
163
+ try:
164
+ return f"~/{path.relative_to(home)}"
165
+ except ValueError:
166
+ return str(path)
167
+
168
+
169
+ def render_panel(trace: Trace, *, cache_dir: Path, elapsed_ms: float) -> str:
170
+ """The `--debug` panel: one row per request, then scan, cache and output
171
+ statistics. Takes plain values; never touches the client or the network."""
172
+ lines = ["── debug ".ljust(56, "─"), f"total {elapsed_ms:.0f}ms"]
173
+ paths = [_request_path(e.url) for e in trace.events]
174
+ width = max((len(p) for p in paths), default=0)
175
+ for event, path in zip(trace.events, paths, strict=True):
176
+ if event.source == "cache":
177
+ cost = "cache"
178
+ else:
179
+ cost = f"{event.elapsed_ms:.0f}ms"
180
+ if event.attempts > 1:
181
+ cost += f" \u00d7{event.attempts} attempts" # multiplication sign
182
+ lines.append(f"{event.method} {path:<{width}} {event.status_code} {cost}")
183
+ for scan in trace.scans:
184
+ capped = (
185
+ f"capped at {scan.api_calls} API calls" if scan.capped else "not capped"
186
+ )
187
+ lines.append(
188
+ f"scan {scan.api_calls} API calls, {scan.rows} rows fetched, "
189
+ f"{scan.kept} kept, {capped}"
190
+ )
191
+ lines.append(
192
+ f"cache {_tilde(cache_dir)} {_cache_size(cache_dir)} "
193
+ f"ttl {CACHE_TTL / 3600:g}h"
194
+ )
195
+ if trace.stdout is not None:
196
+ out = trace.stdout
197
+ lines.append(
198
+ f"output {out.chars} chars, {out.lines} lines, ≈{out.chars // 4} tokens"
199
+ )
200
+ return "\n".join(lines)
201
+
202
+
203
+ def finish() -> None:
204
+ """Print the footer (and the panel under --debug) to stderr and undo
205
+ whatever `begin()` installed."""
206
+ from agent_discogs.client import CACHE_DIR, has_token
207
+
208
+ if _trace.stdout is not None:
209
+ sys.stdout = _trace.stdout.target
210
+ footer = render_footer(
211
+ _trace.events, authenticated=has_token(), cache_misses=_trace.cache_misses
212
+ )
213
+ if footer:
214
+ print(footer, file=sys.stderr)
215
+ if _trace.debug:
216
+ elapsed_ms = (time.perf_counter() - _trace.started) * 1000
217
+ panel = render_panel(_trace, cache_dir=CACHE_DIR, elapsed_ms=elapsed_ms)
218
+ print(panel, file=sys.stderr)
219
+ if _trace.log_handler is not None:
220
+ logger = logging.getLogger(_SDK_LOGGER)
221
+ logger.removeHandler(_trace.log_handler)
222
+ logger.setLevel(_trace.log_level)
223
+ _trace.log_handler = None
File without changes