cli-consumption 0.3.1__tar.gz → 0.3.2__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 (56) hide show
  1. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/CHANGELOG.md +15 -1
  2. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/PKG-INFO +1 -1
  3. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/pyproject.toml +1 -1
  4. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/opencode.py +253 -84
  5. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/registry.py +8 -5
  6. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/.gitignore +0 -0
  7. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/LICENSE +0 -0
  8. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/NOTICE +0 -0
  9. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/README.md +0 -0
  10. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/__init__.py +0 -0
  11. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/__main__.py +0 -0
  12. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/__init__.py +0 -0
  13. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/_shared.py +0 -0
  14. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/aider.py +0 -0
  15. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/amazon_q.py +0 -0
  16. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/amp.py +0 -0
  17. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/base.py +0 -0
  18. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/claude.py +0 -0
  19. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/cline.py +0 -0
  20. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/codex.py +0 -0
  21. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/continue_cli.py +0 -0
  22. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/copilot.py +0 -0
  23. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/crush.py +0 -0
  24. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/cursor.py +0 -0
  25. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/gemini.py +0 -0
  26. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/goose.py +0 -0
  27. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/grok.py +0 -0
  28. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/kilo.py +0 -0
  29. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/kimi.py +0 -0
  30. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/mistral_vibe.py +0 -0
  31. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/openhands.py +0 -0
  32. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/pi.py +0 -0
  33. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/plandex.py +0 -0
  34. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/adapters/qwen.py +0 -0
  35. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/api.py +0 -0
  36. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/cli.py +0 -0
  37. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/dashboard.py +0 -0
  38. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/dashboard_calculations.js +0 -0
  39. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/exporting.py +0 -0
  40. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/__init__.py +0 -0
  41. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/env.py +0 -0
  42. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/__init__.py +0 -0
  43. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
  44. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
  45. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
  46. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
  47. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
  48. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/models.py +0 -0
  49. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/py.typed +0 -0
  50. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/qualifications.py +0 -0
  51. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/reporting.py +0 -0
  52. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/retention.py +0 -0
  53. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/schema.py +0 -0
  54. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/storage.py +0 -0
  55. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/sync.py +0 -0
  56. {cli_consumption-0.3.1 → cli_consumption-0.3.2}/src/cli_consumption/timestamps.py +0 -0
@@ -6,6 +6,19 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.2] - 2026-08-31
10
+
11
+ ### Changed
12
+
13
+ - Version releases now attach the validated wheel and source distribution to an
14
+ automatically generated GitHub Release after PyPI publication succeeds.
15
+
16
+ ### Fixed
17
+
18
+ - OpenCode 1.18.23 SQLite stores now extract model calls and token usage from
19
+ `message.data` and tool calls from `part.data`; incomplete current schemas are
20
+ reported as incompatible instead of silently producing zero usage.
21
+
9
22
  ## [0.3.1] - 2026-08-31
10
23
 
11
24
  ### Changed
@@ -102,7 +115,8 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
102
115
 
