diff --git a/external_plugins/usage-report/.zcode-plugin/plugin.json b/external_plugins/usage-report/.zcode-plugin/plugin.json new file mode 100644 index 00000000..5ee1abf5 --- /dev/null +++ b/external_plugins/usage-report/.zcode-plugin/plugin.json @@ -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" + ] +} diff --git a/external_plugins/usage-report/README.md b/external_plugins/usage-report/README.md new file mode 100644 index 00000000..2f67099f --- /dev/null +++ b/external_plugins/usage-report/README.md @@ -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-.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 diff --git a/external_plugins/usage-report/README_CN.md b/external_plugins/usage-report/README_CN.md new file mode 100644 index 00000000..f1a076c2 --- /dev/null +++ b/external_plugins/usage-report/README_CN.md @@ -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 diff --git a/external_plugins/usage-report/hooks/hooks.json b/external_plugins/usage-report/hooks/hooks.json new file mode 100644 index 00000000..21bb495e --- /dev/null +++ b/external_plugins/usage-report/hooks/hooks.json @@ -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": "统计用量…" + } + ] + } + ] + } +} diff --git a/external_plugins/usage-report/scripts/report_usage.py b/external_plugins/usage-report/scripts/report_usage.py new file mode 100644 index 00000000..9ccfc3f9 --- /dev/null +++ b/external_plugins/usage-report/scripts/report_usage.py @@ -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() diff --git a/marketplace.json b/marketplace.json index 95b67706..3153f9f8 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1499,6 +1524,28 @@ "image-to-video", "mcp" ] + }, + { + "name": "usage-report", + "source": "./external_plugins/usage-report", + "displayName": "Usage Report", + "displayName_i18n": { + "en": "Usage Report", + "zh-CN": "用量统计" + }, + "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 与纯算耗时/墙钟跨度。" + }, + "version": "0.4.0", + "category": "utilities", + "keywords": [ + "usage", + "tokens", + "stats", + "hook" + ] } ] -} \ No newline at end of file +}