Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions external_plugins/usage-report/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "usage-report",
"version": "0.4.0",
"description": "Injects measured token usage on every user prompt via a UserPromptSubmit hook, so the model appends one line with last-turn and cumulative input/output tokens plus pure-compute and wall-clock duration at the end of its reply.",
"description_i18n": {
"en": "Injects measured token usage on every user prompt via a UserPromptSubmit hook, so the model appends one line with last-turn and cumulative input/output tokens plus pure-compute and wall-clock duration at the end of its reply.",
"zh-CN": "通过 UserPromptSubmit 钩子在每条用户消息时注入真实测量的用量数据,模型在自己回复末尾附上一行:上轮/累计的输入输出 tokens 与纯算耗时/墙钟跨度。"
},
"author": {
"name": "forachange"
},
"license": "MIT",
"keywords": [
"usage",
"tokens",
"stats",
"hook",
"monitoring"
]
}
45 changes: 45 additions & 0 deletions external_plugins/usage-report/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Usage Report

A ZCode plugin that appends one measured usage line to the end of every model reply:

```
📊 用量统计|上轮 ↑1.2k ↓100 ⏱2s/2m01s|累计 ↑3.6k ↓300 ⏱6s/5m01s
```

- `↑` input tokens (including cache reads) · `↓` output tokens
- `⏱` pure compute time / wall-clock span (span includes idle time between turns)
- 上轮 = last turn · 累计 = the whole session so far

## How it works

- A `UserPromptSubmit` hook runs `scripts/report_usage.py` on every user message.
- The script parses this session's local ZCode rollout log (`~/.zcode/cli/rollout/model-io-<session>.jsonl`) and reads only the `response.usage` fields (`inputTokens`, `outputTokens`, `cacheReadTokens`), `durationMs` and `completedAt` of each recorded model request. Message contents are not read, processed, or stored.
- It injects the measured numbers as `additionalContext` with an instruction to append the line verbatim at the end of the reply.
- **Zero extra turns**: no Stop-continuation is used, so no separate stats-only turn appears. Trade-off: the numbers cover everything up to the previous turn; the current reply's own usage is counted on the next turn.
- The first message of a session (no records yet) is silently skipped.
- Bookkeeping state lives in `~/.zcode/usage-report-plugin/state/` and survives plugin upgrades.

## Side effects and requirements

| Aspect | Detail |
| --- | --- |
| Hooks | `UserPromptSubmit` → executes `scripts/report_usage.py` (local `python3`, no network) |
| File reads | `~/.zcode/cli/rollout/model-io-*.jsonl` (usage fields only) |
| File writes | `~/.zcode/usage-report-plugin/state/*.json` (small counters) |
| Network access | None |
| Third-party code/assets/services | None, stdlib Python only |
| Credentials | None accessed |
| Runtime | `python3` (bundled on macOS/Linux) |

Notes:

- Stats are token-based; most platforms do not expose a local "credits" API, so credits are not reported.
- No credentials, machine-specific paths, caches, or build artifacts are included in the plugin source.

## Install

In ZCode: **Plugin Marketplace → Add → Add Plugin Marketplace** and add the `zai-org/zcode-plugins` market (or install from the built-in official market once published), then **Usage Report → Install**. Open a new session and the stats line appears from the second reply onward.

## License

MIT
45 changes: 45 additions & 0 deletions external_plugins/usage-report/README_CN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Usage Report(用量统计)

ZCode 插件:在每次模型回复的末尾自动附上一行真实测量的用量统计:

```
📊 用量统计|上轮 ↑1.2k ↓100 ⏱2s/2m01s|累计 ↑3.6k ↓300 ⏱6s/5m01s
```

- `↑` 输入 tokens(含缓存读) `↓` 输出 tokens
- `⏱` 纯算耗时 / 墙钟跨度(跨度包含轮与轮之间的空闲时间)
- 上轮 = 上一轮对话 累计 = 本会话至今全部

## 工作机制

- `UserPromptSubmit` 钩子在每条用户消息时运行 `scripts/report_usage.py`。
- 脚本解析本会话的本地 ZCode 会话日志(`~/.zcode/cli/rollout/model-io-<会话id>.jsonl`),只读取每条模型请求记录的 `response.usage` 字段(`inputTokens`、`outputTokens`、`cacheReadTokens`)、`durationMs` 和 `completedAt`;不读取、不处理、不存储任何对话内容。
- 以 `additionalContext` 注入测量结果,并要求模型在回复末尾原样附上该行。
- **零额外回合**:不使用 Stop 延续,不会出现独立的统计回合。代价是数据截至上一轮,本次回复自身的消耗在下一轮计入。
- 会话第一条消息(尚无记录)静默跳过。
- 记账状态存于 `~/.zcode/usage-report-plugin/state/`,插件升级不会重置。

## 副作用与依赖

| 方面 | 说明 |
| --- | --- |
| 钩子 | `UserPromptSubmit` → 执行 `scripts/report_usage.py`(本地 `python3`,无网络) |
| 文件读取 | `~/.zcode/cli/rollout/model-io-*.jsonl`(仅用量字段) |
| 文件写入 | `~/.zcode/usage-report-plugin/state/*.json`(小型计数文件) |
| 网络访问 | 无 |
| 第三方代码/素材/服务 | 无,仅 Python 标准库 |
| 凭据 | 不访问任何凭据 |
| 运行时 | `python3`(macOS / Linux 自带) |

说明:

- 统计口径为 token;各平台通常不提供本地 "credits" API,因此不报告 credits 数值。
- 插件源码中不含凭据、机器专属路径、缓存或构建产物。

