power-loop 3.7.0__tar.gz → 3.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {power_loop-3.7.0 → power_loop-3.8.1}/PKG-INFO +1 -1
  2. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/__init__.py +1 -1
  3. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/sink.py +3 -0
  4. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/dialect.py +6 -3
  5. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/schema.py +19 -1
  6. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/store.py +12 -7
  7. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/types.py +3 -0
  8. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/default_tools.py +15 -2
  9. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop.egg-info/PKG-INFO +1 -1
  10. {power_loop-3.7.0 → power_loop-3.8.1}/LICENSE +0 -0
  11. {power_loop-3.7.0 → power_loop-3.8.1}/README.md +0 -0
  12. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/__init__.py +0 -0
  13. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/__init__.py +0 -0
  14. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/anthropic_factory.py +0 -0
  15. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/capabilities.py +0 -0
  16. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/interface.py +0 -0
  17. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/llm_factory.py +0 -0
  18. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/llm_tooling.py +0 -0
  19. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/llm_utils.py +0 -0
  20. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/_vendor/llm_client/multimodal.py +0 -0
  21. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/__init__.py +0 -0
  22. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/follow_up.py +0 -0
  23. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/stateful_loop.py +0 -0
  24. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/system_prompt.py +0 -0
  25. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/agent/types.py +0 -0
  26. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/__init__.py +0 -0
  27. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/errors.py +0 -0
  28. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/event_payloads.py +0 -0
  29. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/events.py +0 -0
  30. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/handlers.py +0 -0
  31. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/hook_contexts.py +0 -0
  32. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/hooks.py +0 -0
  33. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/messages.py +0 -0
  34. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/protocols.py +0 -0
  35. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contracts/tools.py +0 -0
  36. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/__init__.py +0 -0
  37. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/_redact.py +0 -0
  38. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/jsonl_sink.py +0 -0
  39. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/logging_sink.py +0 -0
  40. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/mcp.py +0 -0
  41. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/metrics_sink.py +0 -0
  42. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/contrib/otel_sink.py +0 -0
  43. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/agent_context.py +0 -0
  44. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/events.py +0 -0
  45. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/hooks.py +0 -0
  46. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/phase.py +0 -0
  47. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/pipeline.py +0 -0
  48. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/runner.py +0 -0
  49. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/core/state.py +0 -0
  50. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/py.typed +0 -0
  51. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/blackboard.py +0 -0
  52. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/budget.py +0 -0
  53. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/cancellation.py +0 -0
  54. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/compact.py +0 -0
  55. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/env.py +0 -0
  56. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/exec_backend.py +0 -0
  57. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/fold.py +0 -0
  58. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/fold_adapter.py +0 -0
  59. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/history_projector.py +0 -0
  60. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/history_sanitize.py +0 -0
  61. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/human_input.py +0 -0
  62. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/memory.py +0 -0
  63. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/notes.py +0 -0
  64. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/provider.py +0 -0
  65. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/representation.py +0 -0
  66. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/retry.py +0 -0
  67. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/runtime_state.py +0 -0
  68. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/session_store.py +0 -0
  69. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/skills.py +0 -0
  70. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/spec.py +0 -0
  71. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/__init__.py +0 -0
  72. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/backends/__init__.py +0 -0
  73. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/backends/mysql.py +0 -0
  74. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/backends/postgres.py +0 -0
  75. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/backends/sqlite.py +0 -0
  76. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/capabilities.py +0 -0
  77. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/db.py +0 -0
  78. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/store/factory.py +0 -0
  79. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/structured.py +0 -0
  80. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/stub_provider.py +0 -0
  81. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/runtime/timers.py +0 -0
  82. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/__init__.py +0 -0
  83. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/blackboard.py +0 -0
  84. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/default_manifest.py +0 -0
  85. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/registry.py +0 -0
  86. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/tools/spawn_agent.py +0 -0
  87. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/__init__.py +0 -0
  88. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/api.py +0 -0
  89. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/engine.py +0 -0
  90. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/introspect.py +0 -0
  91. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/journal.py +0 -0
  92. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/result.py +0 -0
  93. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/resume.py +0 -0
  94. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/runner.py +0 -0
  95. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/spec.py +0 -0
  96. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/subprocess_executor.py +0 -0
  97. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/tool.py +0 -0
  98. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop/workflow/worker.py +0 -0
  99. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop.egg-info/SOURCES.txt +0 -0
  100. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop.egg-info/dependency_links.txt +0 -0
  101. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop.egg-info/requires.txt +0 -0
  102. {power_loop-3.7.0 → power_loop-3.8.1}/power_loop.egg-info/top_level.txt +0 -0
  103. {power_loop-3.7.0 → power_loop-3.8.1}/pyproject.toml +0 -0
  104. {power_loop-3.7.0 → power_loop-3.8.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.7.0
3
+ Version: 3.8.1
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Stability tiers
15
15
  无版本承诺,可随时变更或删除。
16
16
  """
17
17
 
18
- __version__ = "3.7.0"
18
+ __version__ = "3.8.1"
19
19
 
20
20
  # Public LLM contract (SDK-free) re-exported so callers (e.g. writing llm.* hooks or
21
21
  # a custom LLMService) don't reach into the internal vendored transport package (H3.4).
@@ -377,6 +377,9 @@ class SQLiteSink:
377
377
  usage.get("completion_tokens") or usage.get("output")
378
378
  ),
379
379
  total_tokens=_int_or_none(usage.get("total_tokens")),
380
+ cached_tokens=_int_or_none(
381
+ usage.get("cache_read_tokens") or usage.get("cached_tokens")
382
+ ),
380
383
  )
381
384
 
382
385
 
@@ -101,7 +101,7 @@ class SqliteDialect:
101
101
  PRIMARY KEY (session_id, compact_seq))""",
102
102
  f"""CREATE TABLE IF NOT EXISTS {p}usage_rounds (
103
103
  session_id TEXT NOT NULL, round_index INTEGER NOT NULL, prompt_tokens INTEGER,
104
- completion_tokens INTEGER, total_tokens INTEGER, model TEXT,
104
+ completion_tokens INTEGER, total_tokens INTEGER, cached_tokens INTEGER, model TEXT,
105
105
  created_at INTEGER NOT NULL, PRIMARY KEY (session_id, round_index))""",
106
106
  f"""CREATE TABLE IF NOT EXISTS {p}session_state (
107
107
  session_id TEXT PRIMARY KEY, next_seq INTEGER NOT NULL DEFAULT 1,
@@ -125,6 +125,7 @@ class SqliteDialect:
125
125
  rounds INTEGER NOT NULL DEFAULT 0, llm_calls INTEGER NOT NULL DEFAULT 0,
126
126
  tool_calls INTEGER NOT NULL DEFAULT 0, prompt_tokens INTEGER NOT NULL DEFAULT 0,
127
127
  completion_tokens INTEGER NOT NULL DEFAULT 0, total_tokens INTEGER NOT NULL DEFAULT 0,
128
+ cached_tokens INTEGER NOT NULL DEFAULT 0,
128
129
  first_send_at INTEGER, last_send_at INTEGER, updated_at INTEGER NOT NULL)""",
129
130
  f"""CREATE TABLE IF NOT EXISTS {p}timers (
130
131
  session_id TEXT NOT NULL, timer_id INTEGER NOT NULL, due_at INTEGER NOT NULL,
@@ -243,7 +244,7 @@ class PostgresDialect:
243
244
  PRIMARY KEY (session_id, compact_seq))""",
244
245
  f"""CREATE TABLE IF NOT EXISTS {p}usage_rounds (
245
246
  session_id TEXT NOT NULL, round_index BIGINT NOT NULL, prompt_tokens BIGINT,
246
- completion_tokens BIGINT, total_tokens BIGINT, model TEXT,
247
+ completion_tokens BIGINT, total_tokens BIGINT, cached_tokens BIGINT, model TEXT,
247
248
  created_at BIGINT NOT NULL, PRIMARY KEY (session_id, round_index))""",
248
249
  f"""CREATE TABLE IF NOT EXISTS {p}session_state (
249
250
  session_id TEXT PRIMARY KEY, next_seq BIGINT NOT NULL DEFAULT 1,
@@ -267,6 +268,7 @@ class PostgresDialect:
267
268
  rounds BIGINT NOT NULL DEFAULT 0, llm_calls BIGINT NOT NULL DEFAULT 0,
268
269
  tool_calls BIGINT NOT NULL DEFAULT 0, prompt_tokens BIGINT NOT NULL DEFAULT 0,
269
270
  completion_tokens BIGINT NOT NULL DEFAULT 0, total_tokens BIGINT NOT NULL DEFAULT 0,
271
+ cached_tokens BIGINT NOT NULL DEFAULT 0,
270
272
  first_send_at BIGINT, last_send_at BIGINT, updated_at BIGINT NOT NULL)""",
271
273
  f"""CREATE TABLE IF NOT EXISTS {p}timers (
272
274
  session_id TEXT NOT NULL, timer_id BIGINT NOT NULL, due_at BIGINT NOT NULL,
@@ -372,7 +374,7 @@ class MySQLDialect:
372
374
  PRIMARY KEY (session_id, compact_seq)) {opts}""",
373
375
  f"""CREATE TABLE IF NOT EXISTS {p}usage_rounds (
374
376
  session_id VARCHAR(255) NOT NULL, round_index BIGINT NOT NULL, prompt_tokens BIGINT,
375
- completion_tokens BIGINT, total_tokens BIGINT, model VARCHAR(255),
377
+ completion_tokens BIGINT, total_tokens BIGINT, cached_tokens BIGINT, model VARCHAR(255),
376
378
  created_at BIGINT NOT NULL, PRIMARY KEY (session_id, round_index)) {opts}""",
377
379
  f"""CREATE TABLE IF NOT EXISTS {p}session_state (
378
380
  session_id VARCHAR(255) NOT NULL, next_seq BIGINT NOT NULL DEFAULT 1,
@@ -395,6 +397,7 @@ class MySQLDialect:
395
397
  rounds BIGINT NOT NULL DEFAULT 0, llm_calls BIGINT NOT NULL DEFAULT 0,
396
398
  tool_calls BIGINT NOT NULL DEFAULT 0, prompt_tokens BIGINT NOT NULL DEFAULT 0,
397
399
  completion_tokens BIGINT NOT NULL DEFAULT 0, total_tokens BIGINT NOT NULL DEFAULT 0,
400
+ cached_tokens BIGINT NOT NULL DEFAULT 0,
398
401
  first_send_at BIGINT, last_send_at BIGINT, updated_at BIGINT NOT NULL,
399
402
  PRIMARY KEY (session_id)) {opts}""",
400
403
  f"""CREATE TABLE IF NOT EXISTS {p}timers (
@@ -67,7 +67,9 @@ def validate_table_prefix(prefix: str) -> str:
67
67
  #: Bump + append a migration step for ANY schema change.
68
68
  #: v2 (2026-06): adds the ``{prefix}project_messages`` table (send-context projection).
69
69
  #: v3 (2026-06): adds the ``{prefix}hook_events`` table (ephemeral hook-augmentation audit log).
70
- CURRENT_SCHEMA_VERSION = 4
70
+ #: v4 (2026-06): widen MySQL free-text/JSON columns TEXT→LONGTEXT.
71
+ #: v5 (2026-06): adds ``cached_tokens`` (prompt cache-read tokens) to usage_rounds + session_stats.
72
+ CURRENT_SCHEMA_VERSION = 5
71
73
 
72
74
  #: The store's data tables (besides ``{prefix}schema_migrations``) — used by VERIFY to
73
75
  #: confirm the FULL schema is present, not just the version row. Keep in sync with
@@ -118,6 +120,16 @@ async def _migration_steps(
118
120
  # idempotent. (Fresh stores already provision LONGTEXT via Dialect.ddl.)
119
121
  if db.dialect.name == "mysql":
120
122
  steps += db.dialect.widen_text_columns_ddl(prefix)
123
+ if from_version < 5:
124
+ # v4 → v5: add cached_tokens (prompt cache-read tokens) to usage_rounds + session_stats.
125
+ # ALTER … ADD COLUMN has no portable IF NOT EXISTS, so probe the catalog on the open tx.
126
+ int_t = "INTEGER" if db.dialect.name == "sqlite" else "BIGINT"
127
+ if not await _column_exists(tx, db.dialect.name, f"{prefix}usage_rounds", "cached_tokens"):
128
+ steps.append(f"ALTER TABLE {prefix}usage_rounds ADD COLUMN cached_tokens {int_t}")
129
+ if not await _column_exists(tx, db.dialect.name, f"{prefix}session_stats", "cached_tokens"):
130
+ steps.append(
131
+ f"ALTER TABLE {prefix}session_stats ADD COLUMN cached_tokens {int_t} NOT NULL DEFAULT 0"
132
+ )
121
133
  return steps
122
134
 
123
135
 
@@ -137,6 +149,12 @@ def migration_ddl_for_display(db: Database, prefix: str, *, from_version: int) -
137
149
  steps += db.dialect.hook_events_ddl(prefix)
138
150
  if from_version < 4 and db.dialect.name == "mysql":
139
151
  steps += db.dialect.widen_text_columns_ddl(prefix)
152
+ if from_version < 5:
153
+ int_t = "INTEGER" if db.dialect.name == "sqlite" else "BIGINT"
154
+ steps.append(f"ALTER TABLE {prefix}usage_rounds ADD COLUMN cached_tokens {int_t}")
155
+ steps.append(
156
+ f"ALTER TABLE {prefix}session_stats ADD COLUMN cached_tokens {int_t} NOT NULL DEFAULT 0"
157
+ )
140
158
  return steps
141
159
 
142
160
 
@@ -146,7 +146,7 @@ _EXPORT_TABLES: tuple[tuple[str, Any, tuple[str, ...]], ...] = (
146
146
  "after_tokens", "round_index", "created_at")),
147
147
  ("usage_rounds", lambda t: t.usage_rounds, (
148
148
  "session_id", "round_index", "prompt_tokens", "completion_tokens", "total_tokens",
149
- "model", "created_at")),
149
+ "cached_tokens", "model", "created_at")),
150
150
  ("session_runtime_state", lambda t: t.session_runtime_state, (
151
151
  "session_id", "state_key", "value_json", "updated_at")),
152
152
  ("timers", lambda t: t.timers, (
@@ -156,7 +156,8 @@ _EXPORT_TABLES: tuple[tuple[str, Any, tuple[str, ...]], ...] = (
156
156
  "session_id", "note_id", "content", "pinned", "created_at", "updated_at")),
157
157
  ("session_stats", lambda t: t.session_stats, (
158
158
  "session_id", "sends", "rounds", "llm_calls", "tool_calls", "prompt_tokens",
159
- "completion_tokens", "total_tokens", "first_send_at", "last_send_at", "updated_at")),
159
+ "completion_tokens", "total_tokens", "cached_tokens", "first_send_at", "last_send_at",
160
+ "updated_at")),
160
161
  )
161
162
 
162
163
 
@@ -1143,6 +1144,7 @@ class SessionStore:
1143
1144
  prompt_tokens: int | None,
1144
1145
  completion_tokens: int | None,
1145
1146
  total_tokens: int | None,
1147
+ cached_tokens: int | None = None,
1146
1148
  model: str | None = None,
1147
1149
  ) -> None:
1148
1150
  """Record per-round token usage. Legacy used ``INSERT OR REPLACE`` keyed on
@@ -1151,14 +1153,15 @@ class SessionStore:
1151
1153
  sql = self._db.dialect.upsert(
1152
1154
  self.t.usage_rounds,
1153
1155
  ("session_id", "round_index"),
1154
- ("prompt_tokens", "completion_tokens", "total_tokens", "model", "created_at"),
1156
+ ("prompt_tokens", "completion_tokens", "total_tokens", "cached_tokens", "model",
1157
+ "created_at"),
1155
1158
  )
1156
1159
  async with self._db.transaction() as tx:
1157
1160
  await tx.execute(
1158
1161
  sql,
1159
1162
  (
1160
1163
  session_id, round_index, prompt_tokens, completion_tokens,
1161
- total_tokens, model, _now_ms(),
1164
+ total_tokens, cached_tokens, model, _now_ms(),
1162
1165
  ),
1163
1166
  )
1164
1167
 
@@ -1172,7 +1175,7 @@ class SessionStore:
1172
1175
  tool_calls: int = 0,
1173
1176
  ) -> None:
1174
1177
  """Cumulative per-session accounting, bumped once per finished send. On conflict
1175
- the seven counters ACCUMULATE (``col = col + new``), ``last_send_at``/``updated_at``
1178
+ the eight counters ACCUMULATE (``col = col + new``), ``last_send_at``/``updated_at``
1176
1179
  OVERWRITE, and ``first_send_at`` is INSERT-ONLY (preserved on conflict, matching
1177
1180
  legacy which omits it from the ON CONFLICT SET). ``sends`` accumulates by 1."""
1178
1181
  now = _now_ms()
@@ -1182,7 +1185,7 @@ class SessionStore:
1182
1185
  ("last_send_at", "updated_at"),
1183
1186
  add_cols=(
1184
1187
  "sends", "rounds", "llm_calls", "tool_calls",
1185
- "prompt_tokens", "completion_tokens", "total_tokens",
1188
+ "prompt_tokens", "completion_tokens", "total_tokens", "cached_tokens",
1186
1189
  ),
1187
1190
  insert_only_cols=("first_send_at",),
1188
1191
  )
@@ -1198,6 +1201,7 @@ class SessionStore:
1198
1201
  int(usage.get("prompt_tokens") or 0), # add: prompt_tokens
1199
1202
  int(usage.get("completion_tokens") or 0), # add: completion_tokens