103
116
  - Refreshed the provider guide for the first minor release ([#26]).
104
117
 
105
- [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.1...HEAD
118
+ [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...HEAD
119
+ [0.3.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.1...v0.3.2
106
120
  [0.3.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.0...v0.3.1
107
121
  [0.3.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.2.1...v0.3.0
108
122
  [0.2.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.2.0...v0.2.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -39,6 +39,15 @@ from cli_consumption.adapters.base import UnsupportedProviderFormat
39
39
  from cli_consumption.models import Snapshot, empty_tokens
40
40
 
41
41
 
42
+ @dataclass(slots=True)
43
+ class _Part:
44
+ external_id: str
45
+ kind: str
46
+ created_at: datetime | None
47
+ updated_at: datetime | None
48
+ data: dict[str, Any]
49
+
50
+
42
51
  @dataclass(slots=True)
43
52
  class _Message:
44
53
  external_id: str
@@ -47,6 +56,7 @@ class _Message:
47
56
  created_at: datetime | None
48
57
  updated_at: datetime | None
49
58
  data: dict[str, Any]
59
+ parts: list[_Part]
50
60
 
51
61
 
52
62
  @dataclass(slots=True)
@@ -123,6 +133,8 @@ class OpenCodeAdapter:
123
133
  _mapping(message.data.get("time")).get("completed")
124
134
  )
125
135
  timestamps.extend((timestamp, completed_at))
136
+ for part in message.parts:
137
+ timestamps.extend((part.created_at, part.updated_at))
126
138
 
127
139
  if message.kind == "user":
128
140
  if active is not None:
@@ -142,48 +154,53 @@ class OpenCodeAdapter:
142
154
  **empty_tokens(),
143
155
  }
144
156
  turn_models[active] = set()
145
- continue
146
-
147
- if message.kind == "model-switched":
157
+ elif message.kind == "model-switched":
148
158
  effective_model = _model(message.data.get("model"))
149
- continue
150
-
151
- if message.kind == "compaction":
159
+ elif message.kind == "compaction":
152
160
  compactions.append((active, timestamp))
153
- continue
154
-
155
- if message.kind != "assistant":
156
- continue
157
-
158
- model = _model(message.data.get("model")) or effective_model or "unknown"
159
- effective_model = model if model != "unknown" else effective_model
160
- tokens = _usage(message.data.get("tokens"))
161
- calls.append((active, timestamp, model, tokens))
162
- turn = turns.get(active or "")
163
- if turn:
164
- turn["model_calls"] += 1
165
- turn_models[active or ""].add(model)
166
- _add_tokens(turn, tokens)
167
- turn["ended_at"] = _iso(completed_at or timestamp) or turn["ended_at"]
168
- if isinstance(message.data.get("error"), dict):
169
- turn["status"] = "aborted"
170
- elif (
171
- completed_at is not None or _label(message.data.get("finish"), 255)
172
- ) and turn["status"] != "aborted":
173
- turn["status"] = "completed"
174
-
175
- content = message.data.get("content")
176
- if not isinstance(content, list):
177
- continue
178
- for part in content:
179
- if not isinstance(part, dict) or part.get("type") != "tool":
161
+ elif message.kind == "assistant":
162
+ model = _message_model(message.data) or effective_model or "unknown"
163
+ effective_model = model if model != "unknown" else effective_model
164
+ tokens = _usage(message.data.get("tokens"))
165
+ calls.append((active, timestamp, model, tokens))
166
+ turn = turns.get(active or "")
167
+ if turn:
168
+ turn["model_calls"] += 1
169
+ turn_models[active or ""].add(model)
170
+ _add_tokens(turn, tokens)
171
+ turn["ended_at"] = (
172
+ _iso(completed_at or timestamp) or turn["ended_at"]
173
+ )
174
+ if isinstance(message.data.get("error"), dict):
175
+ turn["status"] = "aborted"
176
+ elif (
177
+ completed_at is not None
178
+ or _label(message.data.get("finish"), 255)
179
+ ) and turn["status"] != "aborted":
180
+ turn["status"] = "completed"
181
+
182
+ for part in message.parts:
183
+ if part.kind == "compaction":
184
+ compactions.append((active, part.created_at or timestamp))
180
185
  continue
181
- name = _label(part.get("name"), 512)
186
+ if part.kind != "tool":
187
+ continue
188
+ name = _label(part.data.get("tool") or part.data.get("name"), 512)
182
189
  if not name:
183
190
  continue
184
- part_time = _mapping(part.get("time"))
191
+ state_time = _mapping(_mapping(part.data.get("state")).get("time"))
192
+ part_time = _mapping(part.data.get("time"))
185
193
  tools.append(
186
- (active, _timestamp(part_time.get("created")) or timestamp, name)
194
+ (
195
+ active,
196
+ part.created_at
197
+ or _timestamp(
198
+ part_time.get("created") or part_time.get("start")
199
+ )
200
+ or _timestamp(state_time.get("start"))
201
+ or timestamp,
202
+ name,
203
+ )
187
204
  )
188
205
 
189
206
  ended_at = max(
@@ -295,25 +312,59 @@ def _read_database(
295
312
  connection = manager.__enter__()
296
313
  connection.row_factory = sqlite3.Row
297
314
  session_columns = _columns(connection, "session")
298
- message_columns = _columns(connection, "session_message")
299
315
  required_session = {"id", "time_created", "time_updated"}
300
- required_message = {
301
- "id",
302
- "session_id",
303
- "type",
304
- "seq",
305
- "time_created",
306
- "time_updated",
307
- "data",
308
- }
309
- if (
310
- not required_session <= session_columns
311
- or not required_message <= message_columns
312
- ):
316
+ if not required_session <= session_columns:
313
317
  raise UnsupportedProviderFormat(
314
318
  f"Unsupported OpenCode database schema: {path}"
315
319
  )
316
- ensure_provider_sqlite_fields(connection, [("session_message", "data")])
320
+
321
+ message_columns = _columns(connection, "message")
322
+ part_columns = _columns(connection, "part")
323
+ current_schema_present = bool(message_columns or part_columns)
324
+ if current_schema_present:
325
+ required_message = {
326
+ "id",
327
+ "session_id",
328
+ "time_created",
329
+ "time_updated",
330
+ "data",
331
+ }
332
+ required_part = {
333
+ "id",
334
+ "message_id",
335
+ "session_id",
336
+ "time_created",
337
+ "time_updated",
338
+ "data",
339
+ }
340
+ if (
341
+ not required_message <= message_columns
342
+ or not required_part <= part_columns
343
+ ):
344
+ raise UnsupportedProviderFormat(
345
+ f"Unsupported OpenCode database schema: {path}"
346
+ )
347
+ ensure_provider_sqlite_fields(
348
+ connection, [("message", "data"), ("part", "data")]
349
+ )
350
+ read_messages = _read_current_messages
351
+ else:
352
+ projection_columns = _columns(connection, "session_message")
353
+ required_projection = {
354
+ "id",
355
+ "session_id",
356
+ "type",
357
+ "seq",
358
+ "time_created",
359
+ "time_updated",
360
+ "data",
361
+ }
362
+ if not required_projection <= projection_columns:
363
+ raise UnsupportedProviderFormat(
364
+ f"Unsupported OpenCode database schema: {path}"
365
+ )
366
+ ensure_provider_sqlite_fields(connection, [("session_message", "data")])
367
+ read_messages = _read_projection_messages
317
368
 
318
369
  directory = "directory" if "directory" in session_columns else "NULL"
319
370
  rows = budget.rows(
@@ -329,39 +380,9 @@ def _read_database(
329
380
  if not external_id:
330
381
  malformed += 1
331
382
  continue
332
- message_rows = budget.rows(
333
- connection.execute(
334
- "SELECT id, type, seq, time_created, time_updated, data "
335
- "FROM session_message WHERE session_id = ? ORDER BY seq, id",
336
- (row["id"],),
337
- )
338
- )
339
383
  digest = hashlib.sha256()
340
- messages: list[_Message] = []
341
- for message_row in message_rows:
342
- budget.json_field(message_row["data"])
343
- digest.update(str(tuple(message_row)).encode())
344
- message_id = _label(message_row["id"], 512)
345
- kind = _label(message_row["type"], 64)
346
- try:
347
- data = json.loads(message_row["data"])
348
- except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
349
- malformed += 1
350
- continue
351
- if not message_id or not kind or not isinstance(data, dict):
352
- malformed += 1
353
- continue
354
- sequence = _counter(message_row["seq"])
355
- messages.append(
356
- _Message(
357
- message_id,
358
- kind,
359
- sequence,
360
- _timestamp(message_row["time_created"]),
361
- _timestamp(message_row["time_updated"]),
362
- data,
363
- )
364
- )
384
+ messages, invalid = read_messages(connection, row["id"], budget, digest)
385
+ malformed += invalid
365
386
  conversations.append(
366
387
  _Conversation(
367
388
  machine=machine,
@@ -383,9 +404,148 @@ def _read_database(
383
404
  manager.__exit__(None, None, None)
384
405
 
385
406
 
407
+ def _read_current_messages(
408
+ connection: sqlite3.Connection,
409
+ session_id: object,
410
+ budget: ProviderInputBudget,
411
+ digest: Any,
412
+ ) -> tuple[list[_Message], int]:
413
+ message_rows = budget.rows(
414
+ connection.execute(
415
+ "SELECT id, time_created, time_updated, data "
416
+ "FROM message WHERE session_id = ? ORDER BY time_created, id",
417
+ (session_id,),
418
+ )
419
+ )
420
+ messages: list[_Message] = []
421
+ messages_by_id: dict[str, _Message] = {}
422
+ malformed = 0
423
+ for sequence, row in enumerate(message_rows, 1):
424
+ budget.json_field(row["data"])
425
+ digest.update(str(tuple(row)).encode())
426
+ message_id = _label(row["id"], 512)
427
+ data = _json_object(row["data"])
428
+ kind = _label(data.get("role"), 64) if data is not None else None
429
+ if not message_id or data is None or kind not in {"user", "assistant"}:
430
+ malformed += 1
431
+ continue
432
+ message = _Message(
433
+ message_id,
434
+ kind,
435
+ sequence,
436
+ _timestamp(row["time_created"]),
437
+ _timestamp(row["time_updated"]),
438
+ data,
439
+ [],
440
+ )
441
+ messages.append(message)
442
+ messages_by_id[message_id] = message
443
+
444
+ part_rows = budget.rows(
445
+ connection.execute(
446
+ "SELECT id, message_id, time_created, time_updated, data "
447
+ "FROM part WHERE session_id = ? ORDER BY message_id, id",
448
+ (session_id,),
449
+ )
450
+ )
451
+ for row in part_rows:
452
+ budget.json_field(row["data"])
453
+ digest.update(str(tuple(row)).encode())
454
+ part_id = _label(row["id"], 512)
455
+ message_id = _label(row["message_id"], 512)
456
+ data = _json_object(row["data"])
457
+ kind = _label(data.get("type"), 64) if data is not None else None
458
+ message = messages_by_id.get(message_id or "")
459
+ if not part_id or not message_id or data is None or not kind or message is None:
460
+ malformed += 1
461
+ continue
462
+ message.parts.append(
463
+ _Part(
464
+ part_id,
465
+ kind,
466
+ _timestamp(row["time_created"]),
467
+ _timestamp(row["time_updated"]),
468
+ data,
469
+ )
470
+ )
471
+ return messages, malformed
472
+
473
+
474
+ def _read_projection_messages(
475
+ connection: sqlite3.Connection,
476
+ session_id: object,
477
+ budget: ProviderInputBudget,
478
+ digest: Any,
479
+ ) -> tuple[list[_Message], int]:
480
+ rows = budget.rows(
481
+ connection.execute(
482
+ "SELECT id, type, seq, time_created, time_updated, data "
483
+ "FROM session_message WHERE session_id = ? ORDER BY seq, id",
484
+ (session_id,),
485
+ )
486
+ )
487
+ messages: list[_Message] = []
488
+ malformed = 0
489
+ for row in rows:
490
+ budget.json_field(row["data"])
491
+ digest.update(str(tuple(row)).encode())
492
+ message_id = _label(row["id"], 512)
493
+ kind = _label(row["type"], 64)
494
+ data = _json_object(row["data"])
495
+ if not message_id or not kind or data is None:
496
+ malformed += 1
497
+ continue
498
+ messages.append(
499
+ _Message(
500
+ message_id,
501
+ kind,
502
+ _counter(row["seq"]),
503
+ _timestamp(row["time_created"]),
504
+ _timestamp(row["time_updated"]),
505
+ data,
506
+ _projection_parts(message_id, data),
507
+ )
508
+ )
509
+ return messages, malformed
510
+
511
+
512
+ def _projection_parts(message_id: str, data: dict[str, Any]) -> list[_Part]:
513
+ content = data.get("content")
514
+ if not isinstance(content, list):
515
+ return []
516
+ result: list[_Part] = []
517
+ for sequence, value in enumerate(content, 1):
518
+ if not isinstance(value, dict):
519
+ continue
520
+ kind = _label(value.get("type"), 64)
521
+ if not kind:
522
+ continue
523
+ part_time = _mapping(value.get("time"))
524
+ result.append(
525
+ _Part(
526
+ _label(value.get("id"), 512) or f"{message_id}:part:{sequence}",
527
+ kind,
528
+ _timestamp(part_time.get("created") or part_time.get("start")),
529
+ _timestamp(part_time.get("completed") or part_time.get("end")),
530
+ value,
531
+ )
532
+ )
533
+ return result
534
+
535
+
536
+ def _json_object(value: object) -> dict[str, Any] | None:
537
+ if not isinstance(value, str | bytes | bytearray):
538
+ return None
539
+ try:
540
+ parsed = json.loads(value)
541
+ except (json.JSONDecodeError, TypeError, UnicodeDecodeError):
542
+ return None
543
+ return parsed if isinstance(parsed, dict) else None
544
+
545
+
386
546
  def _rank(value: _Conversation) -> tuple[int, datetime, str]:
387
547
  return (
388
- len(value.messages),
548
+ sum(1 + len(message.parts) for message in value.messages),
389
549
  value.updated_at or datetime.min.replace(tzinfo=UTC),
390
550
  value.digest,
391
551
  )
@@ -426,6 +586,15 @@ def _model(value: object) -> str | None:
426
586
  return identifier
427
587
 
428
588
 
589
+ def _message_model(data: dict[str, Any]) -> str | None:
590
+ return _model(data.get("model")) or _model(
591
+ {
592
+ "providerID": data.get("providerID"),
593
+ "modelID": data.get("modelID"),
594
+ }
595
+ )
596
+
597
+
429
598
  def _finish_turn(turn: dict[str, Any], fallback: datetime | None) -> None:
430
599
  turn["ended_at"] = turn["ended_at"] or _iso(fallback)
431
600
  start, end = _timestamp(turn["started_at"]), _timestamp(turn["ended_at"])
@@ -98,10 +98,12 @@ def _qualification(
98
98
  format: str,
99
99
  provenance: str,
100
100
  limitations: str,
101
+ *,
102
+ qualified_on: str = _QUALIFIED_ON,
101
103
  ) -> AdapterQualification:
102
104
  return AdapterQualification(
103
105
  version=version,
104
- qualified_on=_QUALIFIED_ON,
106
+ qualified_on=qualified_on,
105
107
  format=format,
106
108
  fixture=f"tests/test_{name.replace('-', '_')}_adapter.py",
107
109
  provenance=provenance,
@@ -358,10 +360,11 @@ ADAPTER_SPECS = (
358
360
  token_semantics="additive",
359
361
  qualification=_qualification(
360
362
  "opencode",
361
- "SQLite v2",
362
- "SQLite session, message, and part tables",
363
- "https://github.com/anomalyco/opencode/tree/10765ff2a9da8c3b88e4de873aa383a49c318912",
364
- "No legacy storage, child sessions, context windows, or costs.",
363
+ "CLI 1.18.23 / SQLite v2",
364
+ "SQLite session plus current message/part or projection records",
365
+ "https://github.com/anomalyco/opencode/tree/v1.18.23",
366
+ "No pre-v2 JSON, child sessions, context windows, or costs.",
367
+ qualified_on="2026-08-31",
365
368
  ),
366
369
  ),
367
370
  AdapterSpec(
File without changes
File without changes