## 安装

在 ZCode 中:**插件市场 → Add → Add Plugin Marketplace** 添加 `zai-org/zcode-plugins` 市场(收录后官方市场内直接可见),然后 **Usage Report → Install**。新开会话,从第二次回复起即可看到统计行。

## 许可证

MIT
20 changes: 20 additions & 0 deletions external_plugins/usage-report/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "process",
"command": "/usr/bin/env",
"args": [
"python3",
"${ZCODE_PLUGIN_ROOT}/scripts/report_usage.py"
],
"timeoutMs": 15000,
"statusMessage": "统计用量…"
}
]
}
]
}
}
139 changes: 139 additions & 0 deletions external_plugins/usage-report/scripts/report_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""UserPromptSubmit hook:用户发送消息时,把截至上一轮的真实用量统计作为
上下文注入,让模型在自己正常回复的末尾顺带附上一行统计。
零额外回合:不使用 Stop 延续,统计行并入模型本回合的回复。

真实 credits 数值无本地 API,以 token 为消耗口径。
"""
import json
import os
import sys
import glob
import time
from datetime import datetime

ROLL_DIR = os.environ.get("ZCODE_ROLLOUT_DIR") or os.path.expanduser(
"~/.zcode/cli/rollout")
# 状态放版本无关的用户目录,插件升级不重置基准
STATE_DIR = os.path.expanduser("~/.zcode/usage-report-plugin/state")
MARKER = "📊 用量统计"

def find_rollout(sid):
if sid:
for cand in (sid, sid if sid.startswith("sess_") else "sess_" + sid):
p = os.path.join(ROLL_DIR, "model-io-%s.jsonl" % cand)
if os.path.exists(p):
return p
files = sorted(glob.glob(os.path.join(ROLL_DIR, "model-io-*.jsonl")),
key=os.path.getmtime, reverse=True)
return files[0] if files else None

def parse_records(path):
recs = []
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
u = (obj.get("usage") or
(obj.get("response") or {}).get("usage") or {})
recs.append({
"completedAt": obj.get("completedAt"),
"durationMs": obj.get("durationMs") or 0,
"in": u.get("input_tokens", u.get("inputTokens", 0)) or 0,
"out": u.get("output_tokens", u.get("outputTokens", 0)) or 0,
"cache": u.get("cache_read_input_tokens",
u.get("cacheReadTokens", 0)) or 0,
})
except OSError:
pass
return recs

def ts(iso):
try:
return datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()
except Exception:
return None

def fmt_dur(ms):
s = int(ms // 1000)
if s < 60:
return "%ds" % s
return "%dm%02ds" % (s // 60, s % 60)

def fmt_k(n):
return "%.1fk" % (n / 1000) if n >= 1000 else str(n)

def stats(rs, label):
tin = sum(r["in"] for r in rs) + sum(r["cache"] for r in rs)
tout = sum(r["out"] for r in rs)
# 纯算:各请求耗时之和(不含轮间空闲);跨度:最早请求开始 → 最新请求完成的墙钟
pure = sum(r["durationMs"] for r in rs)
t0 = ts(rs[0]["completedAt"])
t0 = (t0 - rs[0]["durationMs"] / 1000.0) if t0 else None
t1 = ts(rs[-1]["completedAt"])
span = (t1 - t0) * 1000 if (t0 and t1) else None
return "%s ↑%s ↓%s ⏱%s/%s" % (label, fmt_k(tin), fmt_k(tout),
fmt_dur(pure),
fmt_dur(span) if span is not None else "?")

def main():
try:
json.load(sys.stdin) # 消费 stdin(内容不依赖)
except Exception:
pass

sid = os.environ.get("ZCODE_SESSION_ID") or os.environ.get("CLAUDE_SESSION_ID") or ""
path = find_rollout(sid)
if not path:
sys.exit(0)
recs = parse_records(path)

os.makedirs(STATE_DIR, exist_ok=True)
state_file = os.path.join(STATE_DIR, "prompt-%s.json" % os.path.basename(path))
prev = None
try:
prev = json.load(open(state_file)).get("count", 0)
except Exception:
# 版本升级后新状态目录为空:从旧版本缓存里的状态文件继承基准
old = sorted(glob.glob(os.path.expanduser(
"~/.zcode/cli/plugins/cache/*/*/*/.state/prompt-%s.json"
% os.path.basename(path))), key=os.path.getmtime)
if old:
try:
prev = json.load(open(old[-1])).get("count", 0)
except Exception:
prev = None
if prev is None:
prev = 0
json.dump({"count": len(recs), "at": time.time()}, open(state_file, "w"))

if not recs:
sys.exit(0) # 会话首条消息,尚无任何用量,不注入

# 上轮 = 上次注入以来的新增请求;累计 = 截至当前全部
last = recs[prev:] if prev < len(recs) else recs[-1:]
line = "%s|%s|%s" % (MARKER, stats(last, "上轮"),
stats(recs, "累计"))
ctx = (
"【系统统计注入】下面这行数据由系统钩子从本会话真实的模型请求日志"
"(~/.zcode/cli/rollout/model-io-*.jsonl)自动统计得出(↑=输入tokens "
"含缓存读,↓=输出tokens,⏱=纯算耗时/墙钟跨度,跨度含轮间空闲),"
"是系统测量结果,截至上一轮,"
"真实可靠。请在你本次回复的最末尾,单独一行原样附上它,"
"不改数字、不加解释:\n" + line
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx,
}
}, ensure_ascii=False))

if __name__ == "__main__":
main()
Loading