1200
1203
  int(usage.get("total_tokens") or 0), # add: total_tokens
1204
+ int(usage.get("cache_read_tokens") or usage.get("cached_tokens") or 0), # add: cached_tokens
1201
1205
  now, # insert-only: first_send_at
1202
1206
  )
1203
1207
  async with self._db.transaction() as tx:
@@ -1650,7 +1654,8 @@ def _row_to_stats(row: Row) -> SessionStatsRow:
1650
1654
  session_id=row["session_id"], sends=row["sends"], rounds=row["rounds"],
1651
1655
  llm_calls=row["llm_calls"], tool_calls=row["tool_calls"],
1652
1656
  prompt_tokens=row["prompt_tokens"], completion_tokens=row["completion_tokens"],
1653
- total_tokens=row["total_tokens"], first_send_at=row["first_send_at"],
1657
+ total_tokens=row["total_tokens"], cached_tokens=row["cached_tokens"],
1658
+ first_send_at=row["first_send_at"],
1654
1659
  last_send_at=row["last_send_at"], updated_at=row["updated_at"],
1655
1660
  )
1656
1661
 
@@ -97,6 +97,9 @@ class SessionStatsRow:
97
97
  first_send_at: int | None
98
98
  last_send_at: int | None
99
99
  updated_at: int
100
+ #: Prompt cache-read tokens (v5). Default 0 so the legacy SQLite store (which doesn't track it)
101
+ #: still constructs the row.
102
+ cached_tokens: int = 0
100
103
 
101
104
 
102
105
  @dataclass
@@ -124,8 +124,21 @@ def _looks_binary(data: bytes) -> bool:
124
124
  if not data:
125
125
  return False
126
126
  sample = data[:4096]
127
- textish = sum(byte in b"\n\r\t\b\f" or 32 <= byte <= 126 for byte in sample)
128
- return textish / len(sample) < 0.70
127
+ # Fast path: mostly-ASCII text (incl. common whitespace) is clearly text.
128
+ ascii_ish = sum(byte in b"\n\r\t\b\f" or 32 <= byte <= 126 for byte in sample)
129
+ if ascii_ish / len(sample) >= 0.70:
130
+ return False
131
+ # Otherwise it may be NON-Latin UTF-8 text (CJK / emoji, whose bytes are mostly >126) —
132
+ # NOT binary. Decode leniently (a multibyte char truncated at the 4KB sample boundary is
133
+ # negligible) and flag as binary only if the text is littered with undecodable bytes
134
+ # (U+FFFD) or C0/C1 control chars (excluding normal whitespace), which real text lacks.
135
+ text = sample.decode("utf-8", errors="replace")
136
+ bad = sum(
137
+ 1
138
+ for ch in text
139
+ if ch == "�" or ord(ch) == 0x7f or (ord(ch) < 32 and ch not in "\n\r\t\f\b")
140
+ )
141
+ return bad / len(text) > 0.15
129
142
 
130
143
 
131
144
  def _read_text(fp: Path, *, max_bytes: int | None = TEXT_FILE_MAX_BYTES) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: power-loop
3
- Version: 3.7.0
3
+ Version: 3.8.1
4
4
  Summary: Embeddable agent execution kernel — LLM loop, hooks, events, tools, dynamic sub-agents.
5
5
  Author-email: zhangran <zhangran24@126.com>
6
6
  License: MIT
File without changes
File without changes
File without changes
File without changes