diff --git a/external_plugins/zcode-context/.gitignore b/external_plugins/zcode-context/.gitignore new file mode 100644 index 00000000..f3701516 --- /dev/null +++ b/external_plugins/zcode-context/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +*.log +.cache/ +dist/ +.DS_Store +Thumbs.db diff --git a/external_plugins/zcode-context/.zcode-plugin/plugin.json b/external_plugins/zcode-context/.zcode-plugin/plugin.json new file mode 100644 index 00000000..15e88de6 --- /dev/null +++ b/external_plugins/zcode-context/.zcode-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "zcode-context", + "displayName": "Context Insight", + "description": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.", + "description_i18n": { + "en": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.", + "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(步进/轮次 × 总计/增量,可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。" + }, + "version": "0.2.1", + "author": { + "name": "AwenJackson", + "url": "https://github.com/AwenJackson" + }, + "repository": "https://github.com/AwenJackson/zcode-context", + "keywords": [ + "context-window", + "token-usage", + "token-statistics", + "cost-estimation", + "cache-hit-rate", + "llm-observability", + "dashboard", + "agent-monitoring", + "context-insight", + "dsh-context" + ] +} diff --git a/external_plugins/zcode-context/LICENSE b/external_plugins/zcode-context/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/external_plugins/zcode-context/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/external_plugins/zcode-context/README.md b/external_plugins/zcode-context/README.md new file mode 100644 index 00000000..9c2ad9c0 --- /dev/null +++ b/external_plugins/zcode-context/README.md @@ -0,0 +1,136 @@ +
+ +# 🧠 zcode-context · 上下文洞察 (Context Insight) + +**A context-usage insight dashboard for [ZCode](https://zcode.ai) — inspired by [dsh-context](https://github.com/bowenliang123/dsh-context) for DeepSeek Harness.** + +[![Version](https://img.shields.io/badge/version-0.2.0-blue)](#安装) +[![License](https://img.shields.io/badge/license-Apache%202.0-green)](./LICENSE) +[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](#) +[![ZCode](https://img.shields.io/badge/ZCode-Plugin-orange)](https://github.com/zai-org/zcode-plugins) +[![GitHub Stars](https://img.shields.io/github/stars/AwenJackson/zcode-context?style=social)](https://github.com/AwenJackson/zcode-context/stargazers) + +*ZCode 上下文洞察仪表盘 · Token 统计 / 费用估算 / 缓存命中率 / 上下文浏览器 / Agent 编队 · 纯本地数据,无需其他插件,无需系统 Node.js* + +
+ +--- + +## 📸 界面导览 / Screenshots + +插件共四个视图:**洞察(总览)→ 会话 → 上下文(单会话详情)→ 编队**。以下按页面逐一说明。 + +### 图 1 · 洞察页 — KPI 总览与用量趋势(Overview · KPIs & Usage) + +![洞察页 · KPI 总览](docs/screenshots/01-overview-top.png) + +- **六张 KPI 卡**(顶部居中):全部会话(含总请求次数)、Token 消耗(含输出量)、预估费用(按公开牌价估算)、缓存命中率(`cacheRead / input`,越大说明缓存复用越好、费用越低)、工具调用(含命令执行成功与失败次数)、活跃时长(模型等待 + 输出速度 tok/s)。 +- **近 7 天用量图**(右上):Token(蓝柱)+ 费用(绿柱)双柱对照,横轴为日期;**悬停任意柱**会弹出摘要:日期、Token 总量、费用、请求次数。 +- **Token 统计环形图**:总量居中,环形按 缓存读取 / 模型思考 / 未命中输入 / 输出 / 缓存写入 五类拆分;右侧图例给出每类的占比与 ≈值,**悬停分段或图例会互相高亮联动**。 +- **耗时统计环形图**:总时长居中,按 模型等待(Σ首字延迟)/ 模型思考 / 模型回复 / 工具执行(请求间隙估算)/ 其他 拆分,图例附次数口径;下方时间轴条直观显示各段占比。 +- **时间范围切换**(右上角):今天 / 近 7 天 / 近 30 天;⟳ 刷新、✕ 关闭。 +- 顶部四个视图 Tab:**洞察 · 会话 · 上下文 · 编队**。 + +### 图 2 · 洞察页 — 热力图 / 排行 / 会话卡片(Overview · Rankings & Sessions) + +![洞察页 · 热力图与排行](docs/screenshots/02-overview-rankings.png) + +- **活跃热力图**:近 8 周活跃度,按天着色,支持 **Token 消耗 / 会话** 双指标切换;**悬停显示当日会话数与 Token 量,点击格子可筛选当天的会话卡片**。 +- **工具调用排行**:按调用次数排序(图例:Bash ×4047、Edit ×1010、Read ×459…),行内附彩色占比条;**悬停显示次数与占比,点击行会在下方出现「筛选:Bash ✕」chip,把会话卡片过滤到使用过该工具的会话;再次点击行或点 chip 取消**。 +- **命令执行排行**:取 Bash/PowerShell 命令首词统计(cd ×1189、grep ×844…),并显示总执行次数与失败次数。 +- **项目分布**:按工作区聚合的 Token 排行,同样支持点击筛选。 +- **会话卡片**:标题、时间、工作区 chip、迷你上下文占用环(≥85% 变红)、轮次 / Token / 费用、最近消息摘要;卡片底部的「筛选」chip 显示当前生效的筛选条件。 + +### 图 3 · 会话页(Sessions) + +![会话页](docs/screenshots/03-sessions.png) + +- **搜索框**:按标题模糊搜索;**工作区 chips**:全部 12 / masscms_sass_backend 4 / plugins 3 …,点击即过滤。 +- **三种排序**:Token 总用量 / 最近更新 / 当前上下文(上下文占比从高到低,快速找出快爆窗口的会话)。 +- **每张卡片**:迷你环形图中心直接显示当前上下文占用百分比(如 100%、93%、52%),占用 ≥85% 环变红色预警;右侧显示 轮次、Token、总预估费用。 +- 点击任意卡片进入该会话的 **上下文** 详情页。 + +### 图 4 · 上下文页 — 统计头卡与构成(Context · Stats & Composition) + +![上下文页 · 统计与构成](docs/screenshots/04-context-stats.png) + +- **上下文统计头卡**:轮次(点击查看趋势)、步骤(点击查看趋势)、工具调用、回复块、Token 总用量 + 费用。 +- **输入输出行**:用户 / 读取 / 写入 / 搜索 / 命令的可点击 chip —— **点击「读取」会滚动到文件活动卡并切到「读取」筛选;工具调用行同理,点击某工具名会把事件日志过滤到该工具**。 +- **Token 统计**:本会话的缓存读取 / 未命中输入 / 输出拆分与缓存命中率。 +- **耗时统计**:模型等待 / 模型回复 / 工具执行的三段环形 + 时间轴。 +- **当前上下文条**:当前窗口占用(如 224k / 205k tokens),分段显示六类构成占比,末段为斜纹的**压缩预留区**;**悬停分段显示「≈值 · N% 占已用」,点击分段会滚动到下方上下文浏览器对应分组并自动展开**。 +- **上下文费用构成**:把当前上下文按六类 token 乘以最近模型牌价估算为 CNY,环形图 + 逐类百分比(悬停显示 ≈tokens × 单价 = ¥ 口径)。 +- **上下文趋势**:总计 / 增量 × **步进 / 轮次** 四种组合切换;蓝线=输入(上下文规模)、绿线=输出、紫虚线=单请求耗时、红虚线=模型窗口上限、琥珀虚线=compact 压缩点。**悬停任意步骤弹出「第 T 轮 · 第 S 步」摘要(输入/输出/缓存命中率/耗时/Δ增量),点击可固定该步详情**(见图 5 底部横条)。 + +### 图 5 · 上下文页 — 趋势固定详情 / 事件日志 / 文件活动(Context · Trend Detail, Events & Files) + +![上下文页 · 趋势与事件](docs/screenshots/05-context-trend.png) + +- **RequestDetail 固定条**(趋势图下方):点击趋势图某一步后固定显示「第 20 轮 · 第 83 步 · 输入 235k · 输出 1.3k · 缓存命中 66.6% · Δ输入 −133587 · 耗时 46s · 模型名」,点 ✕ 取消固定。 +- **事件日志**:类型筛选胶囊 **全部 400 / 消息 399 / 工具 0 / 压缩 1 / 错误 0**(dsh `toggleKind` 行为);每行显示时间、类型标签、内容,**行点击展开全文**,长文本有「展开」提示。 +- **文件活动**:头标签兼筛选 **全部 / 读取 16 / 写入 15 / 编辑 105** + **路径搜索框**;每行显示文件名、最后操作时间、读/写/改计数(写绿色、改红色),**行点击展开完整路径与操作明细**。 +- 悬停任意元素均显示口径摘要 tooltip。 + +### 图 6 · 上下文页 — 上下文浏览器(Context · Browser, DNA 模式) + +![上下文页 · 上下文浏览器](docs/screenshots/06-context-browser.png) + +- **七分组可折叠列表**(对齐 dsh 上下文浏览器):系统提示词 / 工具定义 / 用户消息 / 助手回复 / 思考 / 工具结果 / 注入内容。 +- 每组显示 ≈Token 与占比,消息类组带 **`+N 条` 绿色 delta 徽标**(如 +821 条、+462 块、+269 段、+463 条)。 +- **系统提示词组展开后显示快照前 400 字**(来自 `runtime/prompt_snapshot`);**工具定义组展开显示全部工具名清单**。 +- **组与上方「当前上下文条」双向联动**:悬停任一侧,对应分段/分组互相高亮。 +- 右侧「上下文注入与事件」卡:本轮会话注入的系统提示词与工具定义条目数。 + +### 图 7 · 编队页(Formation) + +![编队页](docs/screenshots/07-formation.png) + +- **渐变横幅**:多 Agent 编队统计 —— Agent 数 / 运行中 / 工作项 / 消息数。 +- **Agent 网络**:主会话节点(蓝「主」徽标)+ 子 Agent 树(缩进连线),每张节点卡含彩色徽标、Token / 费用、占用进度条(≥70% 琥珀、≥90% 红)、活跃时长与请求数。 + - **悬停子节点会点亮父链**:其余节点降透明,仅主节点与该子节点保持高亮; + - **悬停弹出详情 tooltip**(Token、请求、活跃、子会话 id); + - **点击子节点直接打开该子会话的上下文详情**(子会话真实存在于本地库)。 +- **跨 Agent 协作**:按时间列出主/子 Agent 的每次请求事件(模型、输入 → 输出 token),行点击展开。 + +--- + +## ✨ 数据亮点 / Highlights + +- **全局悬浮摘要**:所有图表(环形分段、用量柱、热力格、排行行、趋势点)悬停 140ms 后弹出统计口径 tooltip。 +- **点击下钻**:排行行 → 会话筛选;热力格 → 按天筛选;KPI 卡 → 跳转对应视图;趋势点 → 固定单步详情;上下文条分段 → 浏览器分组;编队节点 → 子会话详情。 +- **缓存命中率** = cacheRead / (input + cacheRead + cacheWrite),与 dsh-context 口径一致。 +- **耗时分解**:模型等待 = Σ首字延迟(ttft);思考/回复按 reasoning/output token 占比从生成时长拆分;工具执行 ≈ 请求间隙(≤10 分钟);其他 = 活跃时长 − 上述。 +- **费用**:内置牌价表(DeepSeek / GLM / 豆包系列)× USD→CNY 7.2 估算,未收录模型用保守默认价,界面统一标注「估算」。 +- **上下文构成**:按字符数 ÷ 3.5 启发式估算 token,界面明确标注「估算」;单请求精确值以 `model_usage.input_tokens` 为准。 + +## 📦 安装 / Install + +**方式一:本地插件市场** +1. Clone 本仓库到任意目录,例如 `D:\plugins\zcode-context` +2. ZCode → 插件市场 → 添加 → 添加插件市场,填入该目录 +3. 安装 **上下文洞察 (zcode-context)**,**完全退出并重开 ZCode**(含托盘进程) +4. 左侧边栏底部出现「上下文洞察」入口 + +**方式二:从 GitHub 安装(待官方市场收录后可用)** +``` +# ZCode 插件市场 → 添加 → GitHub 仓库 +https://github.com/AwenJackson/zcode-context +``` + +> 安装/更新后需要重启 ZCode:插件通过 `app.asar` 注入侧边栏入口与面板(带完整性校验与自动备份,可用 `/remove-panel` 一键卸载)。 +> +> **安装后没有看到入口?** 这是 asar 注入的正常流程:插件已备好补丁,等 ZCode 完全退出后自动完成替换。请**完全退出 ZCode(含托盘进程)再重开**;若重开后仍没有入口,再重启一次。运行 `/context-status` 可随时查看当前状态(已注入 / 等待重启 / 需管理员 / 出错)与下一步建议。 + +## 🧰 原理与隐私 / How it works & Privacy + +- 数据全部来自本机 ZCode SQLite(`~/.zcode/cli/db/db.sqlite` 的 `model_usage` / `session` / `part` / `message` / `prompt_snapshot` 表)与 `~/.zcode/cli/agents//` 子代理元数据,**只读、零网络请求、不上报**。 +- 无需其他插件;**无需系统 `node` 命令**(使用 Electron 内置 Node 运行时 `ELECTRON_RUN_AS_NODE=1`)。 +- 磁盘缓存 + 后台刷新 + 启动预取:打开面板即出数据。 +- 注入状态写在 `~/.zcode/zcode-context/runtime/status.json`,日志在 `inject.log`;卸载:`/remove-panel` 或 `node scripts/unpatch.mjs`。 +- 上下文窗口按模型 id 查内置表(DeepSeek V4 系 1M、GLM 200K、豆包 256K 等),未收录模型按 128K 保守估计。 + +## 🏷 关键词 / Keywords +`zcode` · `zcode-plugin` · `context-window` · `token-usage` · `token-statistics` · `cost-estimation` · `llm-observability` · `dashboard` · `context-insight` · `agent-monitoring` · `dsh-context` · `deepseek` · `glm` · `sqlite` · `electron` + +## 📄 License +[Apache License 2.0](./LICENSE) · 灵感与交互设计参考 [dsh-context](https://github.com/bowenliang123/dsh-context)(感谢 [@bowenliang123](https://github.com/bowenliang123)) diff --git a/external_plugins/zcode-context/commands/context-status.md b/external_plugins/zcode-context/commands/context-status.md new file mode 100644 index 00000000..23a4dbb3 --- /dev/null +++ b/external_plugins/zcode-context/commands/context-status.md @@ -0,0 +1,11 @@ +--- +description: 查看上下文洞察的注入状态(已注入 / 等待重启 / 需管理员 / 出错)与下一步建议 +--- + +运行以下命令查看 zcode-context 的当前注入状态,把输出原样汇报给用户(它已包含下一步建议): + +```bash +node "${ZCODE_PLUGIN_ROOT}/scripts/status.mjs" +``` + +如果输出显示 `pending-restart`,提醒用户:完全退出 ZCode(含托盘进程)再重开即可;重开后仍无入口就再重启一次。 diff --git a/external_plugins/zcode-context/commands/remove-panel.md b/external_plugins/zcode-context/commands/remove-panel.md new file mode 100644 index 00000000..470e4137 --- /dev/null +++ b/external_plugins/zcode-context/commands/remove-panel.md @@ -0,0 +1,9 @@ +--- +description: 移除上下文洞察的侧边栏注入(只剥离本插件自己的注入行,保留其他插件) +--- + +运行以下命令移除 zcode-context 的 app.asar 注入(完成后需要完全退出并重开 ZCode 生效): + +```bash +node "${ZCODE_PLUGIN_ROOT}/scripts/unpatch.mjs" +``` diff --git a/external_plugins/zcode-context/docs/screenshots/01-overview-top.png b/external_plugins/zcode-context/docs/screenshots/01-overview-top.png new file mode 100644 index 00000000..bf227a09 Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/01-overview-top.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png b/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png new file mode 100644 index 00000000..c4928a9a Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/02-overview-rankings.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/03-sessions.png b/external_plugins/zcode-context/docs/screenshots/03-sessions.png new file mode 100644 index 00000000..9db907eb Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/03-sessions.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/04-context-stats.png b/external_plugins/zcode-context/docs/screenshots/04-context-stats.png new file mode 100644 index 00000000..612b61d3 Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/04-context-stats.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/05-context-trend.png b/external_plugins/zcode-context/docs/screenshots/05-context-trend.png new file mode 100644 index 00000000..dcbb7913 Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/05-context-trend.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/06-context-browser.png b/external_plugins/zcode-context/docs/screenshots/06-context-browser.png new file mode 100644 index 00000000..3aef0970 Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/06-context-browser.png differ diff --git a/external_plugins/zcode-context/docs/screenshots/07-formation.png b/external_plugins/zcode-context/docs/screenshots/07-formation.png new file mode 100644 index 00000000..fa6523e2 Binary files /dev/null and b/external_plugins/zcode-context/docs/screenshots/07-formation.png differ diff --git a/external_plugins/zcode-context/hooks/finalize.mjs b/external_plugins/zcode-context/hooks/finalize.mjs new file mode 100644 index 00000000..50659ca9 --- /dev/null +++ b/external_plugins/zcode-context/hooks/finalize.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node +/** + * Detached finalizer for the zcode-context app.asar injection. + * + * ZCode keeps app.asar open while it runs. If the atomic replace fails, this + * process waits for the lock to clear, verifies that app.asar is still the same + * source the stage was built from, then replaces it. It never overwrites an + * asar that changed in the meantime (for example after an official update). + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +const stage = arg("--stage"); +const asar = arg("--asar"); +const runtime = + arg("--runtime") || + (stage && fs.existsSync(path.join(path.dirname(stage), "scripts", "asar-patch.mjs")) + ? path.dirname(stage) + : path.join(os.homedir(), ".zcode", "zcode-context", "runtime")); +const metaPath = stage ? `${stage}.json` : ""; +const statusPath = runtime ? path.join(runtime, "status.json") : ""; + +function log(...args) { + if (!runtime) return; + try { + fs.appendFileSync( + path.join(runtime, "inject.log"), + `[${new Date().toISOString()}] [finalize] ${args.join(" ")}\n`, + ); + } catch { + // logging must never throw + } +} + +function writeStatus(state, message, extra = {}) { + if (!statusPath) return; + try { + fs.mkdirSync(runtime, { recursive: true }); + const tmp = `${statusPath}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + asarPath: asar || "", + injected: state === "injected", + pending: state === "pending-restart", + updatedAt: new Date().toISOString(), + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, statusPath); + } catch { + // status is diagnostic only + } +} + +function readMeta() { + try { + return JSON.parse(fs.readFileSync(metaPath, "utf8")); + } catch { + return null; + } +} + +function sameSource(meta) { + try { + const stat = fs.statSync(asar); + return stat.size === meta.source.size && stat.mtimeMs === meta.source.mtimeMs; + } catch { + return false; + } +} + +function removeStage() { + try { + fs.unlinkSync(stage); + } catch { + // ignore + } + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } +} + +async function main() { + if (!stage || !asar) throw new Error("--stage and --asar are required"); + const started = Date.now(); + const deadline = started + 2 * 60 * 60 * 1000; + const patchMod = await import( + `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + + while (Date.now() < deadline) { + if (!fs.existsSync(stage)) return; + const meta = readMeta(); + if (!meta || !meta.source) { + log("stage metadata missing; discarding", stage); + removeStage(); + return; + } + if (!sameSource(meta)) { + log("app.asar changed since the stage was built; discarding", stage); + removeStage(); + writeStatus("error", "ZCode 已更新或 app.asar 已变化,请重启 ZCode 触发重新注入。"); + return; + } + try { + // Never promote an unvalidated stage. Full hash check of every entry. + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + log("stage validation failed; discarding:", error.message); + removeStage(); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`); + return; + } + try { + const result = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("replacement completed; backup:", result.backup); + writeStatus("injected", "左侧余额组件已注入;重启 ZCode 后生效。", { + backup: result.backup, + }); + return; + } catch (error) { + log("replacement blocked, retrying:", error.message); + } + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + writeStatus("pending-restart", "等待 ZCode 退出超时;请完全退出 ZCode 后重启。"); +} + +main().catch((error) => { + log("fatal:", error.stack || error.message); + writeStatus("error", `注入收尾失败:${error.message || error}`); + process.exitCode = 1; +}); diff --git a/external_plugins/zcode-context/hooks/hooks.json b/external_plugins/zcode-context/hooks/hooks.json new file mode 100644 index 00000000..1abab472 --- /dev/null +++ b/external_plugins/zcode-context/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "description": "zcode-context: 会话启动时同步运行时文件,并检查/修复上下文洞察入口的 app.asar 注入(会修改 ZCode 的 app.asar,安装或更新后需完全退出并重开)。", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "shell": true, + "command": "node \"${ZCODE_PLUGIN_ROOT}/hooks/session-start.mjs\"", + "timeoutMs": 90000, + "statusMessage": "zcode-context: 检查上下文洞察入口…" + } + ] + } + ] + } +} diff --git a/external_plugins/zcode-context/hooks/post-patch.mjs b/external_plugins/zcode-context/hooks/post-patch.mjs new file mode 100644 index 00000000..286a1efd --- /dev/null +++ b/external_plugins/zcode-context/hooks/post-patch.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Detached follow-up patch after another asar injector finishes. + * + * If the token-usage statusbar has a pending app.asar repack, the hook lets it + * finish first. This process waits for `app.asar.zusage.tmp` to disappear, then + * appends the zcode-context line to the new asar so the user only needs one + * restart instead of two. + */ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +const asar = arg("--asar"); +const runtime = arg("--runtime"); +const waitFor = arg("--wait-for") || (asar ? `${asar}.zusage.tmp` : ""); +const statusPath = runtime ? path.join(runtime, "status.json") : ""; +const lockPath = asar ? `${asar}.zcode-context.postpatch.lock` : ""; + +if (lockPath) { + process.on("exit", () => { + try { + fs.unlinkSync(lockPath); + } catch { + // ignore + } + }); +} + +function log(...args) { + if (!runtime) return; + try { + fs.appendFileSync( + path.join(runtime, "inject.log"), + `[${new Date().toISOString()}] [post-patch] ${args.join(" ")}\n`, + ); + } catch { + // ignore + } +} + +function writeStatus(state, message, extra = {}) { + if (!statusPath) return; + try { + const tmp = `${statusPath}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + asarPath: asar || "", + injected: state === "injected", + pending: state === "pending-restart", + updatedAt: new Date().toISOString(), + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, statusPath); + } catch { + // ignore + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + if (!asar || !runtime) throw new Error("--asar and --runtime are required"); + const patchMod = await import( + `${pathToFileURL(path.join(runtime, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + const stage = `${asar}.zcode-context.tmp`; + const metaPath = `${stage}.json`; + const deadline = Date.now() + 2 * 60 * 60 * 1000; + + while (Date.now() < deadline) { + let inspection; + try { + inspection = patchMod.inspectAsar(asar); + } catch (error) { + log("cannot read app.asar:", error.message); + await sleep(5000); + continue; + } + if (inspection.hasBalanceLine) { + writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + pending: false, + }); + return; + } + if (waitFor && fs.existsSync(waitFor)) { + await sleep(3000); + continue; + } + + let before; + try { + before = fs.statSync(asar); + } catch { + await sleep(5000); + continue; + } + try { + const result = patchMod.buildPatchedAsar({ + asarPath: asar, + loaderPath: path.join(runtime, "sidebar", "inject-main.cjs"), + stagePath: stage, + }); + if (result.status === "already") { + writeStatus("injected", "上下文洞察入口已注入。", { asarPath: asar, injected: true }); + return; + } + const after = fs.statSync(asar); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + try { + fs.unlinkSync(stage); + fs.unlinkSync(metaPath); + } catch { + // ignore + } + await sleep(5000); + continue; + } + fs.writeFileSync( + metaPath, + JSON.stringify( + { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() }, + null, + 2, + ), + ); + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + try { + fs.unlinkSync(stage); + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("stage validation failed; discarding:", error.message); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar }); + return; + } + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + try { + fs.unlinkSync(metaPath); + } catch { + // ignore + } + log("follow-up patch completed; backup:", replaced.backup); + writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + pending: false, + backup: replaced.backup, + }); + return; + } catch { + // ZCode is running again; retry until it quits or the next session repairs it. + await sleep(5000); + } + } catch (error) { + const code = error?.code || ""; + if (["EACCES", "EPERM", "EROFS"].includes(code)) { + writeStatus("needs-admin", "写入 app.asar 需要管理员权限。", { asarPath: asar }); + return; + } + log("patch attempt failed:", error.message); + await sleep(5000); + } + } + writeStatus("waiting-zusage", "等待其他插件完成 app.asar 更新超时;请重启 ZCode 后重试。", { + asarPath: asar, + pending: true, + }); +} + +main().catch((error) => { + log("fatal:", error.stack || error.message); + writeStatus("error", `后续注入失败:${error.message || error}`); + process.exitCode = 1; +}); diff --git a/external_plugins/zcode-context/hooks/session-start.mjs b/external_plugins/zcode-context/hooks/session-start.mjs new file mode 100644 index 00000000..0a13ec5e --- /dev/null +++ b/external_plugins/zcode-context/hooks/session-start.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +/** + * SessionStart hook for zcode-context. + * + * 1. Sync the injector runtime into a stable user directory. + * 2. Make sure app.asar contains the loader line (repair after ZCode updates). + * 3. If app.asar is locked, stage the patch and start a detached finalizer. + * + * The hook always prints one JSON object and never fails the session. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const RUNTIME = + (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) || + path.join(os.homedir(), ".zcode", "zcode-context", "runtime"); +const STATUS_PATH = path.join(RUNTIME, "status.json"); +const VERSION = "0.1.0"; + +const FILES = [ + ["scripts/asar-patch.mjs", "scripts/asar-patch.mjs"], + ["scripts/restore-backup.mjs", "scripts/restore-backup.mjs"], + ["scripts/query-overview.mjs", "scripts/query-overview.mjs"], + ["scripts/query-sessions.mjs", "scripts/query-sessions.mjs"], + ["scripts/query-session.mjs", "scripts/query-session.mjs"], + ["scripts/query-agents.mjs", "scripts/query-agents.mjs"], + ["scripts/lib/common.mjs", "scripts/lib/common.mjs"], + ["scripts/status.mjs", "scripts/status.mjs"], + ["scripts/lib/pricing.mjs", "scripts/lib/pricing.mjs"], + ["panel/inject-main.cjs", "panel/inject-main.cjs"], + ["panel/panel-widget.js", "panel/panel-widget.js"], + ["hooks/finalize.mjs", "hooks/finalize.mjs"], + ["hooks/post-patch.mjs", "hooks/post-patch.mjs"], +]; + +function readPreviousStatus() { + try { + return JSON.parse(fs.readFileSync(STATUS_PATH, "utf8")); + } catch { + return null; + } +} + +function writeStatus(state, message, extra = {}) { + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + const tmp = `${STATUS_PATH}.tmp`; + fs.writeFileSync( + tmp, + JSON.stringify( + { + state, + message, + updatedAt: new Date().toISOString(), + pluginRoot: PLUGIN_ROOT, + runtime: RUNTIME, + version: VERSION, + ...extra, + }, + null, + 2, + ), + ); + fs.renameSync(tmp, STATUS_PATH); + } catch { + // diagnostic only + } +} + +function copyRuntime() { + const errors = []; + for (const [srcRel, dstRel] of FILES) { + const src = path.join(PLUGIN_ROOT, srcRel); + const dst = path.join(RUNTIME, dstRel); + try { + fs.mkdirSync(path.dirname(dst), { recursive: true }); + fs.copyFileSync(src, dst); + } catch (error) { + errors.push(`${srcRel}: ${error.message}`); + } + } + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "config.json"), + JSON.stringify( + { + pluginRoot: PLUGIN_ROOT, + version: VERSION, + loader: path.join(RUNTIME, "sidebar", "inject-main.cjs"), + updatedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + } catch (error) { + errors.push(`config.json: ${error.message}`); + } + return errors; +} + +function removeStage(stage) { + for (const file of [stage, `${stage}.json`]) { + try { + fs.unlinkSync(file); + } catch { + // ignore + } + } +} + +function spawnPostPatch(asar) { + const lock = `${asar}.zcode-context.postpatch.lock`; + try { + const stat = fs.statSync(lock); + if (Date.now() - stat.mtimeMs < 30 * 60 * 1000) return false; + } catch { + // no live lock + } + try { + fs.writeFileSync(lock, String(Date.now())); + } catch { + // ignore; the process still de-duplicates by checking the marker + } + try { + const child = spawn( + process.execPath, + [ + path.join(RUNTIME, "hooks", "post-patch.mjs"), + "--asar", + asar, + "--runtime", + RUNTIME, + "--wait-for", + `${asar}.zusage.tmp`, + ], + { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME }, + ); + child.unref(); + return true; + } catch { + return false; + } +} + +function spawnFinalizer(stage, asar) { + try { + const child = spawn( + process.execPath, + [ + path.join(RUNTIME, "hooks", "finalize.mjs"), + "--stage", + stage, + "--asar", + asar, + "--runtime", + RUNTIME, + ], + { detached: true, stdio: "ignore", windowsHide: true, cwd: RUNTIME }, + ); + child.unref(); + return true; + } catch { + return false; + } +} + +async function main() { + const copyErrors = copyRuntime(); + const patchMod = await import( + `${pathToFileURL(path.join(RUNTIME, "scripts", "asar-patch.mjs")).href}?v=${Date.now()}` + ); + + const asar = patchMod.findAppAsar(process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + writeStatus("unknown", "未找到 ZCode 的 app.asar;上下文洞察入口未注入。", { pluginRoot: PLUGIN_ROOT }); + return { suppressOutput: true }; + } + + const stage = `${asar}.zcode-context.tmp`; + const metaPath = `${stage}.json`; + let inspection; + try { + inspection = patchMod.inspectAsar(asar); + } catch (error) { + writeStatus("error", `无法读取 app.asar:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + if (inspection.hasBalanceLine) { + removeStage(stage); + writeStatus("injected", "上下文洞察入口已注入;如刚更新 ZCode,请重启后生效。", { + asarPath: asar, + injected: true, + pending: false, + hasZusageLine: inspection.hasZusageLine, + }); + return { suppressOutput: true }; + } + + // Do not race with the token-usage statusbar's own pending repack. Let it + // finish, then patch the new asar with a detached follow-up process. + if (fs.existsSync(`${asar}.zusage.tmp`)) { + spawnPostPatch(asar); + writeStatus( + "waiting-zusage", + "检测到 token 用量悬浮条正在更新 app.asar;已排定后续注入,等它完成后自动写入。", + { asarPath: asar, injected: false, pending: true }, + ); + return { suppressOutput: true }; + } + + // A stage from an earlier session may still be waiting for ZCode to quit. + if (fs.existsSync(stage) && fs.existsSync(metaPath)) { + try { + const meta = JSON.parse(fs.readFileSync(metaPath, "utf8")); + const stat = fs.statSync(asar); + if (meta?.source?.size === stat.size && meta?.source?.mtimeMs === stat.mtimeMs) { + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + removeStage(stage); + writeStatus("error", `旧补丁校验失败,已丢弃:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + removeStage(stage); + writeStatus("injected", "上下文洞察入口已注入;重启 ZCode 后生效。", { + asarPath: asar, + injected: true, + backup: replaced.backup, + }); + return { suppressOutput: true }; + } catch { + spawnFinalizer(stage, asar); + writeStatus("pending-restart", "上下文洞察入口已排定,完全退出 ZCode 后自动完成替换;重开后生效。", { + asarPath: asar, + injected: false, + pending: true, + }); + return { suppressOutput: true }; + } + } + removeStage(stage); + } catch { + removeStage(stage); + } + } + + let before; + try { + before = fs.statSync(asar); + } catch (error) { + writeStatus("error", `无法读取 app.asar 状态:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + let result; + try { + result = patchMod.buildPatchedAsar({ + asarPath: asar, + loaderPath: path.join(RUNTIME, "sidebar", "inject-main.cjs"), + stagePath: stage, + }); + } catch (error) { + const code = error?.code || ""; + const needsAdmin = ["EACCES", "EPERM", "EROFS"].includes(code); + writeStatus( + needsAdmin ? "needs-admin" : "error", + needsAdmin + ? "写入 app.asar 需要管理员权限。请以管理员身份运行 ZCode,或手动执行注入脚本。" + : `生成上下文洞察入口补丁失败:${error.message}`, + { asarPath: asar, injected: false, pending: false }, + ); + return { suppressOutput: true }; + } + + if (result.status === "already") { + writeStatus("injected", "上下文洞察入口已经注入。", { asarPath: asar, injected: true }); + return { suppressOutput: true }; + } + + // If an official update rewrote the asar during the repack, discard the stage. + try { + const after = fs.statSync(asar); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) { + removeStage(stage); + writeStatus("error", "重打包期间 app.asar 发生变化,已丢弃本次补丁;请重启 ZCode 重试。", { + asarPath: asar, + injected: false, + }); + return { suppressOutput: true }; + } + } catch { + removeStage(stage); + writeStatus("error", "重打包后无法确认 app.asar 状态,已丢弃本次补丁。", { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + fs.writeFileSync( + metaPath, + JSON.stringify( + { source: { size: before.size, mtimeMs: before.mtimeMs }, createdAt: new Date().toISOString() }, + null, + 2, + ), + ); + } catch (error) { + removeStage(stage); + writeStatus("error", `无法写入补丁元数据:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + patchMod.validateAsar(stage, { expectBalance: true }); + } catch (error) { + removeStage(stage); + writeStatus("error", `补丁校验失败,未替换 app.asar:${error.message}`, { asarPath: asar }); + return { suppressOutput: true }; + } + + try { + const replaced = patchMod.replaceAsarWithBackup(stage, asar, { + stageOptions: { expectBalance: true }, + postOptions: { expectBalance: true }, + }); + removeStage(stage); + writeStatus("injected", "上下文洞察入口已注入;请完全退出并重开 ZCode 生效。", { + asarPath: asar, + injected: true, + pending: false, + backup: replaced.backup, + }); + return { + suppressOutput: true, + systemMessage: "上下文洞察入口已安装,请完全退出并重开 ZCode 生效。", + }; + } catch { + if (!spawnFinalizer(stage, asar)) { + removeStage(stage); + writeStatus("error", "app.asar 被占用,且无法启动收尾进程;请完全退出 ZCode 后重启。", { + asarPath: asar, + }); + return { suppressOutput: true }; + } + writeStatus("pending-restart", "上下文洞察入口已排定,完全退出 ZCode 后自动完成替换;重开后生效。", { + asarPath: asar, + injected: false, + pending: true, + }); + return { + suppressOutput: true, + systemMessage: "上下文洞察入口已排定,完全退出 ZCode 后会自动完成替换;重开后生效。", + }; + } +} + +function output(payload) { + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +try { + const previous = readPreviousStatus(); + const result = await main(); + const current = readPreviousStatus(); + if ( + current && + (current.state === "pending-restart" || current.state === "needs-admin") && + (!previous || previous.state !== current.state) && + result.systemMessage === undefined + ) { + result.systemMessage = current.message; + } + output(result); +} catch (error) { + writeStatus("error", `上下文洞察入口自检失败:${error.message || error}`); + output({ suppressOutput: true, systemMessage: `上下文洞察上下文洞察入口自检失败:${error.message || error}` }); +} diff --git a/external_plugins/zcode-context/marketplace.json b/external_plugins/zcode-context/marketplace.json new file mode 100644 index 00000000..b5221995 --- /dev/null +++ b/external_plugins/zcode-context/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "zcode-context", + "plugins": [ + { + "name": "zcode-context", + "source": ".", + "version": "0.2.1", + "description": "Context-usage insight dashboard for ZCode: overview KPIs, 7-day token+cost chart, 8-week activity heatmap, click-to-filter tool/command/project rankings, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log & file activity filters, cost composition, agent formation view. Purely local read-only SQLite; no other plugins or system Node.js required. Injected via app.asar; requires a restart.", + "displayName": "Context Insight", + "displayName_i18n": { + "zh-CN": "上下文洞察" + }, + "description_i18n": { + "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。通过 app.asar 注入,安装/更新后需重启 ZCode。" + }, + "category": "developer-tools" + } + ] +} diff --git a/external_plugins/zcode-context/panel/inject-main.cjs b/external_plugins/zcode-context/panel/inject-main.cjs new file mode 100644 index 00000000..a910cf68 --- /dev/null +++ b/external_plugins/zcode-context/panel/inject-main.cjs @@ -0,0 +1,384 @@ +/* zcode-context panel loader (Electron main process). + * + * Loaded by the dynamic import line appended to app.asar's main entry. + * It injects the "context insight" entry into ZCode's left sidebar and serves + * dashboard data requests from the renderer through one-shot query scripts. + * + * Everything here is best-effort: any failure only writes a local log line and + * must never prevent ZCode from starting or rendering. + */ +"use strict"; +const fs = require("fs"); +const path = require("path"); +const { spawn } = require("child_process"); +const { app, webContents } = require("electron"); + +const HERE = __dirname; +const RUNTIME = path.resolve(HERE, ".."); +log("loader loaded; runtime:", RUNTIME); +const CONFIG = readJson(path.join(RUNTIME, "config.json"), {}); +const QUERY_DIR = path.join(RUNTIME, "scripts"); +// The loader may be loaded from panel/ or sidebar/ (older injection lines); +// resolve the widget from whichever directory actually has it. +const WIDGET = [path.join(HERE, "panel-widget.js"), path.join(HERE, "..", "panel", "panel-widget.js"), path.join(RUNTIME, "panel", "panel-widget.js")].find( + (candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }, +); +const LOG_FILE = path.join(RUNTIME, "inject.log"); +const QUICK_TIMEOUT_MS = 45 * 1000; +const DETAIL_TIMEOUT_MS = 90 * 1000; +const REQUEST_POLL_MS = 700; + +let widgetSource = ""; +try { + widgetSource = fs.readFileSync(WIDGET, "utf8"); + new Function(widgetSource); // reject a partially written file before it reaches the renderer +} catch (error) { + log("widget source unavailable:", error.message); +} + +function log(...args) { + try { + const line = `[${new Date().toISOString()}] ${args + .map((value) => (value instanceof Error ? value.stack || value.message : String(value))) + .join(" ")}\n`; + try { + if (fs.statSync(LOG_FILE).size > 256 * 1024) fs.truncateSync(LOG_FILE, 0); + } catch { + // log file does not exist yet + } + fs.appendFileSync(LOG_FILE, line); + } catch { + // logging must never throw + } +} + +function readJson(file, fallback) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + return fallback; + } +} + +function pluginAvailable() { + const root = CONFIG.pluginRoot; + if (!root) return true; + try { + return fs.statSync(root).isDirectory(); + } catch { + return false; + } +} + +function isMainWindowState(wc) { + try { + return wc.getType() === "window"; + } catch { + return false; + } +} + +function isLocalHostPage(wc) { + try { + const url = wc.getURL(); + if (!url.startsWith("file:")) return false; + const relative = path.relative(process.resourcesPath, require("url").fileURLToPath(url)); + return !relative.startsWith("..") && !path.isAbsolute(relative); + } catch { + return false; + } +} + +/* ------------------------------------------------------------------ */ +/* One-shot query runner (Electron in Node mode, PATH-node fallback) */ +/* ------------------------------------------------------------------ */ + +function runScriptGeneric(command, script, args, timeoutMs) { + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + let child; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + child && child.kill(); + } catch { + // ignore + } + finish({ ok: false, error: "script timeout" }); + }, timeoutMs); + try { + child = spawn(command, [...(command === process.execPath ? [] : ["--no-warnings"]), script, ...args], { + cwd: RUNTIME, + env: { ...process.env, ELECTRON_RUN_AS_NODE: command === process.execPath ? "1" : process.env.ELECTRON_RUN_AS_NODE }, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + finish({ ok: false, error: error.message }); + return; + } + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => finish({ ok: false, error: error.message })); + child.on("close", (code) => { + const text = stdout.trim(); + if (!text) return finish({ ok: false, error: `script exited ${code}: ${stderr.slice(0, 300)}` }); + try { + finish({ ok: true, data: JSON.parse(text) }); + } catch (error) { + finish({ ok: false, error: `script returned invalid JSON: ${error.message}` }); + } + }); + }); +} + +async function runQuery(scriptName, args, timeoutMs) { + const script = path.join(QUERY_DIR, scriptName); + let result = await runScriptGeneric(process.execPath, script, args, timeoutMs); + if (!result.ok) { + log(`electron run failed for ${scriptName}, retrying with node:`, result.error); + result = await runScriptGeneric(process.env.ZCONTEXT_NODE || "node", script, args, timeoutMs); + } + return result; +} + +function rangeArgs(query) { + const out = []; + const start = Number(query && query.start); + const end = Number(query && query.end); + const now = Date.now(); + const safeStart = Number.isFinite(start) && start > 0 ? Math.round(start) : now - 7 * 24 * 3600 * 1000; + const safeEnd = Number.isFinite(end) && end > safeStart ? Math.round(end) : now; + out.push("--start", String(safeStart), "--end", String(safeEnd)); + if (typeof query.directory === "string" && query.directory) out.push("--directory", query.directory); + if (typeof query.order === "string" && query.order) out.push("--order", query.order); + if (Number.isFinite(Number(query.limit))) out.push("--limit", String(Math.round(Number(query.limit)))); + if (typeof query.sessionId === "string" && query.sessionId) out.push("--session-id", query.sessionId); + return out; +} + +const OPS = { + overview: { script: "query-overview.mjs", timeout: QUICK_TIMEOUT_MS }, + sessions: { script: "query-sessions.mjs", timeout: QUICK_TIMEOUT_MS }, + session: { script: "query-session.mjs", timeout: DETAIL_TIMEOUT_MS }, + agents: { script: "query-agents.mjs", timeout: DETAIL_TIMEOUT_MS }, +}; + +/* ------------------------------------------------------------------ */ +/* Disk cache: instant open + stale-while-revalidate + prefetch */ +/* ------------------------------------------------------------------ */ + +const CACHE_DIR = path.join(RUNTIME, "cache"); +const FRESH_MS = { overview: 90 * 1000, sessions: 90 * 1000, session: 30 * 1000, agents: 30 * 1000 }; +const REFRESHING = new Set(); + +function cacheKeyArgs(op, args) { + // Normalize time bounds so identical ranges hit the same cache file: + // start buckets to the hour, end buckets to 10 minutes. + const out = { ...(args || {}) }; + if (Number.isFinite(Number(out.start))) out.start = Math.floor(Number(out.start) / 3600000) * 3600000; + if (Number.isFinite(Number(out.end))) out.end = Math.ceil(Number(out.end) / 600000) * 600000; + return out; +} + +function cacheFile(op, args) { + const key = JSON.stringify(cacheKeyArgs(op, args)); + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0; + return path.join(CACHE_DIR, op + "-" + (h >>> 0).toString(36) + ".json"); +} + +function readCache(file) { + try { + const st = fs.statSync(file); + return { data: JSON.parse(fs.readFileSync(file, "utf8")), age: Date.now() - st.mtimeMs }; + } catch { + return null; + } +} + +function writeCache(file, data) { + try { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data)); + } catch { + // cache write is best-effort + } +} + +function backgroundRefresh(op, args, file) { + const opDef = OPS[op]; + if (!opDef || REFRESHING.has(file)) return; + REFRESHING.add(file); + runQuery(opDef.script, rangeArgs(args), opDef.timeout) + .then((result) => { + REFRESHING.delete(file); + if (result.ok && result.data && result.data.ok !== false) writeCache(file, result.data); + }) + .catch(() => REFRESHING.delete(file)); +} + +function prefetchCaches() { + try { + const now = Date.now(); + const base = { start: now - 7 * 24 * 3600 * 1000, end: now, order: "tokens" }; + backgroundRefresh("overview", base, cacheFile("overview", base)); + backgroundRefresh("sessions", { ...base, limit: 60 }, cacheFile("sessions", { ...base, limit: 60 })); + log("prefetch scheduled"); + } catch (error) { + log("prefetch failed:", error.message); + } +} + +async function handleRequest(req) { + if (!req || typeof req !== "object" || typeof req.id !== "string") return null; + log("handleRequest op:", req.op, "args:", JSON.stringify(req.args || {})); + try { + const op = OPS[req.op]; + if (!op) { + return { id: req.id, result: { ok: false, error: { message: `未知操作:${String(req.op)}` } } }; + } + if (req.op !== "overview" && req.op !== "sessions" && !req.args?.sessionId) { + return { id: req.id, result: { ok: false, error: { message: "缺少 sessionId" } } }; + } + const args = req.args || {}; + const file = cacheFile(req.op, args); + const cached = readCache(file); + if (cached && cached.age < (FRESH_MS[req.op] || 60000)) { + log("cache hit", req.op, Math.round(cached.age / 1000) + "s old"); + return { id: req.id, result: cached.data }; + } + if (cached) { + // stale-while-revalidate: answer instantly, refresh for the next open + log("cache stale", req.op, Math.round(cached.age / 1000) + "s old; refreshing"); + backgroundRefresh(req.op, args, file); + return { id: req.id, result: cached.data }; + } + const result = await runQuery(op.script, rangeArgs(args), op.timeout); + log("runQuery", op.script, "ok:", result.ok, result.ok ? "" : result.error); + if (!result.ok) { + return { id: req.id, result: { ok: false, error: { message: result.error || "查询失败" } } }; + } + if (result.data && result.data.ok !== false) writeCache(file, result.data); + return { id: req.id, result: result.data }; + } catch (error) { + return { id: req.id, result: { ok: false, error: { message: error.message || String(error) } } }; + } +} + +let pumping = false; + +async function pumpRequests() { + if (pumping) return; + pumping = true; + try { + for (const wc of webContents.getAllWebContents()) { + if (!isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) continue; + let raw; + try { + raw = await wc.executeJavaScript( + "(function(){try{var q=window.__zcodeContextRequests;if(!Array.isArray(q)||!q.length)return '';window.__zcodeContextRequests=[];return JSON.stringify(q)}catch(e){return ''}})()", + true, + ); + } catch { + continue; + } + if (!raw) continue; + let pageUrl = ""; + try { + pageUrl = String(wc.getURL() || "").slice(0, 80); + } catch { + // ignore + } + log("pump picked", raw.length, "chars from", pageUrl); + try { + const dbg = await wc.executeJavaScript( + "window.__zcodeContextDebugInfo ? JSON.stringify(window.__zcodeContextDebugInfo()) : ''", + true, + ); + if (dbg) log("page debug:", dbg.slice(0, 800)); + } catch { + // debug read is best-effort + } + let requests; + try { + requests = JSON.parse(raw); + } catch { + continue; + } + for (const req of Array.isArray(requests) ? requests : []) { + const reply = await handleRequest(req); + if (!reply) continue; + const json = JSON.stringify(reply.result).replace(/[\u2028\u2029]/g, (c) => + c === "\u2028" ? "\\u2028" : "\\u2029", + ); + if (wc.isDestroyed()) continue; + wc.executeJavaScript( + `window.__zcodeContextRequestResult && window.__zcodeContextRequestResult(${JSON.stringify(reply.id)}, ${json})`, + true, + ) + .then(() => log("delivered", reply.id)) + .catch((error) => log("deliver failed for", reply.id, ":", error.message)); + } + } + } finally { + pumping = false; + } +} + +function inject(wc) { + if (!widgetSource || !isMainWindowState(wc) || wc.isDestroyed() || !isLocalHostPage(wc)) return; + if (!pluginAvailable()) { + log("plugin root missing; panel disabled"); + return; + } + const cleanup = + "(function(){try{if(window.__zcodeContextDispose)window.__zcodeContextDispose()}catch(e){}})();"; + const config = { + runtime: RUNTIME, + pluginRoot: CONFIG.pluginRoot || "", + version: CONFIG.version || "0.1.0", + }; + const source = `${cleanup}window.__zcodeContextConfig=${JSON.stringify(config)};\n${widgetSource}`; + wc.executeJavaScript(source, true).catch((error) => log("executeJavaScript failed:", error.message)); +} + +function hook(wc) { + if (!isMainWindowState(wc)) return; + wc.on("dom-ready", () => inject(wc)); + wc.on("did-finish-load", () => inject(wc)); + if (!wc.isLoading()) inject(wc); +} + +app.on("web-contents-created", (_event, wc) => hook(wc)); +for (const wc of webContents.getAllWebContents()) hook(wc); + +app.whenReady() + .then(() => { + setInterval(pumpRequests, REQUEST_POLL_MS); + setTimeout(prefetchCaches, 6000); + }) + .catch((error) => log("whenReady failed:", error.message)); + +module.exports = { + inject, + pumpRequests, + _state: () => ({ runtime: RUNTIME, widgetReady: Boolean(widgetSource) }), +}; diff --git a/external_plugins/zcode-context/panel/panel-widget.js b/external_plugins/zcode-context/panel/panel-widget.js new file mode 100644 index 00000000..599c615e --- /dev/null +++ b/external_plugins/zcode-context/panel/panel-widget.js @@ -0,0 +1,2115 @@ +/* zcode-context renderer: sidebar entry + context insight dashboard. + * + * Injected into ZCode's local renderer pages by panel/inject-main.cjs. + * Data flows through window.__zcodeContextRequests (renderer -> main) and + * window.__zcodeContextRequestResult (main -> renderer). All charts are + * inline SVG; no external libraries. Visual language mirrors the dsh-context + * reference: dense dark glass panels, thin multi-color donuts, dot+count + * ranking rows, tabular numerals. + */ +"use strict"; +(function () { + if (window.__zcodeContextPanelBooted) return; + window.__zcodeContextPanelBooted = true; + var BOOT_ID = "b" + ((Number(window.__zciBootCounter) || 0) + 1); + window.__zciBootCounter = Number(window.__zciBootCounter) || 0; + window.__zciBootCounter += 1; + window.__zciBoots = window.__zciBoots || []; + window.__zciBoots.push(BOOT_ID); + try { + if (window.__zciObserver) { + window.__zciObserver.disconnect(); + window.__zciObserver = null; + } + } catch { + // ignore + } + try { + var oldEntry = document.getElementById("zci-entry"); + if (oldEntry) oldEntry.remove(); + document.querySelectorAll(".zci-overlay").forEach(function (el) { + el.remove(); + }); + var oldStyle = document.getElementById("zci-style"); + if (oldStyle) oldStyle.remove(); + } catch { + // ignore + } + + var config = window.__zcodeContextConfig || {}; + + /* ---------------------------------------------------------------- */ + /* Styles */ + /* ---------------------------------------------------------------- */ + + var CSS = ` +.zci-entry{display:flex;align-items:center;gap:9px;padding:8px 12px;margin:2px 10px;border-radius:10px;cursor:pointer;color:#aeb4c0;font-size:13px;user-select:none;transition:background .15s,color .15s} +.zci-entry:hover{background:rgba(255,255,255,.08);color:#fff} +.zci-entry svg{flex:0 0 auto} + +.zci-overlay{position:fixed;inset:0;z-index:99990;display:flex;flex-direction:column;color:#e6e9f0;font-size:12px;line-height:1.5;overflow:hidden; + font-family:-apple-system,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif; + background:#0d0f13;background-image:radial-gradient(1000px 460px at 20% -10%,rgba(70,110,255,.12),transparent 60%),radial-gradient(800px 420px at 90% -8%,rgba(34,211,197,.06),transparent 60%)} +.zci-head{display:flex;align-items:center;gap:12px;padding:14px 20px 8px;flex:0 0 auto} +.zci-title h1{font-size:16px;font-weight:700;margin:0} +.zci-title .sub{font-size:11px;color:#757d8c} +.zci-spacer{flex:1} +.zci-seg{display:flex;gap:2px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.07);border-radius:999px;padding:2px} +.zci-seg .zci-pill{border:none;background:transparent;color:#a6adba;border-radius:999px;padding:4px 13px;cursor:pointer;font-size:12px;transition:all .15s} +.zci-seg .zci-pill:hover{color:#fff} +.zci-seg .zci-pill.zci-on{background:linear-gradient(135deg,#5b8cff,#3b6dff);color:#fff} +.zci-link{border:none;background:transparent;color:#8b93a3;cursor:pointer;font-size:12px;padding:4px 6px} +.zci-link:hover{color:#fff} +.zci-link.zci-on{color:#fff;text-shadow:0 0 8px rgba(255,255,255,.35)} +.zci-iconbtn{width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,.1);background:rgba(255,255,255,.05);color:#c6ccd8;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s} +.zci-iconbtn:hover{background:rgba(255,255,255,.12);color:#fff} +.zci-iconbtn.zci-accent{background:linear-gradient(135deg,#5b8cff,#3b6dff);border-color:transparent;color:#fff} +.zci-tabs{display:flex;gap:4px;padding:2px 20px 12px;flex:0 0 auto} +.zci-tab{padding:6px 16px;cursor:pointer;border-radius:999px;color:#9aa1af;font-size:12px;transition:all .15s} +.zci-tab:hover{color:#e6e9f0;background:rgba(255,255,255,.05)} +.zci-tab.zci-on{color:#fff;background:linear-gradient(135deg,rgba(91,140,255,.85),rgba(59,109,255,.85))} +.zci-body{flex:1;overflow:auto;padding:2px 20px 44px} +.zci-grid>*,.zci-row>*,.zci-row-2>*,.zci-row-3>*{min-width:0} + +.zci-card{background:rgba(10,12,16,.72);border:1px solid rgba(255,255,255,.06);border-radius:10px;padding:13px 15px;backdrop-filter:blur(14px);transition:border-color .15s} +.zci-card.zci-hover{cursor:pointer;transition:border-color .15s,background .15s} +.zci-card.zci-hover:hover{border-color:rgba(120,150,255,.4);background:rgba(18,22,30,.8)} +.zci-card h3{margin:0 0 10px;font-size:12px;font-weight:650;color:#cdd3de;display:flex;align-items:center;gap:7px} +.zci-card h3 .zci-ico{width:7px;height:7px;border-radius:50%;display:inline-block} +.zci-card h3 .zci-cnt{margin-left:auto;color:#757d8c;font-weight:400;font-size:11px;font-variant-numeric:tabular-nums} + +.zci-top{display:grid;grid-template-columns:1fr 320px;gap:10px} +.zci-kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:10px} +.zci-kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:14px 10px;min-height:86px} +.zci-kpi .zci-kpi-label{font-size:12px;color:#8b93a3} +.zci-kpi .zci-kpi-value{font-size:23px;font-weight:750;margin:5px 0 2px;color:#f2f5fb;font-variant-numeric:tabular-nums;letter-spacing:.3px} +.zci-kpi .zci-kpi-sub{font-size:11px;color:#5f6675} + +.zci-row{display:grid;gap:10px;grid-template-columns:1fr 1fr;align-items:stretch;margin-top:10px} +.zci-row-2{display:grid;gap:10px;grid-template-columns:1fr 1fr;align-items:stretch;margin-top:10px} +.zci-row-3{display:grid;gap:10px;grid-template-columns:1.2fr 1fr 1fr 1fr;align-items:stretch;margin-top:10px} + +/* donut + legend */ +.zci-donut-wrap{display:flex;gap:16px;align-items:center} +.zci-legend{flex:1;display:grid;grid-template-columns:repeat(3,1fr);gap:8px 14px;align-content:center} +.zci-legend.zci-2col{grid-template-columns:repeat(2,1fr)} +.zci-lg{display:flex;flex-direction:column;gap:1px} +.zci-lg .l1{display:flex;align-items:center;gap:6px;color:#a6adba;font-size:11px;white-space:nowrap} +.zci-lg .l1 .dot{width:8px;height:8px;border-radius:2.5px;flex:0 0 auto} +.zci-lg .l1 .pct{margin-left:auto;color:#e6e9f0;font-weight:600;font-variant-numeric:tabular-nums} +.zci-lg .l2{padding-left:14px;color:#757d8c;font-size:11px;font-variant-numeric:tabular-nums} + +/* ranking rows: dot + name + xN with thin colored underline */ +.zci-rank{display:flex;flex-direction:column} +.zci-rank-row{padding:4px 2px 3px;cursor:default} +.zci-rank-row:hover{background:rgba(255,255,255,.04);border-radius:6px} +.zci-rank-row .r1{display:flex;align-items:center;gap:7px} +.zci-rank-row .dot{width:7px;height:7px;border-radius:50%;flex:0 0 auto} +.zci-rank-row .name{flex:1;color:#b8bfcc;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.zci-rank-row .n{color:#8b93a3;font-variant-numeric:tabular-nums;font-size:11px} +.zci-rank-row .bar{height:2px;border-radius:1px;margin-top:3px;background:rgba(255,255,255,.04);position:relative;overflow:hidden} +.zci-rank-row .bar i{position:absolute;left:0;top:0;bottom:0;border-radius:1px} + +/* usage tall chart */ +.zci-usage{display:flex;flex-direction:column} +.zci-usage svg{flex:1} + +/* heatmap */ +.zci-heat-tools{display:flex;align-items:center;gap:8px;margin-left:auto} +.zci-heat{padding:2px 0} +.zci-heat text{font-size:9px;fill:#6d7484} + +/* timeline strip */ +.zci-timeline{margin-top:10px} +.zci-timeline .bar{height:6px;border-radius:3px;overflow:hidden;display:flex;background:rgba(255,255,255,.05)} +.zci-timeline .bar span{height:100%} +.zci-timeline .ticks{display:flex;justify-content:space-between;color:#5f6675;font-size:10px;margin-top:3px;font-variant-numeric:tabular-nums} + +/* sessions */ +.zci-sess-head{display:flex;align-items:center;gap:10px;margin:12px 0 8px} +.zci-sess-head h2{margin:0;font-size:13px;font-weight:650;color:#cdd3de;display:flex;align-items:center;gap:7px} +.zci-sess-head h2 .zci-ico{width:7px;height:7px;border-radius:50%;background:#5b8cff} +.zci-sess-head .zci-spacer{flex:1} +.zci-search{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:999px;color:#e6e9f0;font-size:12px;padding:5px 14px;width:210px;outline:none} +.zci-search:focus{border-color:rgba(120,150,255,.5)} +.zci-chips{display:flex;gap:6px;margin:0 0 10px;flex-wrap:wrap} +.zci-chip{font-size:11px;padding:3px 10px;border-radius:999px;background:rgba(91,140,255,.14);color:#9db9ff;border:1px solid rgba(91,140,255,.28);cursor:pointer;white-space:nowrap} +.zci-chip:hover{background:rgba(91,140,255,.25)} +.zci-chip.zci-on{background:linear-gradient(135deg,#5b8cff,#3b6dff);color:#fff;border-color:transparent} +.zci-chip.zci-plain{background:rgba(255,255,255,.05);color:#a6adba;border-color:rgba(255,255,255,.1)} +.zci-chip.zci-amber{background:rgba(247,185,85,.14);color:#f7cd85;border-color:rgba(247,185,85,.3);cursor:default} + +.zci-sessions{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:10px} +.zci-scard{display:flex;flex-direction:column;gap:7px} +.zci-scard .zci-shead{display:flex;align-items:center;gap:6px} +.zci-scard .t{font-weight:650;font-size:12px;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#e6e9f0} +.zci-scard .time{color:#5f6675;font-size:10px;white-space:nowrap} +.zci-scard .zci-smid{display:flex;align-items:center;gap:10px} +.zci-scard .zci-sstats{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:3px 10px;font-size:11px;color:#8b93a3} +.zci-scard .zci-sstats b{color:#dfe3ec;font-weight:600;font-variant-numeric:tabular-nums} +.zci-scard .zci-sstats .wide{grid-column:1/-1} +.zci-scard .zci-slast{font-size:11px;color:#5f6675;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + +/* detail header stats */ +.zci-dstats{display:flex;gap:0;align-items:stretch;flex-wrap:wrap} +.zci-dstat{flex:1;text-align:center;padding:2px 14px;border-right:1px solid rgba(255,255,255,.06)} +.zci-dstat:last-child{border-right:none} +.zci-dstat .v{font-size:20px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums} +.zci-dstat .l{font-size:11px;color:#8b93a3} +.zci-dstat .s{font-size:10px;color:#5f6675} +.zci-io-line{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:11px;color:#a6adba;margin-top:9px;padding-top:9px;border-top:1px dashed rgba(255,255,255,.07)} +.zci-io-line .n{color:#5f6675;font-variant-numeric:tabular-nums} +.zci-io-line .tot{margin-left:auto;color:#757d8c;font-variant-numeric:tabular-nums} + +/* context bar */ +.zci-ctx-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap} +.zci-ctx-head .big{font-size:19px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums} +.zci-ctx-head .big small{font-size:12px;color:#8b93a3;font-weight:400} +.zci-ctx-head .pct{margin-left:auto;font-size:12px;color:#cdd3de} +.zci-ctx-head .pct b{font-size:15px;color:#f2f5fb} +.zci-ctxbar{position:relative;height:14px;border-radius:7px;overflow:hidden;display:flex;background:rgba(255,255,255,.05);margin:10px 0 8px} +.zci-ctxbar .seg{height:100%} +.zci-ctxbar .reserve{height:100%;background:repeating-linear-gradient(135deg,rgba(255,255,255,.12) 0 5px,transparent 5px 10px)} +.zci-ctxline{height:8px;border-radius:4px;background:rgba(255,255,255,.06);overflow:hidden;display:flex} +.zci-ctxline .seg{height:100%} +.zci-ctx-leg{display:grid;grid-template-columns:repeat(4,1fr);gap:6px 16px;margin-top:8px} +.zci-ctx-leg .zci-lg .l2{font-size:10px} + +/* cost composition */ +.zci-cost-head{display:flex;gap:26px;align-items:baseline;flex-wrap:wrap;margin-bottom:8px} +.zci-cost-head .item{display:flex;flex-direction:column} +.zci-cost-head .item .v{font-size:20px;font-weight:750;color:#f2f5fb;font-variant-numeric:tabular-nums} +.zci-cost-head .item .l{font-size:11px;color:#8b93a3} +.zci-cost-head .item .s{font-size:10px;color:#5f6675} +.zci-cost-donut{display:flex;gap:18px;align-items:center} +.zci-donut-100{position:relative} +.zci-donut-100 .c{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center} +.zci-donut-100 .c .v{font-size:16px;font-weight:750;color:#2bd576} +.zci-donut-100 .c .l{font-size:10px;color:#757d8c} + +/* trend */ +.zci-trend-tools{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap} +.zci-trend-tools .grp{margin-left:auto;display:flex;gap:2px;background:rgba(255,255,255,.06);border-radius:999px;padding:2px} +.zci-trend-tools .grp:first-of-type{margin-left:0} + +/* events & files */ +.zci-events{max-height:320px;overflow:auto;font-size:11px;padding-right:4px} +.zci-ev{display:flex;gap:8px;padding:4px 0;border-bottom:1px dashed rgba(255,255,255,.05);align-items:baseline} +.zci-ev:last-child{border-bottom:none} +.zci-ev .k{flex:0 0 74px;color:#5f6675;font-variant-numeric:tabular-nums} +.zci-ev .tag{flex:0 0 66px;font-size:10px;padding:1px 7px;border-radius:999px;text-align:center;border:1px solid} +.zci-ev .tag.zci-t-tool{color:#9db9ff;border-color:rgba(91,140,255,.35);background:rgba(91,140,255,.1)} +.zci-ev .tag.zci-t-compact{color:#f7cd85;border-color:rgba(247,185,85,.35);background:rgba(247,185,85,.1)} +.zci-ev .tag.zci-t-error{color:#ff8fa0;border-color:rgba(255,107,129,.4);background:rgba(255,107,129,.12)} +.zci-ev .tag.zci-t-msg{color:#a3b3c9;border-color:rgba(255,255,255,.14);background:rgba(255,255,255,.05)} +.zci-ev .txt{flex:1;min-width:0;color:#b8bfcc;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + +.zci-files{font-size:11px} +.zci-files .fhead{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px} +.zci-file{display:flex;gap:8px;padding:4px 8px;border-radius:6px;align-items:center} +.zci-file:hover{background:rgba(255,255,255,.05)} +.zci-file .p{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#b8bfcc;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11px} +.zci-file .c{color:#8b93a3;font-variant-numeric:tabular-nums;flex:0 0 auto;font-size:10px} +.zci-file .c b{color:#2bd576;font-weight:600} +.zci-file .c i{color:#ff8fa0;font-style:normal;font-weight:600} + +/* context browser (right rail) */ +.zci-browser{display:flex;flex-direction:column;gap:6px} +.zci-brow-row{background:rgba(255,255,255,.045);border:1px solid rgba(255,255,255,.06);border-radius:8px;padding:7px 10px} +.zci-brow-row .r1{display:flex;align-items:center;gap:7px;font-size:11px;color:#cdd3de} +.zci-brow-row .r1 .dot{width:7px;height:7px;border-radius:50%} +.zci-brow-row .r1 .name{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.zci-brow-row .r1 .items{color:#757d8c} +.zci-brow-row .r1 .add{color:#2bd576;font-variant-numeric:tabular-nums} +.zci-brow-row .r1 .val{color:#8b93a3;font-variant-numeric:tabular-nums;margin-left:auto} +.zci-brow-row .r1 .pct{color:#5f6675;font-variant-numeric:tabular-nums} + +/* formation */ +.zci-banner{display:flex;align-items:center;gap:12px;border-radius:12px;padding:14px 18px;margin-bottom:10px; + background:linear-gradient(120deg,rgba(122,90,248,.55),rgba(59,109,255,.45));border:1px solid rgba(167,139,250,.3)} +.zci-banner .t{font-size:16px;font-weight:750;color:#fff} +.zci-banner .d{font-size:11px;color:rgba(255,255,255,.75)} +.zci-banner .stats{margin-left:auto;display:flex;gap:8px} +.zci-banner .stat{background:rgba(0,0,0,.25);border-radius:8px;padding:5px 12px;text-align:center;min-width:56px} +.zci-banner .stat .v{font-size:15px;font-weight:700;color:#fff;font-variant-numeric:tabular-nums} +.zci-banner .stat .l{font-size:10px;color:rgba(255,255,255,.7)} + +.zci-net{position:relative} +.zci-node{display:flex;flex-direction:column;gap:6px} +.zci-node .zci-ahead{display:flex;align-items:center;gap:8px} +.zci-badge{width:26px;height:26px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;color:#fff;flex:0 0 auto} +.zci-node .name{font-weight:650;font-size:12px;color:#e6e9f0} +.zci-node .val{font-size:11px;color:#8b93a3;font-variant-numeric:tabular-nums} +.zci-node .pbar{height:6px;border-radius:3px;background:rgba(255,255,255,.06);overflow:hidden} +.zci-node .pbar i{display:block;height:100%;border-radius:3px} +.zci-node .meta{font-size:10px;color:#5f6675;display:flex;gap:10px} +.zci-net-children{margin:8px 0 0 13px;padding-left:16px;border-left:1px solid rgba(255,255,255,.14);display:flex;flex-direction:column;gap:8px} + +.zci-empty{padding:40px 16px;text-align:center;color:#5f6675;font-size:12px} +.zci-err{color:#ff8fa0;padding:16px 20px;font-size:13px} +.zci-err + pre{white-space:pre-wrap;font-size:11px;color:#ff8fa0;padding:0 20px 16px;margin:0;opacity:.8} +.zci-note{font-size:11px;color:#757d8c} +.zci-trend-toggle{display:flex;gap:2px;background:rgba(255,255,255,.06);border-radius:999px;padding:2px;width:fit-content;margin-bottom:8px} +.zci-trend-toggle .zci-pill{border:none;background:transparent;color:#a6adba;border-radius:999px;padding:3px 12px;cursor:pointer;font-size:11px} +.zci-trend-toggle .zci-pill.zci-on{background:rgba(91,140,255,.4);color:#fff} +.zci-back{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.zci-back .t{font-weight:700;font-size:14px;color:#e6e9f0} +.zci-btn{border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05);color:#c6ccd8;border-radius:999px;padding:4px 14px;cursor:pointer;font-size:12px} +.zci-btn:hover{background:rgba(255,255,255,.12);color:#fff} + +/* global tooltip */ +.zci-tip{position:fixed;z-index:100000;pointer-events:none;background:rgba(16,19,26,.96);border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:7px 11px;font-size:11px;color:#dfe3ec;line-height:1.55;box-shadow:0 6px 24px rgba(0,0,0,.5);max-width:340px;display:none;font-variant-numeric:tabular-nums} +.zci-tip b{color:#fff;font-weight:650} +.zci-tip .dim{color:#8b93a3} + +/* clickable rows / cells */ +.zci-rank-row.zci-ck{cursor:pointer} +.zci-rank-row.zci-ck:hover{background:rgba(255,255,255,.06)} +.zci-rank-row.zci-sel{background:rgba(91,140,255,.16);border-radius:6px;outline:1px solid rgba(91,140,255,.4)} +.zci-rank-row.zci-sel .name{color:#fff} +.zci-heat rect[data-day]{cursor:pointer} +.zci-heat rect[data-day]:hover{stroke:#fff;stroke-width:1} +.zci-kpi.zci-ck{cursor:pointer} +.zci-dseg{cursor:pointer;transition:opacity .12s} +.zci-dimmed{opacity:.18} +.zci-lg.zci-lgck{cursor:default;border-radius:5px;padding:1px 3px;transition:background .12s} +.zci-lg.zci-lgck:hover{background:rgba(255,255,255,.05)} + +/* ctx bar segments */ +.zci-ctxbar .seg{cursor:pointer;transition:filter .12s} +.zci-ctxbar .seg:hover{filter:brightness(1.35)} +.zci-ctxbar .seg.zci-seg-hot{outline:1.5px solid #fff;outline-offset:-1px;filter:brightness(1.35)} +.zci-ctxbar .reserve{cursor:help} + +/* trend hover + pinned detail */ +.zci-trend-hit{cursor:pointer} +.zci-trend-hit:hover{fill:rgba(255,255,255,.07)} +.zci-xhair{pointer-events:none} +.zci-reqdetail{margin-top:8px;border:1px dashed rgba(91,140,255,.4);border-radius:8px;padding:8px 12px;background:rgba(91,140,255,.08);display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:#a6adba;align-items:center} +.zci-reqdetail b{color:#f2f5fb;font-variant-numeric:tabular-nums;font-weight:650} +.zci-reqdetail .x{margin-left:auto;color:#757d8c;cursor:pointer} +.zci-reqdetail .x:hover{color:#fff} + +/* events expand + pills */ +.zci-ev{cursor:pointer} +.zci-ev.open .txt{white-space:normal;overflow:visible} +.zci-ev .more{color:#5b8cff;font-size:10px;flex:0 0 auto} +.zci-dstat.zci-ck{cursor:pointer;border-radius:8px} +.zci-dstat.zci-ck:hover{background:rgba(255,255,255,.05)} +.zci-dstat.zci-ck .s{color:#5b8cff} + +/* files */ +.zci-file{cursor:pointer} +.zci-file .fmeta{flex:0 0 auto;color:#5f6675;font-size:10px} +.zci-file .fdetail{flex-basis:100%;color:#757d8c;font-size:10px;padding-left:4px;display:none} +.zci-file.open .fdetail{display:block} +.zci-file.open{background:rgba(91,140,255,.08);flex-wrap:wrap} +.zci-filesearch{background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);border-radius:999px;color:#e6e9f0;font-size:11px;padding:3px 12px;width:170px;outline:none} + +/* browser rows collapsible */ +.zci-brow-row{cursor:pointer;transition:background .12s,border-color .12s} +.zci-brow-row:hover{background:rgba(255,255,255,.08);border-color:rgba(120,150,255,.35)} +.zci-brow-row.zci-bhot{border-color:rgba(120,150,255,.55);background:rgba(91,140,255,.12)} +.zci-brow-row .bdetail{display:none;margin-top:6px;padding-top:6px;border-top:1px dashed rgba(255,255,255,.08);font-size:10px;color:#8b93a3;line-height:1.6;max-height:180px;overflow:auto} +.zci-brow-row.open .bdetail{display:block} +.zci-brow-row .caret{font-size:9px;color:#5f6675;flex:0 0 auto;transition:transform .15s} +.zci-brow-row.open .caret{transform:rotate(90deg)} +.zci-brow-row .bdetail .tnames{display:flex;flex-wrap:wrap;gap:4px;margin-top:3px} +.zci-brow-row .bdetail .tnames span{background:rgba(255,255,255,.07);border-radius:4px;padding:1px 6px;font-family:ui-monospace,Consolas,monospace;font-size:10px} + +/* agent nodes */ +.zci-node.zci-ck{cursor:pointer} +.zci-node .pbar{position:relative} +.zci-net.zci-dimnet .zci-node{opacity:.25;transition:opacity .15s} +.zci-net.zci-dimnet .zci-node.zci-chain{opacity:1} +.zci-press-ok{background:rgba(43,213,118,.7)} +.zci-press-warn{background:rgba(245,158,66,.85)} +.zci-press-hot{background:rgba(255,107,129,.9)} +`; + + function ensureStyle() { + if (document.getElementById("zci-style")) return; + var style = document.createElement("style"); + style.id = "zci-style"; + style.textContent = CSS; + document.head.appendChild(style); + } + + /* ---------------------------------------------------------------- */ + /* Data plumbing */ + /* ---------------------------------------------------------------- */ + + var requestSeq = 0; + window.__zciPending = window.__zciPending || new Map(); + var pending = window.__zciPending; + window.__zciDeliveredLog = []; + + window.__zcodeContextRequestResult = function (id, result) { + try { + window.__zciDeliveredLog.push({ id: id, at: Date.now(), type: typeof result, ok: result ? result.ok : null }); + if (window.__zciDeliveredLog.length > 20) window.__zciDeliveredLog.shift(); + } catch { + // diagnostics only + } + var entry = pending.get(id); + if (!entry) { + window.__zciOrphans = window.__zciOrphans || []; + window.__zciOrphans.push({ id: id, boot: BOOT_ID, at: Date.now() }); + if (window.__zciOrphans.length > 20) window.__zciOrphans.shift(); + return; + } + pending.delete(id); + if (result && result.ok === false) entry.reject(new Error(result.error?.message || "查询失败")); + else entry.resolve(result); + }; + + window.__zcodeContextDebugInfo = function () { + return { + boot: BOOT_ID, + boots: window.__zciBoots, + booted: true, + tab: state.tab, + loading: state.loading, + error: state.error ? String(state.error).slice(0, 120) : null, + pending: Array.from(pending.keys()), + queue: Array.isArray(window.__zcodeContextRequests) ? window.__zcodeContextRequests.length : -1, + delivered: window.__zciDeliveredLog.slice(-8), + orphans: (window.__zciOrphans || []).slice(-5), + overlayShown: state.overlayEl ? state.overlayEl.style.display !== "none" : false, + }; + }; + + function query(op, args) { + return new Promise(function (resolve, reject) { + var id = "q" + ++requestSeq; + pending.set(id, { resolve: resolve, reject: reject }); + window.__zcodeContextRequests = window.__zcodeContextRequests || []; + window.__zcodeContextRequests.push({ id: id, op: op, args: args || {}, boot: BOOT_ID }); + setTimeout(function () { + if (pending.has(id)) { + pending.delete(id); + reject(new Error("查询超时")); + } + }, 95000); + }); + } + + /* ---------------------------------------------------------------- */ + /* Formatting helpers */ + /* ---------------------------------------------------------------- */ + + function fmtTokens(n) { + n = Number(n) || 0; + if (n >= 1e9) return (n / 1e9).toFixed(2) + "B"; + if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 1 : 2) + "M"; + if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e5 ? 0 : 1) + "k"; + return String(Math.round(n)); + } + function fmtCost(n) { + return "¥" + (Number(n) || 0).toFixed(2); + } + function fmtDuration(ms) { + ms = Number(ms) || 0; + if (ms >= 3600000) return Math.floor(ms / 3600000) + "h" + Math.round((ms % 3600000) / 60000) + "m"; + if (ms >= 60000) return Math.floor(ms / 60000) + "m" + Math.round((ms % 60000) / 1000) + "s"; + return Math.round(ms / 1000) + "s"; + } + function fmtPct(p) { + return ((Number(p) || 0) * 100).toFixed(1) + "%"; + } + function fmtTime(ts) { + if (!ts) return "-"; + var d = new Date(Number(ts)); + return d.toLocaleString(undefined, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + } + function esc(s) { + return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; + }); + } + + var C = { + blue: "#4b7bff", + green: "#2bd576", + amber: "#f59e42", + red: "#ff6b81", + purple: "#a78bfa", + cyan: "#22d3c5", + magenta: "#e04fa3", + gray: "#697182", + }; + var RANK_COLORS = [C.amber, C.green, C.red, C.blue, C.cyan, C.purple, C.magenta, "#8fa0b8", "#d0a24a", "#5ec9b0"]; + + /* ---------------------------------------------------------------- */ + /* Global floating tooltip (140ms delay, follows the cursor) */ + /* ---------------------------------------------------------------- */ + + var tipEl = null; + var tipTimer = null; + function tipEnsure() { + if (tipEl && tipEl.isConnected) return tipEl; + tipEl = document.createElement("div"); + tipEl.className = "zci-tip"; + document.body.appendChild(tipEl); + return tipEl; + } + function tipShow(html, x, y) { + var el = tipEnsure(); + el.innerHTML = html; + el.style.display = "block"; + var w = el.offsetWidth || 160; + var h = el.offsetHeight || 40; + var px = Math.min(x + 14, window.innerWidth - w - 10); + var py = y + 16; + if (py + h > window.innerHeight - 8) py = y - h - 10; + el.style.left = Math.max(6, px) + "px"; + el.style.top = Math.max(6, py) + "px"; + } + function tipHide() { + if (tipTimer) { clearTimeout(tipTimer); tipTimer = null; } + if (tipEl) tipEl.style.display = "none"; + } + /** Bind [data-tip] elements under root: hover shows the floating tooltip. */ + function bindTips(root) { + if (!root || !root.querySelectorAll) return; + root.querySelectorAll("[data-tip]").forEach(function (el) { + if (el.__zciTip) return; + el.__zciTip = true; + el.addEventListener("mousemove", function (e) { + var html = el.getAttribute("data-tip"); + if (!html) return; + if (tipTimer) clearTimeout(tipTimer); + tipTimer = setTimeout(function () { tipShow(html, e.clientX, e.clientY); }, 140); + }); + el.addEventListener("mouseleave", tipHide); + el.addEventListener("mousedown", tipHide); + }); + } + + /** Donut hover <-> legend highlight within each .zci-donut-wrap. */ + function linkDonuts(root) { + if (!root) return; + root.querySelectorAll(".zci-donut-wrap").forEach(function (wrap) { + if (wrap.__zciLinked) return; + wrap.__zciLinked = true; + var segs = wrap.querySelectorAll(".zci-dseg"); + var rows = wrap.querySelectorAll("[data-lkey]"); + function setHot(key) { + segs.forEach(function (s) { + s.classList.toggle("zci-dimmed", key != null && s.getAttribute("data-dkey") !== key); + }); + rows.forEach(function (r) { + r.classList.toggle("zci-dimmed", key != null && r.getAttribute("data-lkey") !== key); + r.style.background = r.getAttribute("data-lkey") === key ? "rgba(255,255,255,.07)" : ""; + }); + } + wrap.addEventListener("mouseover", function (e) { + var t = e.target.closest("[data-dkey],[data-lkey]"); + setHot(t ? t.getAttribute("data-dkey") || t.getAttribute("data-lkey") : null); + }); + wrap.addEventListener("mouseleave", function () { setHot(null); }); + }); + } + + /** One delegated pass: tooltips, donut linking, filter chips. Call after each innerHTML write. */ + function bindAll(root) { + bindTips(root); + linkDonuts(root); + } + + + /* ---------------------------------------------------------------- */ + /* SVG chart helpers */ + /* ---------------------------------------------------------------- */ + + function donut(parts, size, thickness, centerLabel, centerSub) { + size = size || 118; + thickness = thickness || 13; + var total = parts.reduce(function (a, p) { return a + p.value; }, 0); + var r = (size - thickness) / 2 - 1; + var c = size / 2; + var circ = 2 * Math.PI * r; + var shown = parts.filter(function (p) { return p.value > 0; }); + var gap = shown.length > 1 ? 2 : 0; + var offset = 0; + var segs = ""; + if (total <= 0) { + segs = ''; + } else { + shown.forEach(function (p) { + var frac = p.value / total; + var len = Math.max(0.5, frac * circ - gap); + var tip = p.tip || (p.label + (p.valueText ? " " + p.valueText : "")); + segs += + '" + p.label + "" + (p.valueText ? " " + p.valueText : "") + " · " + ((frac * 100).toFixed(1) + "% 占比")) : "") + + '" cx="' + c + '" cy="' + c + '" r="' + r + '" fill="none" stroke="' + p.color + + '" stroke-width="' + thickness + '" stroke-linecap="round" stroke-dasharray="' + len + " " + (circ - len) + + '" stroke-dashoffset="' + -offset + '" transform="rotate(-90 ' + c + " " + c + ')">'; + offset += frac * circ; + }); + } + var center = ""; + if (centerLabel) { + // adaptive center font: small donuts (mini rings) shrink to fit + var bigFs = size <= 64 ? (String(centerLabel).length > 4 ? 8 : 10) : String(centerLabel).length > 6 ? 12 : 15; + var subFs = size <= 64 ? 7 : 9; + var subDy = size <= 64 ? 10 : 14; + center = + '' + esc(centerLabel) + "" + + '' + esc(centerSub || "") + ""; + } + return '' + segs + center + ""; + } + + /** dsh-style legend: label + pct on first line, ≈value on second. */ + function legendGrid(parts, opts) { + opts = opts || {}; + var total = parts.reduce(function (a, p) { return a + p.value; }, 0); + var cls = "zci-legend" + (opts.cols === 2 ? " zci-2col" : ""); + var rows = parts + .filter(function (p) { return p.value > 0 || opts.showZero; }) + .map(function (p) { + var pct = total > 0 ? ((p.value / total) * 100).toFixed(1) + "%" : "0%"; + var val = p.valueText || fmtTokens(p.value); + var tip = p.tip ? '" data-tip="' + esc(p.tip) : ""; + return ( + '
' + + '' + esc(p.label) + '' + pct + '
' + + '
≈' + esc(val) + "
" + ); + }) + .join(""); + return '
' + rows + "
"; + } + + /** ranking rows: dot + name + ×n + thin colored underline. + * With opts.filterType the rows become clickable session-card filters. */ + function rankRows(items, colorFn, opts) { + opts = opts || {}; + var max = 1; + items.forEach(function (it) { + max = Math.max(max, it.calls); + }); + var total = items.reduce(function (a, it) { return a + it.calls; }, 0) || 1; + return items + .map(function (it, i) { + var col = colorFn(it, i); + var pct = Math.max(2, (it.calls / max) * 100); + var clickable = Boolean(opts.filterType); + var selected = clickable && state.filter && state.filter.type === opts.filterType && String(state.filter.value) === String(it.name); + var cls = "zci-rank-row" + (clickable ? " zci-ck" : "") + (selected ? " zci-sel" : ""); + var tip = it.tip || ("" + esc(it.name) + " ×" + it.calls + " · " + ((it.calls / total) * 100).toFixed(1) + "% 占比" + (clickable ? "
点击筛选相关会话
" : "")); + return ( + '
' + + '' + esc(it.name) + + '×' + it.calls + "
" + + '
' + ); + }) + .join(""); + } + + /** Delegated click binding for [data-ft]/[data-fv] filter rows + filter chip. */ + function bindFilterRows(root, rerender) { + if (!root) return; + root.querySelectorAll("[data-ft]").forEach(function (el) { + if (el.__zciF) return; + el.__zciF = true; + el.addEventListener("click", function () { + var type = el.getAttribute("data-ft"); + var value = el.getAttribute("data-fv"); + if (state.filter && state.filter.type === type && String(state.filter.value) === value) state.filter = null; + else state.filter = { type: type, value: value, label: value }; + rerender(); + }); + }); + } + + function filterChipHtml() { + if (!state.filter) return ""; + return '
筛选:' + esc(state.filter.label) + " ✕" + + '再次点击排行行或此标签取消筛选
'; + } + + /** Apply state.filter to a session card list. */ + function applyCardFilter(list) { + if (!state.filter) return list; + var f = state.filter; + return list.filter(function (card) { + if (f.type === "tool") return ((card.tools || []).some(function (t) { return t.tool === f.value; })); + if (f.type === "cmd") { + var bash = (card.tools || []).some(function (t) { return t.tool === "Bash" || t.tool === "PowerShell" || t.tool === "pwsh"; }); + return bash; + } + if (f.type === "proj") return card.workspace === f.value; + if (f.type === "day") { + var d = new Date(Number(card.lastActiveAt) || 0); + var key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); + return key === f.value; + } + return true; + }); + } + + /** tall usage chart with y-axis labels and thin paired bars */ + function usageChart(series) { + if (!series.length) return '
暂无数据
'; + var w = 300; + var h = 240; + var padL = 40; + var padB = 22; + var plotW = w - padL - 6; + var plotH = h - padB - 10; + var max = 1; + series.forEach(function (s) { max = Math.max(max, s.tokens); }); + var ticks = [0, 0.25, 0.5, 0.75, 1].map(function (f) { return { v: max * f, y: 10 + plotH - f * plotH }; }); + var n = series.length; + var slot = plotW / n; + var bw = Math.min(9, slot * 0.4); + var svg = ''; + ticks.forEach(function (t) { + svg += '' + fmtTokens(t.v) + ""; + svg += ''; + }); + series.forEach(function (s, i) { + var x = padL + slot * i + (slot - bw * 2.3) / 2; + var hT = Math.max(2, (s.tokens / max) * plotH); + var hC = Math.max(2, ((s.cost * 4e6) / max) * plotH); + var tip = "" + esc(s.key) + "
Token " + fmtTokens(s.tokens) + "
费用 " + fmtCost(s.cost) + " · " + s.requests + " 次请求"; + svg += ''; + svg += ''; + if (n <= 16 || i % Math.ceil(n / 12) === 0) { + svg += '' + esc(s.key.slice(5)) + ""; + } + }); + svg += ""; + return svg; + } + + function heatmap(days, metric) { + metric = metric || "tokens"; + var map = {}; + days.forEach(function (d) { map[d.day] = d; }); + var today = new Date(); + var cells = []; + var maxV = 1; + days.forEach(function (d) { maxV = Math.max(maxV, metric === "sessions" ? d.sessions : d.tokens); }); + var start = new Date(today.getTime() - 55 * 86400000); + start = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + var weekdayLabels = ["", "一", "", "三", "", "五", ""]; + var svgCells = ""; + var monthMarks = []; + var lastMonth = -1; + var idx = 0; + for (var d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) { + var key = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0"); + var cell = map[key]; + var level = 0; + if (cell) { + var v = metric === "sessions" ? cell.sessions : cell.tokens; + level = v > 0 ? Math.min(4, Math.ceil((v / maxV) * 4)) : 0; + } + var col = Math.floor(idx / 8); + var row = idx % 8; + if (row === 0 && d.getMonth() !== lastMonth) { + lastMonth = d.getMonth(); + monthMarks.push({ x: col * 15.4 + 34, label: d.getMonth() + 1 + "月" }); + } + var val = cell ? (metric === "sessions" ? cell.sessions : cell.tokens) : 0; + var tip = cell ? "" + esc(key) + "
" + cell.sessions + " 会话 · " + fmtTokens(cell.tokens) + (val > 0 ? "
点击筛选当天的会话
" : "") : "" + esc(key) + "
无活动"; + svgCells += + ''; + idx += 1; + } + var svg = ''; + monthMarks.forEach(function (m) { + svg += '' + esc(m.label) + ""; + }); + weekdayLabels.forEach(function (w, i) { + if (w) svg += '' + w + ""; + }); + svg += svgCells + ""; + return ( + svg + + '
近 8 周活跃度少 ' + + ["rgba(255,255,255,.06)", "#1d3a70", "#2b57b0", "#3f74e8", "#6d9bff"].map(function (col) { + return ''; + }).join("") + " 多
" + ); + } + + function trendChart(trend, windowTokens, mode, pinnedIndex) { + if (!trend.length) return '
暂无请求数据
'; + var width = 760; + var height = 250; + var n = trend.length; + var maxV = 1; + trend.forEach(function (t) { + maxV = Math.max(maxV, mode === "delta" ? Math.max(t.inputTokens, t.outputTokens) : t.inputTokens); + }); + if (mode !== "delta" && windowTokens > 0) maxV = Math.max(maxV, windowTokens * 1.06); + var maxDur = 1; + trend.forEach(function (t) { maxDur = Math.max(maxDur, t.durationMs || 0); }); + var padL = 8; + var yOf = function (v) { + return height - 30 - (v / maxV) * (height - 52); + }; + var ydOf = function (v) { + return height - 30 - (v / maxDur) * (height - 52); + }; + var xOf = function (i) { + return padL + (i / Math.max(1, n - 1)) * (width - padL * 2); + }; + var uid = "tg" + Math.floor(Math.random() * 1e6); + var inputLine = ""; + trend.forEach(function (t, i) { + inputLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + yOf(t.inputTokens).toFixed(1); + }); + var outputLine = ""; + trend.forEach(function (t, i) { + outputLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + yOf(t.outputTokens).toFixed(1); + }); + var durLine = ""; + trend.forEach(function (t, i) { + durLine += (i ? " L" : "M") + xOf(i).toFixed(1) + " " + ydOf(t.durationMs || 0).toFixed(1); + }); + var marks = ""; + if (mode !== "delta" && windowTokens > 0 && windowTokens <= maxV) { + marks += + '' + + '窗口 ' + fmtTokens(windowTokens) + ""; + } + trend.forEach(function (t, i) { + if (t.compact) { + marks += ''; + } + }); + // pinned marker + if (pinnedIndex != null && trend[pinnedIndex]) { + marks += ''; + } + // right-side duration ticks + var durTicks = [0, 0.5, 1].map(function (f) { + return { y: height - 30 - f * (height - 52), text: fmtDuration(maxDur * f) }; + }); + // per-point hover/click hit regions + var slot = (width - padL * 2) / Math.max(1, n); + var hits = ""; + trend.forEach(function (t, i) { + var prev = trend[i - 1]; + var deltaIn = prev ? t.inputTokens - prev.inputTokens : t.inputTokens; + var tip = + "" + (t.turn != null ? "第 " + t.turn + " 轮 · " : "") + "第 " + (t.index + 1) + " 步" + + "
输入 " + fmtTokens(t.inputTokens) + " · 输出 " + fmtTokens(t.outputTokens) + + "
缓存读 " + fmtTokens(t.cacheReadTokens) + "(" + fmtPct(t.cacheRate || 0) + ")" + + "
耗时 " + fmtDuration(t.durationMs) + (prev ? "
Δ输入 " + (deltaIn >= 0 ? "+" : "") + fmtTokens(deltaIn) : "") + + (t.compact ? "
✂ compact 压缩" : "") + + "
点击固定该步详情
"; + hits += + ''; + }); + var svg = + '' + + '' + + '' + + '' + + marks + + '' + + '' + + '' + + '' + + hits + + durTicks.map(function (t) { + return '' + esc(t.text) + ""; + }).join("") + + ""; + return svg; + } + + function timelineStrip(segments, totalLabel) { + var total = segments.reduce(function (a, s) { return a + s.value; }, 0) || 1; + var bars = segments + .filter(function (s) { return s.value > 0; }) + .map(function (s) { + return ''; + }) + .join(""); + return ( + '
' + bars + '
0' + esc(totalLabel || "") + "
" + ); + } + + /* ---------------------------------------------------------------- */ + /* State + views */ + /* ---------------------------------------------------------------- */ + + var state = { + tab: "overview", + range: 7, + directory: "", + directoryFilter: "", + order: "tokens", + search: "", + heatMetric: "tokens", + filter: null, + evFilter: null, + evOpen: {}, + fileFilter: "", + fileSearch: "", + fileOpen: {}, + browserOpen: {}, + pinnedStep: null, + trendGran: "step", + overview: null, + sessions: null, + sessionDetail: null, + detailSessionId: null, + trendMode: "total", + loading: false, + error: null, + overlayEl: null, + tabsEl: null, + bodyEl: null, + onKey: null, + observer: null, + }; + + function rangeStartMs() { + if (state.range === "today") { + var d = new Date(); + d.setHours(0, 0, 0, 0); + return d.getTime(); + } + return Date.now() - state.range * 86400000; + } + + function sectionCard(title, color, countText, bodyHtml) { + return ( + '

' + esc(title) + + (countText ? '' + countText + "" : "") + "

" + (bodyHtml || "") + "
" + ); + } + + function timeDonutParts(ts) { + return [ + { label: "模型等待", value: ts.modelWaitMs, valueText: fmtDuration(ts.modelWaitMs), color: C.amber, tip: "模型等待 " + fmtDuration(ts.modelWaitMs) + "
Σ 各请求首字延迟(ttft)
" }, + { label: "模型思考", value: ts.thinkMs || 0, valueText: fmtDuration(ts.thinkMs || 0), color: C.purple, tip: "模型思考 " + fmtDuration(ts.thinkMs || 0) + "
生成时长 × reasoning token 占比
" }, + { label: "模型回复", value: ts.replyMs || 0, valueText: fmtDuration(ts.replyMs || 0), color: C.green, tip: "模型回复 " + fmtDuration(ts.replyMs || 0) + "
生成时长 × 输出 token 占比
" }, + { label: "工具执行", value: ts.toolMs, valueText: fmtDuration(ts.toolMs), color: C.blue, tip: "工具执行 " + fmtDuration(ts.toolMs) + "
相邻请求之间的空闲间隔(≤120s)
" }, + { label: "其他", value: ts.otherMs, valueText: fmtDuration(ts.otherMs), color: C.gray, tip: "其他 " + fmtDuration(ts.otherMs) + "
活跃时长 − 上述各项
" }, + ]; + } + + function timeLegendRows(parts, counts) { + var total = parts.reduce(function (a, p) { return a + p.value; }, 0) || 1; + return parts + .filter(function (p) { return p.value > 0; }) + .map(function (p) { + var pct = ((p.value / total) * 100).toFixed(1) + "%"; + var extra = counts && counts[p.label] ? " · " + counts[p.label] : ""; + return ( + '
' + + '' + esc(p.label) + '' + pct + '
' + + '
≈' + esc(fmtDuration(p.value)) + esc(extra) + "
" + ); + }) + .join(""); + } + + function renderOverview(body) { + var o = state.overview; + if (!o) return; + var s = o.summary; + var ts = o.timeStats || { modelWaitMs: s.activeMs, toolMs: 0, otherMs: 0, outputSpeed: s.outputSpeed }; + var usageParts = usageDonutParts(o); + var timeParts = timeDonutParts(ts); + + var kpis = + kpi("全部会话", fmtTokens(s.sessions), "含 " + s.requests + " 次请求", { kpiId: "sessions", tip: "点击查看会话列表
按当前时间范围统计
" }) + + kpi("Token 消耗", fmtTokens(s.totalTokens), "输出 " + fmtTokens(s.outputTokens), { tip: "所有请求 computed_total_tokens 合计" }) + + kpi("预估费用", fmtCost(s.cost), "按公开牌价估算", { tip: "按内置牌价表估算:输入/输出/缓存读写分别计价
实际账单以模型服务商为准
" }) + + kpi("缓存命中", fmtPct(s.cacheHitRate), "cacheRead / input", { tip: "cache_read / input(dsh 口径)
越高说明缓存复用越好、费用越低
" }) + + kpi("工具调用", fmtTokens(s.toolCalls), "命令执行 " + s.commandExec + " 次" + ((o.commandStats && o.commandStats.failed) ? " · 失败 " + o.commandStats.failed : ""), { kpiId: "tools", tip: "点击滚动到工具调用排行" }) + + kpi("活跃时长", fmtDuration(ts.modelWaitMs + (ts.thinkMs || 0) + (ts.replyMs || 0)), "模型等待 · " + s.outputSpeed + " tok/s", { tip: "模型等待=Σ首字延迟;生成按思考/回复 token 占比拆分
工具执行按请求间隙估算
" }); + + var usageCard = sectionCard("近 7 天用量", "", "", '
' + usageChart(o.series.slice(-14)) + + '
Token费用
'); + + var tokenCard = sectionCard("Token 统计", C.blue, "", '
' + + donut(usageParts, 118, 13, fmtTokens(s.totalTokens), "总用量") + + legendGrid(usageParts) + "
"); + + var timeTotalMs = ts.modelWaitMs + (ts.thinkMs || 0) + (ts.replyMs || 0) + ts.toolMs + ts.otherMs; + var timeCard = sectionCard("耗时统计", C.amber, "输出速度 " + s.outputSpeed + " tok/s", '
' + + donut(timeParts, 118, 13, fmtDuration(timeTotalMs), "总时长") + + '
' + timeLegendRows(timeParts, { 模型等待: s.requests + " 次", 工具执行: s.toolCalls + " 次" }) + "
" + + timelineStrip(timeParts, fmtDuration(ts.spanMs || timeTotalMs))); + + var heatCard = sectionCard("活跃", C.green, "", '
' + + '' + + '
' + + '
' + heatmap(o.heatmap, state.heatMetric) + "
"); + + var toolItems = (o.tools || []).slice(0, 10).map(function (t, i) { + return { name: t.tool, calls: t.calls }; + }); + var toolCard = sectionCard("工具调用", C.cyan, (o.tools || []).length + " 个工具 · " + s.toolCalls + " 次调用", + '
' + rankRows(toolItems, function (it, i) { return RANK_COLORS[i % RANK_COLORS.length]; }, { filterType: "tool" }) + "
"); + + var cmdItems = (o.commands || []).map(function (c, i) { + return { name: c.cmd, calls: c.calls }; + }); + var cmdCard = sectionCard("命令执行", C.red, (o.commands || []).length + " 个程序 · " + s.commandExec + " 次执行" + ((o.commandStats && o.commandStats.failed) ? " · 失败 " + o.commandStats.failed : ""), + '
' + rankRows(cmdItems, function (it, i) { return RANK_COLORS[(i + 3) % RANK_COLORS.length]; }, { filterType: "cmd" }) + "
"); + + var projItems = (o.projects || []).slice(0, 8).map(function (p, i) { + return { name: p.workspace, calls: Math.max(1, Math.round(p.tokens / 1e6)), tip: "" + esc(p.workspace) + "
" + fmtTokens(p.tokens) + " tokens · " + p.sessions + " 会话
点击筛选该工作区的会话
" }; + }); + var projCard = sectionCard("项目分布", C.purple, (o.projects || []).length + " 个工作区", + '
' + rankRows(projItems, function (it, i) { return RANK_COLORS[(i + 5) % RANK_COLORS.length]; }, { filterType: "proj" }) + "
"); + + var filteredCards = applyCardFilter((o.sessions || []).slice(0, 12)); + var sessionsHtml = renderSessionCards(filteredCards, true); + var sessCard = + '

会话共 ' + s.sessions + "

" + + '
' + + '' + + '
' + + filterChipHtml() + + '
' + (sessionsHtml || '
没有匹配当前筛选的会话
') + "
"; + + body.innerHTML = + '
' + kpis + "
" + usageCard + "
" + + '
' + tokenCard + timeCard + "
" + + '
' + heatCard + toolCard + cmdCard + projCard + "
" + + sessCard; + + body.querySelectorAll("[data-session]").forEach(function (el) { + el.addEventListener("click", function () { + openSession(el.getAttribute("data-session")); + }); + }); + body.querySelectorAll("[data-order]").forEach(function (el) { + el.addEventListener("click", function () { + state.order = el.getAttribute("data-order"); + load(); + }); + }); + body.querySelectorAll("[data-heat]").forEach(function (el) { + el.addEventListener("click", function () { + state.heatMetric = el.getAttribute("data-heat"); + render(); + }); + }); + body.querySelectorAll("[data-kpi]").forEach(function (el) { + el.addEventListener("click", function () { + var id = el.getAttribute("data-kpi"); + if (id === "sessions") { + state.tab = "sessions"; + if (!state.sessions) loadSessions(); + else render(); + } else if (id === "tools") { + var card = body.querySelectorAll(".zci-card")[3]; + try { + var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) { + return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("工具调用") >= 0; + }); + if (target) target.scrollIntoView({ behavior: "smooth", block: "center" }); + } catch { + // ignore + } + } + }); + }); + // heatmap day cells -> filter session cards by that day + body.querySelectorAll("[data-day]").forEach(function (el) { + el.addEventListener("click", function () { + var day = el.getAttribute("data-day"); + if (state.filter && state.filter.type === "day" && state.filter.value === day) state.filter = null; + else state.filter = { type: "day", value: day, label: day }; + render(); + }); + }); + body.querySelectorAll("[data-fclear]").forEach(function (el) { + el.addEventListener("click", function () { + state.filter = null; + render(); + }); + }); + bindFilterRows(body, render); + bindAll(body); + } + + function kpi(label, value, sub, opts) { + opts = opts || {}; + return ( + '
' + esc(label) + + '
' + esc(value) + '
' + esc(sub || "") + "
" + ); + } + + function compositionParts(detail) { + var c = detail.composition; + return [ + { label: "系统提示词", value: c.systemPrompt.tokens, color: C.purple }, + { label: "工具定义", value: c.toolDefs.tokens, color: C.amber }, + { label: "用户消息", value: c.userMessages.tokens, color: C.blue }, + { label: "助手回复", value: c.assistantText.tokens, color: C.green }, + { label: "思考", value: c.reasoning.tokens, color: C.cyan }, + { label: "工具结果", value: c.toolResults.tokens, color: C.red }, + ]; + } + + function usageDonutParts(overview) { + var s = overview.summary; + return [ + { label: "缓存读取", value: s.cacheReadTokens, color: C.cyan }, + { label: "模型思考", value: s.reasoningTokens, color: C.purple }, + { label: "未命中输入", value: Math.max(0, s.inputTokens - s.cacheReadTokens - s.cacheWriteTokens), color: C.blue }, + { label: "输出", value: s.outputTokens, color: C.green }, + { label: "缓存写入", value: s.cacheWriteTokens, color: C.amber }, + ]; + } + + function sessionUsageParts(d) { + var agg = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 }; + (d.usage || []).forEach(function (r) { + agg.input += r.inputTokens || 0; + agg.output += r.outputTokens || 0; + agg.reasoning += r.reasoningTokens || 0; + agg.cacheRead += r.cacheReadTokens || 0; + }); + return [ + { label: "缓存读取", value: agg.cacheRead, color: C.cyan }, + { label: "模型思考", value: agg.reasoning, color: C.purple }, + { label: "未命中输入", value: Math.max(0, agg.input - agg.cacheRead - agg.cacheWrite), color: C.blue }, + { label: "输出", value: agg.output, color: C.green }, + ]; + } + + function renderSessionCards(list, compact) { + var maxTokens = 1; + list.forEach(function (card) { maxTokens = Math.max(maxTokens, card.tokens || 0); }); + return list + .map(function (card) { + var pct = card.contextPct != null ? Math.min(100, Math.round(card.contextPct * 100)) : Math.min(100, Math.round(((card.tokens || 0) / maxTokens) * 100)); + var donutColor = pct >= 85 ? C.red : C.blue; + var miniDonut = + donut( + [ + { label: "上下文占比", value: Math.max(0.5, pct), color: donutColor }, + { label: "余量", value: Math.max(0, 100 - pct), color: "rgba(255,255,255,.08)" }, + ], + 56, + 7, + pct + "%", + "上下文", + ); + return ( + '
' + + '
' + esc(card.title) + '' + esc(fmtTime(card.lastActiveAt)) + "
" + + '
' + esc(card.workspace) + "" + + (card.compacted ? '已压缩' : "") + "
" + + '
' + miniDonut + + '
轮次 ' + (card.turns || card.requests) + "Token " + fmtTokens(card.tokens) + "" + + '总预估费用 ' + fmtCost(card.cost) + "
" + + (card.lastMessage ? '
' + esc(card.lastMessage.slice(0, 80)) + "
" : "") + + "
" + ); + }) + .join(""); + } + + function renderSessions(body) { + var list = state.sessions || []; + var workspaces = {}; + list.forEach(function (s) { workspaces[s.workspace] = true; }); + var kw = state.search.trim().toLowerCase(); + var filtered = list.filter(function (s) { + return (!state.directoryFilter || s.workspace === state.directoryFilter) && + (!kw || String(s.title).toLowerCase().indexOf(kw) >= 0); + }); + var chips = Object.keys(workspaces) + .slice(0, 9) + .map(function (w) { + var n = list.filter(function (s) { return s.workspace === w; }).length; + return '' + esc(w) + " " + n + ""; + }) + .join(""); + body.innerHTML = + '

会话共 ' + list.length + "

" + + '' + + '
' + + '' + + '' + + '
' + + '
全部 ' + list.length + "" + chips + "
" + + '
' + renderSessionCards(filtered, false) + "
"; + body.querySelector(".zci-search").addEventListener("input", function (e) { + state.search = e.target.value; + render(); + var input = body.querySelector(".zci-search"); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + }); + body.querySelectorAll("[data-session]").forEach(function (el) { + el.addEventListener("click", function () { + openSession(el.getAttribute("data-session")); + }); + }); + body.querySelectorAll("[data-ws]").forEach(function (el) { + el.addEventListener("click", function () { + state.directoryFilter = el.getAttribute("data-ws"); + render(); + }); + }); + body.querySelectorAll("[data-order]").forEach(function (el) { + el.addEventListener("click", function () { + state.order = el.getAttribute("data-order"); + loadSessions(); + }); + }); + bindAll(body); + } + + function ioCell(label, value) { + return '' + esc(label) + ' ' + value + ""; + } + + /** clickable io chip: filters the 文件活动 card (op) or 事件日志 card (tool). */ + function ioCellChip(label, value, op) { + if (!value) return ioCell(label, value); + return '' + esc(label) + ' ' + value + ""; + } + + function renderSessionDetail(body) { + var d = state.sessionDetail; + if (!d) return; + var st = d.stats; + var io = d.ioCounts || { user: 0, read: 0, write: 0, edit: 0, search: 0, bash: 0, tools: 0 }; + var compParts = compositionParts(d); + var compTotal = compParts.reduce(function (a, p) { return a + p.value; }, 0) || 1; + var win = st.contextWindow || 1; + var usedPct = Math.min(1, st.currentContextTokens / win); + + // context bar segments with hatched reserve; hover tip + click -> browser group + var segs = compParts + .filter(function (p) { return p.value > 0; }) + .map(function (p) { + var segPct = (p.value / compTotal) * 100; + var tip = "" + p.label + " ≈" + fmtTokens(p.value) + " · " + segPct.toFixed(1) + "% 占已用
点击在上下文浏览器中展开
"; + return ''; + }) + .join(""); + var reserve = Math.max(0, 100 - usedPct * 100); + + var headerStats = + '
' + d.turns + '
轮次
点击查看趋势
' + + '
' + d.requests + '
步骤
点击查看趋势
' + + '
' + io.tools + '
工具调用
' + + '
' + (d.composition.assistantText.items || 0) + '
回复块
' + + '
' + fmtTokens(st.totalTokens) + '
Token 总用量
' + fmtCost(st.cost) + "
"; + + var ioLine = + '
输入输出' + + ioCell("用户", io.user) + + ioCellChip("读取", io.read, "read") + ioCellChip("写入", io.write, "write") + + ioCellChip("搜索", io.search, "search") + ioCellChip("命令", io.bash, "bash") + + '合计 ' + (io.read + io.write + io.edit + io.search + io.bash) + "
"; + + var toolItems = (d.toolTop || []).map(function (t) { + return { name: t.tool, calls: t.calls }; + }); + var ioToolsLine = + '
工具调用' + + toolItems + .slice(0, 8) + .map(function (t) { + return '' + esc(t.tool) + ' ' + t.calls + ""; + }) + .join("") + + '合计 ' + io.tools + "
"; + + var ts = (st && st.timing) || d.timeStats || { modelWaitMs: st.modelMs, toolMs: 0, otherMs: 0, outputSpeed: st.outputSpeed }; + var timeParts = timeDonutParts(ts); + var sUsageParts = sessionUsageParts(d); + var cacheRate = st.cacheRate != null ? st.cacheRate : 0; + + var costComp = d.costComposition || { totalTokens: st.compositionTokens, totalCost: st.cost, parts: [] }; + var costColors = [C.purple, C.amber, C.blue, C.green, C.cyan, C.red]; + var costParts = (costComp.parts || []).map(function (p, i) { + return { + label: p.label, + value: Math.max(0.5, p.pct * 100), + valueText: "¥" + Number(p.cost).toFixed(5), + color: costColors[i % costColors.length], + tip: "" + esc(p.label) + " ≈" + fmtTokens(Math.max(0.5, p.pct * 100) / 100 * costComp.totalTokens) + " tokens
≈¥" + Number(p.cost).toFixed(5) + "
按最近模型牌价估算
", + }; + }); + + // browser groups (collapsible; expand content per group) + var compGroups = [ + { label: "系统提示词", detail: (d.promptPreview || "(无快照)"), raw: true }, + { label: "工具定义", toolNames: true }, + { label: "用户消息", items: (d.composition.userMessages.items || 0) + " 条" }, + { label: "助手回复", items: (d.composition.assistantText.items || 0) + " 块" }, + { label: "思考", items: (d.composition.reasoning.items || 0) + " 段" }, + { label: "工具结果", items: (d.composition.toolResults.items || 0) + " 条" }, + ]; + var browserRows = compParts + .map(function (p) { + var g = compGroups.find(function (x) { return x.label === p.label; }) || {}; + var pct = ((p.value / compTotal) * 100).toFixed(1) + "%"; + var add = g.items ? '+' + esc(g.items) + "" : ""; + var detail = ""; + if (g.raw) { + detail = '
系统提示词快照(前 400 字):
' + esc(g.detail) + (g.detail && g.detail.length >= 400 ? "…" : "") + "
"; + } else if (g.toolNames) { + var names = d.toolNames || []; + detail = '
工具定义 ' + names.length + " 个:
" + + (names.length ? '
' + names.map(function (n) { return "" + esc(n) + ""; }).join("") + "
" : "
(未捕获工具清单)
") + "
"; + } else { + detail = '
条目数:' + esc(g.items || "0") + " · 占已用上下文 " + pct + "
"; + } + var open = state.browserOpen[p.label] ? " open" : ""; + return ( + '
▶' + + '' + esc(p.label) + '' + add + + '≈' + fmtTokens(p.value) + '' + pct + "
" + detail + "
" + ); + }) + .join(""); + + var fileTotalWrite = (d.files || []).reduce(function (a, f) { return a + f.write; }, 0); + var fileTotalEdit = (d.files || []).reduce(function (a, f) { return a + f.edit; }, 0); + var fileTotalRead = (d.files || []).reduce(function (a, f) { return a + f.read; }, 0); + + // --- 趋势数据:步进=逐请求;轮次=按 turn 聚合 --- + var trendRows = d.trend || []; + if (state.trendGran === "turn") { + var byTurn = new Map(); + trendRows.forEach(function (t) { + var key = t.turn || 1; + var agg = byTurn.get(key); + if (!agg) { + agg = { index: key - 1, turn: key, startedAt: t.startedAt, inputTokens: 0, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0, cacheRate: 0, durationMs: 0, compact: false, n: 0 }; + byTurn.set(key, agg); + } + agg.inputTokens = Math.max(agg.inputTokens, t.inputTokens); + agg.outputTokens += t.outputTokens; + agg.reasoningTokens += t.reasoningTokens; + agg.cacheReadTokens += t.cacheReadTokens; + agg.durationMs += t.durationMs || 0; + agg.compact = agg.compact || t.compact; + agg.n += 1; + }); + trendRows = Array.from(byTurn.values()).sort(function (a, b) { return a.index - b.index; }); + if (state.pinnedStep != null && !trendRows[state.pinnedStep]) state.pinnedStep = null; + } + + function requestDetailHtml(dd) { + if (state.pinnedStep == null) return ""; + var t = (dd.trend || [])[state.pinnedStep]; + var u = (dd.usage || [])[state.pinnedStep]; + if (!t && !u) return ""; + var row = u || t; + var input = row.inputTokens != null ? row.inputTokens : row.input_tokens; + var output = row.outputTokens != null ? row.outputTokens : row.output_tokens; + var cacheRead = row.cacheReadTokens != null ? row.cacheReadTokens : row.cache_read_input_tokens; + var model = row.modelId || row.model_id || ""; + var prev = (dd.trend || [])[state.pinnedStep - 1]; + var delta = prev ? input - prev.inputTokens : input; + return ( + '
' + (t && t.turn != null ? "第 " + t.turn + " 轮 · " : "") + "第 " + (state.pinnedStep + 1) + " 步" + + "输入 " + fmtTokens(input) + "输出 " + fmtTokens(output) + "" + + "缓存命中 " + fmtPct(t && t.cacheRate) + "" + + "Δ输入 " + (delta >= 0 ? "+" : "") + fmtTokens(delta) + "" + + "耗时 " + fmtDuration(row.durationMs) + "" + + (model ? "" + esc(String(model).slice(0, 40)) + "" : "") + + '✕ 取消固定
' + ); + } + + // --- 事件:筛选 + 计数 + 行点击展开 --- + var evCounts = { all: (d.events || []).length, message: 0, tool: 0, compact: 0, error: 0 }; + (d.events || []).forEach(function (ev) { + if (evCounts[ev.kind] != null) evCounts[ev.kind] += 1; + }); + var evShown = (d.events || []).filter(function (ev) { + if (state.evFilter && state.evFilter.indexOf("tool:") === 0) return ev.kind === "tool" && ev.tool === state.evFilter.slice(5); + if (!state.evFilter) return true; + return ev.kind === state.evFilter; + }); + var eventsHtml = + evShown + .slice(-120) + .reverse() + .map(function (ev) { + var cls = ev.kind === "error" ? "zci-t-error" : ev.kind === "compact" ? "zci-t-compact" : ev.kind === "tool" ? "zci-t-tool" : "zci-t-msg"; + var tag = ev.kind === "tool" ? ev.tool : ev.kind === "compact" ? "压缩" : ev.kind === "error" ? "错误" : "消息"; + var id = "ev" + (ev.at || 0) + Math.abs(String(ev.text || "").length); + var open = state.evOpen[id] ? " open" : ""; + var long = (ev.text || "").length > 96; + return ( + '
点击展开全文
" : "") + '">' + + '' + esc(fmtTime(ev.at)) + '' + esc(String(tag).slice(0, 10)) + '' + + '' + esc(ev.text || "") + "" + + (long && !state.evOpen[id] ? '展开' : "") + + "" + ); + }) + .join("") || '
暂无事件
'; + + // --- 文件:筛选 + 搜索 + 行点击展开明细 --- + var filesFiltered = (d.files || []).filter(function (f) { + if (state.fileFilter && !(f[state.fileFilter] > 0)) return false; + if (state.fileSearch) { + var kw = state.fileSearch.toLowerCase(); + return String(f.file || "").toLowerCase().indexOf(kw) >= 0 || String(f.full || "").toLowerCase().indexOf(kw) >= 0; + } + return true; + }); + var filesHtml = + filesFiltered + .slice(0, 60) + .map(function (f, i) { + var id = String(f.full || f.file || i); + var open = state.fileOpen[id] ? " open" : ""; + return ( + '

读 " + f.read + " · 写 " + f.write + " · 改 " + f.edit + "
点击展开明细
\">" + + '' + esc(f.file) + '' + esc(fmtTime(f.at)) + '' + + '读 ' + f.read + " · 写 " + f.write + " · 改 " + f.edit + "" + + '完整路径:' + esc(f.full) + " · 最后操作 " + esc(fmtTime(f.at)) + "
" + ); + }) + .join("") || '
暂无文件活动
'; + + var parts = [ + '
' + esc(d.session.title) + "" + + '' + esc(d.session.workspace) + "" + + '' + esc(fmtTime(d.session.startedAt)) + "
", + + '

上下文统计

' + + '
' + headerStats + "
" + ioLine + ioToolsLine + "
", + + '
' + + sectionCard("Token 统计", C.blue, "缓存命中 " + fmtPct(cacheRate), + '
' + donut(sUsageParts, 110, 12, fmtTokens(st.totalTokens), "总用量") + legendGrid(sUsageParts, { cols: 2 }) + "
") + + sectionCard("耗时统计", C.amber, "输出速度 " + st.outputSpeed + " tok/s", + '
' + donut(timeParts, 110, 12, fmtDuration(ts.modelWaitMs), "总时长") + + '
' + timeLegendRows(timeParts, { 模型等待: d.requests + " 次" }) + "
" + + timelineStrip(timeParts, fmtDuration(ts.spanMs || ts.modelWaitMs))) + + "
", + + '
' + + '

当前上下文' + esc(st.lastModelId) + "

" + + '
' + fmtTokens(st.currentContextTokens) + ' / ' + fmtTokens(win) + " tokens" + + '' + fmtPct(usedPct) + " 上下文已用
" + + '
' + segs + '
' + + '
' + compParts.map(function (p) { + var pct = ((p.value / compTotal) * 100).toFixed(0) + "%"; + return '
' + esc(p.label) + '' + pct + '
≈' + fmtTokens(p.value) + "
"; + }).join("") + "
", + + '

上下文费用构成估算 · CNY

' + + '
' + + '
≈ ' + fmtTokens(costComp.totalTokens) + '当前上下文tokens(估算)
' + + '
≈ ' + (Number(costComp.totalCost) || 0).toFixed(5) + '非缓存输入估算按最近模型牌价
' + + '
' + + '
' + donut(costParts, 104, 12, "", "") + + '
100%参考费用占比
' + + legendGrid(costParts, { cols: 2 }) + "
", + "
", + + '

上下文趋势' + d.requests + " 步 · " + d.turns + " 轮

" + + '
' + + '' + + '
' + + '
' + + '
' + + '紫虚线为单请求耗时 · 悬停查看摘要 · 点击固定该步
' + + trendChart(trendRows, win, state.trendMode, state.pinnedStep) + + requestDetailHtml(d) + + '
输入(上下文)输出compact 压缩模型窗口上限
', + + '
' + + '

事件日志' + evCounts.all + " 条

" + + '
' + + '全部 ' + evCounts.all + "" + + '消息 ' + evCounts.message + "" + + '工具 ' + evCounts.tool + "" + + '压缩 ' + evCounts.compact + "" + + '错误 ' + evCounts.error + "" + + (state.evFilter && state.evFilter.indexOf("tool:") === 0 ? '' + esc(state.evFilter.slice(5)) + " ✕" : "") + + "
" + + '
' + eventsHtml + "
" + + '

文件活动' + filesFiltered.length + "/" + (d.files || []).length + " 个 · 读" + fileTotalRead + " 写" + fileTotalWrite + " 改" + fileTotalEdit + "

" + + '
' + + '全部' + + '读取 ' + fileTotalRead + "" + + '写入 ' + fileTotalWrite + "" + + '编辑 ' + fileTotalEdit + "" + + '
' + + filesHtml + "
", + + '
' + + '

上下文浏览器DNA 模式 · 当前

' + + '
' + browserRows + "
" + + '

上下文注入与事件注入 ' + (d.composition.systemPrompt.items || 0) + "

" + + '
' + + '本轮会话注入 ' + (d.composition.systemPrompt.items || 0) + ' 项系统提示 · 工具定义 ' + (d.composition.toolDefs.items || 0) + " 项
" + + '
上下文文件(注入/压缩/切换事件)与压缩历史暂由事件日志承载。
', + ]; + body.innerHTML = parts.join(""); + body.querySelector("[data-back]").addEventListener("click", backToOverview); + body.querySelectorAll("[data-tmode]").forEach(function (btn) { + btn.addEventListener("click", function () { + state.trendMode = btn.getAttribute("data-tmode"); + render(); + }); + }); + body.querySelectorAll("[data-tgran]").forEach(function (btn) { + btn.addEventListener("click", function () { + state.trendGran = btn.getAttribute("data-tgran"); + state.pinnedStep = null; + render(); + }); + }); + // header figures -> scroll to the trend card + body.querySelectorAll("[data-jump]").forEach(function (el) { + el.addEventListener("click", function () { + var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) { + return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("上下文趋势") >= 0; + }); + if (target) target.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + }); + // io chips -> filter file card + scroll + body.querySelectorAll("[data-iofilter]").forEach(function (el) { + el.addEventListener("click", function () { + var op = el.getAttribute("data-iofilter"); + state.fileFilter = state.fileFilter === op ? "" : op; + var target = Array.from(body.querySelectorAll(".zci-card")).find(function (c) { + return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("文件活动") >= 0; + }); + if (target) { + render(); + var again = Array.from(state.bodyEl.querySelectorAll(".zci-card")).find(function (c) { + return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("文件活动") >= 0; + }); + if (again) again.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }); + }); + // tool chips -> filter event log to that tool + scroll + body.querySelectorAll("[data-evtool]").forEach(function (el) { + el.addEventListener("click", function () { + var tool = el.getAttribute("data-evtool"); + state.evFilter = state.evFilter === "tool:" + tool ? null : "tool:" + tool; + render(); + var again = Array.from(state.bodyEl.querySelectorAll(".zci-card")).find(function (c) { + return c.querySelector("h3") && c.querySelector("h3").textContent.indexOf("事件日志") >= 0; + }); + if (again) again.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + }); + // ctx bar segments -> expand + scroll to browser group + body.querySelectorAll(".zci-ctxbar [data-bgroup]").forEach(function (el) { + el.addEventListener("click", function () { + var label = el.getAttribute("data-bgroup"); + state.browserOpen[label] = true; + render(); + var row = state.bodyEl.querySelector('[data-brow="' + label.replace(/"/g, '\\"') + '"]'); + if (row) row.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + }); + // ctx bar hover <-> browser row highlight (bidirectional) + var ctxSegs = body.querySelectorAll(".zci-ctxbar [data-bgroup]"); + var browRows = body.querySelectorAll("[data-brow]"); + function hotGroup(label) { + ctxSegs.forEach(function (s) { + s.classList.toggle("zci-seg-hot", s.getAttribute("data-bgroup") === label); + }); + browRows.forEach(function (r) { + r.classList.toggle("zci-bhot", r.getAttribute("data-brow") === label); + }); + } + ctxSegs.forEach(function (s) { + s.addEventListener("mouseenter", function () { hotGroup(s.getAttribute("data-bgroup")); }); + s.addEventListener("mouseleave", function () { hotGroup(null); }); + }); + browRows.forEach(function (r) { + r.addEventListener("mouseenter", function () { hotGroup(r.getAttribute("data-brow")); }); + r.addEventListener("mouseleave", function () { hotGroup(null); }); + }); + // browser rows expand/collapse + body.querySelectorAll("[data-brow]").forEach(function (row) { + row.addEventListener("click", function (e) { + if (e.target.closest(".bdetail a")) return; + var label = row.getAttribute("data-brow"); + state.browserOpen[label] = !state.browserOpen[label]; + render(); + }); + }); + // event pills + body.querySelectorAll("[data-evk]").forEach(function (el) { + el.addEventListener("click", function () { + var k = el.getAttribute("data-evk"); + state.evFilter = k || null; + render(); + }); + }); + body.querySelectorAll("[data-evk-clear]").forEach(function (el) { + el.addEventListener("click", function () { + state.evFilter = null; + render(); + }); + }); + // event rows expand full text + body.querySelectorAll("[data-ev]").forEach(function (row) { + row.addEventListener("click", function () { + var id = row.getAttribute("data-ev"); + state.evOpen[id] = !state.evOpen[id]; + render(); + }); + }); + // file pills + search + row expand + body.querySelectorAll("[data-fop]").forEach(function (el) { + el.addEventListener("click", function () { + state.fileFilter = el.getAttribute("data-fop") || ""; + render(); + }); + }); + var fileSearch = body.querySelector("[data-filesearch]"); + if (fileSearch) { + fileSearch.addEventListener("input", function (e) { + state.fileSearch = e.target.value; + render(); + var again = state.bodyEl.querySelector("[data-filesearch]"); + if (again) { + again.focus(); + again.setSelectionRange(again.value.length, again.value.length); + } + }); + } + body.querySelectorAll("[data-file]").forEach(function (row) { + row.addEventListener("click", function () { + var id = row.getAttribute("data-file"); + state.fileOpen[id] = !state.fileOpen[id]; + render(); + }); + }); + // trend points: click to pin request detail + body.querySelectorAll(".zci-trend-hit").forEach(function (hit) { + hit.addEventListener("click", function () { + var i = Number(hit.getAttribute("data-step")); + state.pinnedStep = state.pinnedStep === i ? null : i; + render(); + }); + }); + var unpin = body.querySelector("[data-unpin]"); + if (unpin) { + unpin.addEventListener("click", function () { + state.pinnedStep = null; + render(); + }); + } + bindAll(body); + } + + function renderAgents(body) { + var data = state.agentsData; + if (!data) return; + var maxTokens = Math.max.apply( + null, + [1].concat( + data.agents.map(function (a) { return a.tokens; }), + ), + ); + var main = { name: data.sessionTitle, tokens: 0, requests: 0, activeMs: 0 }; + var usageMain = data.events.filter(function (e) { return e.sessionId === data.sessionId; }); + if (usageMain.length) { + main.tokens = usageMain.reduce(function (a, e) { return a + e.inputTokens + e.outputTokens; }, 0); + main.requests = usageMain.length; + } + var running = data.agents.filter(function (a) { return a.lastAt > Date.now() - 3600000; }).length; + var msgs = data.events.length; + + var agentColors = [C.blue, C.purple, C.cyan, C.green, C.amber, C.red]; + function pressCls(pct) { + return pct >= 90 ? "zci-press-hot" : pct >= 70 ? "zci-press-warn" : "zci-press-ok"; + } + function nodeCard(a, color, badge, child) { + var pct = Math.max(2, Math.min(100, (a.tokens / maxTokens) * 100)); + var ctxPct = a.contextPct != null ? a.contextPct * 100 : null; + var tip = + "" + esc(a.name) + "" + + "
Token " + fmtTokens(a.tokens) + " · " + (a.requests || 0) + " 请求" + + "
活跃 " + fmtDuration(a.activeMs || 0) + + (child && child.childSessionId ? "
子会话 " + esc(child.childSessionId.slice(0, 24)) + "…
点击打开该子会话详情
" : "
主会话节点
"); + return ( + '
' + + '
' + esc(badge) + "" + + '
' + esc(a.name) + '
' + fmtTokens(a.tokens) + " · " + fmtCost(a.cost || 0) + "
" + + (ctxPct != null ? '' + Math.round(ctxPct) + "%" : "") + "
" + + '
' + + '
' + esc(fmtDuration(a.activeMs || 0)) + "" + (a.requests || 0) + " 请求" + + (a.desc ? "" + esc(String(a.desc).slice(0, 40)) + "" : "") + "
" + ); + } + + var childRows = (data.agents || []) + .map(function (a, i) { + var col = agentColors[i % agentColors.length]; + var badge = String(a.profileName || a.profileId || "AG").slice(0, 2).toUpperCase(); + return nodeCard( + { + name: a.profileName || a.profileId || "agent", + tokens: a.tokens, + cost: 0, + activeMs: a.activeMs, + requests: a.requests, + desc: a.description, + contextPct: a.contextPct, + }, + col, + badge, + a, + ); + }) + .join(""); + + var evRows = (data.events || []) + .slice(-100) + .reverse() + .map(function (ev, i) { + var who = ev.sessionId === data.sessionId ? "主" : "子"; + var id = "ag" + i; + var open = state.evOpen[id] ? " open" : ""; + return ( + '
点击展开
\">" + esc(fmtTime(ev.at)) + '[' + esc(who) + ']' + + '' + esc(ev.modelId) + " · " + fmtTokens(ev.inputTokens) + " → " + fmtTokens(ev.outputTokens) + '' + (open ? "收起" : "展开") + "" + ); + }) + .join(""); + + var parts = [ + '
编队视图
', + '
多 Agent 编队
在同一时间线上,观察 Agent 间的通讯、活跃状态与委派工作。
' + + '
' + ((data.agents || []).length + 1) + '
Agent
' + + '
' + running + '
运行中
' + + '
' + (data.agents || []).length + '
工作项
' + + '
' + msgs + '
消息
', + '
' + + '

Agent 网络' + ((data.agents || []).length + 1) + " 个节点

" + + '
' + nodeCard({ name: main.name, tokens: main.tokens, requests: main.requests, activeMs: 0, cost: 0 }, C.blue, "主") + + ((data.agents || []).length ? '
' + childRows + "
" : "") + + "
" + + '

跨 Agent 协作' + msgs + " 条消息

" + + '
' + (evRows || '
这个编队还没有跨 Agent 事件
') + "
", + ]; + body.innerHTML = parts.join(""); + body.querySelector("[data-back]").addEventListener("click", backToOverview); + // 点击子 Agent 节点 -> 打开该子会话详情 + body.querySelectorAll("[data-open]").forEach(function (el) { + el.addEventListener("click", function () { + openSession(el.getAttribute("data-open"), "session"); + }); + }); + // hover 子节点 -> 点亮自身与主节点(父链),其余降透明 + var net = body.querySelector(".zci-net"); + if (net) { + net.addEventListener("mouseover", function (e) { + var node = e.target.closest("[data-agent]"); + net.classList.toggle("zci-dimnet", Boolean(node)); + var mainCard = net.querySelector(".zci-node"); + body.querySelectorAll("[data-agent]").forEach(function (n) { + n.classList.toggle("zci-chain", n === node); + }); + if (mainCard) mainCard.classList.toggle("zci-chain", Boolean(node)); + }); + net.addEventListener("mouseleave", function () { + net.classList.remove("zci-dimnet"); + body.querySelectorAll("[data-agent]").forEach(function (n) { + n.classList.remove("zci-chain"); + }); + var mainCard = net.querySelector(".zci-node"); + if (mainCard) mainCard.classList.remove("zci-chain"); + }); + } + // 协作事件行点击展开 + body.querySelectorAll("[data-agev]").forEach(function (row) { + row.addEventListener("click", function () { + var id = row.getAttribute("data-agev"); + state.evOpen[id] = !state.evOpen[id]; + render(); + }); + }); + bindAll(body); + } + + function render() { + var body = state.bodyEl; + if (!body) return; + var tabs = + '
洞察
' + + '
会话
' + + '
上下文
' + + '
编队
'; + state.tabsEl.innerHTML = tabs; + state.tabsEl.querySelectorAll("[data-tab]").forEach(function (el) { + el.addEventListener("click", function () { + var target = el.getAttribute("data-tab"); + if ((target === "session" || target === "agents") && !state.detailSessionId) { + // pick the most recently active session — the one the user is likely in + var sessions = (state.sessions && state.sessions.length ? state.sessions : null) || + (state.overview && state.overview.sessions) || []; + var pick = null; + sessions.forEach(function (s) { + if (!pick || (s.lastActiveAt || 0) > (pick.lastActiveAt || 0)) pick = s; + }); + if (pick && pick.id) { + openSession(pick.id, target); + return; + } + state.tab = "sessions"; + loadSessions(true); + return; + } + state.tab = target; + if (state.tab === "agents") loadAgents(state.detailSessionId); + else if (state.tab === "sessions" && !state.sessions) loadSessions(); + else render(); + }); + }); + + if (state.error) { + body.innerHTML = + '
' + esc(state.error) + '
'; + body.querySelector("[data-retry]").addEventListener("click", function () { + state.error = null; + load(); + }); + return; + } + if (state.loading) { + body.innerHTML = '
加载中…
'; + return; + } + function safe(name, fn) { + try { + fn(); + } catch (e) { + body.innerHTML = + '
渲染出错(' + esc(name) + '):
' +
+          esc(String((e && (e.stack || e.message)) || e)) + "
"; + } + } + if (state.tab === "overview") safe("overview", function () { renderOverview(body); }); + else if (state.tab === "sessions") safe("sessions", function () { renderSessions(body); }); + else if (state.tab === "session") safe("session", function () { renderSessionDetail(body); }); + else if (state.tab === "agents") safe("agents", function () { renderAgents(body); }); + } + + /* ---------------------------------------------------------------- */ + /* Loading */ + /* ---------------------------------------------------------------- */ + + function load() { + if (state.tab === "agents" && state.detailSessionId) return loadAgents(state.detailSessionId); + state.loading = true; + state.error = null; + render(); + var args = { start: rangeStartMs(), end: Date.now(), order: state.order }; + if (state.tab === "sessions") return loadSessions(); + query("overview", args) + .then(function (data) { + state.overview = data && data.ok !== false ? data : state.overview; + state.loading = false; + if (state.tab === "overview") render(); + }) + .catch(function (e) { + state.loading = false; + state.error = e.message; + if (state.tab === "overview") render(); + }); + } + + function loadSessions(openAfter) { + state.loading = true; + state.error = null; + render(); + query("sessions", { start: rangeStartMs(), end: Date.now(), order: state.order, limit: 60 }) + .then(function (data) { + state.sessions = (data && data.sessions) || []; + state.loading = false; + if (openAfter && state.sessions.length) { + openSession(state.sessions[0].id, state.tab === "agents" ? "agents" : "session"); + return; + } + render(); + }) + .catch(function (e) { + state.loading = false; + state.error = e.message; + render(); + }); + } + + function openSession(id, targetTab) { + state.detailSessionId = id; + state.tab = targetTab || "session"; + state.loading = true; + state.error = null; + render(); + if (state.tab === "agents") return loadAgents(id); + query("session", { sessionId: id }) + .then(function (data) { + if (!data || data.ok === false) throw new Error((data && data.error && data.error.message) || "查询失败"); + state.sessionDetail = data; + state.loading = false; + render(); + }) + .catch(function (e) { + state.loading = false; + state.error = e.message; + render(); + }); + } + + function loadAgents(id) { + state.loading = true; + state.error = null; + render(); + query("agents", { sessionId: id }) + .then(function (data) { + if (!data || data.ok === false) throw new Error((data && data.error && data.error.message) || "查询失败"); + state.agentsData = data; + state.loading = false; + render(); + }) + .catch(function (e) { + state.loading = false; + state.error = e.message; + render(); + }); + } + + function backToOverview() { + state.tab = "overview"; + state.sessionDetail = null; + state.agentsData = null; + state.detailSessionId = null; + render(); + } + + /* ---------------------------------------------------------------- */ + /* Overlay + sidebar entry */ + /* ---------------------------------------------------------------- */ + + function buildOverlay() { + var overlay = document.createElement("div"); + overlay.className = "zci-overlay"; + overlay.innerHTML = + '

上下文洞察

数据来自本机 ZCode 用量库 · 费用为牌价估算
' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '
'; + document.body.appendChild(overlay); + function syncOffset() { + var aside = findAside(); + var w = 262; + try { + if (aside) { + var rect = aside.getBoundingClientRect(); + if (rect && rect.width > 80) w = rect.width; + } + } catch { + // keep default + } + overlay.style.left = w + 6 + "px"; + } + syncOffset(); + window.addEventListener("resize", syncOffset); + state.overlayEl = overlay; + state.tabsEl = overlay.querySelector(".zci-tabs"); + state.bodyEl = overlay.querySelector(".zci-body"); + + overlay.querySelectorAll("[data-range]").forEach(function (btn) { + btn.addEventListener("click", function () { + state.range = btn.getAttribute("data-range") === "today" ? "today" : Number(btn.getAttribute("data-range")); + overlay.querySelectorAll("[data-range]").forEach(function (b) { + b.classList.remove("zci-on"); + }); + btn.classList.add("zci-on"); + if (state.tab === "session" || state.tab === "agents") state.tab = "overview"; + load(); + }); + }); + overlay.querySelector("[data-refresh]").addEventListener("click", load); + overlay.querySelector("[data-close]").addEventListener("click", closeOverlay); + state.onKey = function (e) { + if (e.key === "Escape") closeOverlay(); + }; + document.addEventListener("keydown", state.onKey, true); + load(); + } + + function openOverlay() { + if (!state.overlayEl) buildOverlay(); + else { + state.overlayEl.style.display = "flex"; + load(); + } + } + + function closeOverlay() { + if (state.overlayEl) state.overlayEl.style.display = "none"; + if (state.onKey) document.removeEventListener("keydown", state.onKey, true); + } + + function visible(el) { + try { + var rect = el.getBoundingClientRect(); + return rect && rect.width >= 80 && rect.height >= 80; + } catch { + return true; + } + } + + function findAside() { + var list = [ + document.querySelector('aside[data-testid="sidebar"]'), + document.querySelector('[data-workspace-sidebar-panel="true"] aside'), + document.querySelector('nav[data-testid="sidebar"]'), + ].filter(Boolean); + return list.find(visible) || list[0] || null; + } + + function isCompactProfileBlock(el, aside, panel) { + if (!el || el === aside || el === panel || el === document.body) return false; + if (!(aside.contains(el) || panel.contains(el))) return false; + if (el.querySelector('aside[data-testid="sidebar"], nav[data-testid="sidebar"]')) return false; + try { + var rect = el.getBoundingClientRect(); + if (!rect || rect.height <= 0 || rect.height > 140) return false; + } catch { + return false; + } + return true; + } + + function findProfileBlock(aside) { + var panel = aside.closest('[data-workspace-sidebar-panel="true"]') || aside.parentElement || aside; + var triggers = []; + ['[data-testid="login-trigger"]', '[data-testid="task-settings-button"]'].forEach(function (selector) { + [aside, panel].forEach(function (root) { + root.querySelectorAll(selector).forEach(function (el) { + if (!triggers.includes(el)) triggers.push(el); + }); + }); + }); + var trigger = null; + var lowest = -Infinity; + for (var el of triggers) { + try { + var rect = el.getBoundingClientRect(); + if (!rect || rect.height <= 0 || rect.top < lowest) continue; + lowest = rect.top; + trigger = el; + } catch { + // detached + } + } + if (!trigger) return null; + var best = null; + var node = trigger; + while (node && node !== aside && node !== panel && node !== document.body) { + if (isCompactProfileBlock(node, aside, panel)) best = node; + node = node.parentElement; + } + if (!best || !best.parentElement) return null; + return { parent: best.parentElement, node: best }; + } + + function mountEntry() { + var aside = findAside(); + if (!aside) return false; + if (document.getElementById("zci-entry")) return true; + var entry = document.createElement("div"); + entry.className = "zci-entry"; + entry.id = "zci-entry"; + entry.innerHTML = + '上下文洞察'; + entry.addEventListener("click", openOverlay); + var footer = aside.querySelector(":scope > footer"); + if (footer) { + aside.insertBefore(entry, footer); + return true; + } + var profile = findProfileBlock(aside); + if (profile && profile.parent && profile.node) { + profile.parent.insertBefore(entry, profile.node); + return true; + } + aside.appendChild(entry); + return true; + } + + function tryMount(attempt) { + ensureStyle(); + if (mountEntry()) { + if (state.observer) return; + state.observer = new MutationObserver(function () { + if (window.__zciLive !== BOOT_ID) return; + if (!document.getElementById("zci-entry")) mountEntry(); + }); + window.__zciObserver = state.observer; + window.__zciLive = BOOT_ID; + try { + state.observer.observe(document.body, { childList: true, subtree: true }); + } catch { + // ignore + } + return; + } + if (attempt < 120) setTimeout(function () { tryMount(attempt + 1); }, 500); + } + + tryMount(0); + + window.__zcodeContextDispose = function () { + try { + if (window.__zciObserver) { + window.__zciObserver.disconnect(); + window.__zciObserver = null; + } + var entry = document.getElementById("zci-entry"); + if (entry) entry.remove(); + var style = document.getElementById("zci-style"); + if (style) style.remove(); + if (state.overlayEl) state.overlayEl.remove(); + document.removeEventListener("keydown", state.onKey, true); + window.__zcodeContextPanelBooted = false; + } catch (e) { + /* ignore */ + } + }; +})(); diff --git a/external_plugins/zcode-context/scripts/asar-patch.mjs b/external_plugins/zcode-context/scripts/asar-patch.mjs new file mode 100644 index 00000000..be5a48a6 --- /dev/null +++ b/external_plugins/zcode-context/scripts/asar-patch.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * Minimal, dependency-free app.asar entry-line patcher. + * + * It only appends/removes the zcode-context loader line in + * `out/main/index.js`; every other file is stream-copied byte-for-byte. + * The asar layout follows Electron's pickle format (same approach as the + * token-usage statusbar plugin), including per-file offsets and integrity. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const ENTRY = "/out/main/index.js"; +const ALIGN = 4; +const BLOCK = 4 * 1024 * 1024; +const RUNTIME_DIR = + (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) || + path.join(os.homedir(), ".zcode", "zcode-context", "runtime"); +const LOADER_PATH = path.join(RUNTIME_DIR, "panel", "inject-main.cjs"); +const STAGE_SUFFIX = ".zcode-context.tmp"; + +const INJECT_LINE = `\n;import(${JSON.stringify(pathToFileURL(LOADER_PATH).href)}).then(() => null, (e) => console.error("[zcode-context] load failed", e));`; +const INJECT_RE = + /\n;import\("file:[^"]*inject-main\.cjs"\)\.then\(\(\) => null, \(e\) => console\.error\("\[zcode-context\] load failed", e\)\);?/g; + +export function injectionLine(loaderPath = LOADER_PATH) { + return `\n;import(${JSON.stringify(pathToFileURL(loaderPath).href)}).then(() => null, (e) => console.error("[zcode-context] load failed", e));`; +} + +function readHeader(fd) { + const head = Buffer.alloc(16); + fs.readSync(fd, head, 0, 16, 0); + const a = head.readUInt32LE(0); + const b = head.readUInt32LE(4); + const c = head.readUInt32LE(8); + const d = head.readUInt32LE(12); + if (a !== 4) throw new Error(`not an asar archive (prefix ${a})`); + if (d <= 0 || d > 64 * 1024 * 1024) throw new Error(`invalid asar header length ${d}`); + const json = Buffer.alloc(d); + fs.readSync(fd, json, 0, d, 16); + const header = JSON.parse(json.toString("utf8")); + return { header, base: 8 + b, headerLength: d, headerBlock: c }; +} + +function iterFiles(node, prefix = "") { + const out = []; + for (const [name, child] of Object.entries(node.files || {})) { + const p = `${prefix}/${name}`; + if (child && typeof child === "object" && child.files) out.push(...iterFiles(child, p)); + else out.push({ path: p, node: child }); + } + return out; +} + +function integrityOf(data) { + const hash = crypto.createHash("sha256").update(data).digest("hex"); + const blocks = []; + if (data.length === 0) { + // Electron's asar writer records one empty block for zero-length files. + blocks.push(hash); + } else { + for (let i = 0; i < data.length; i += BLOCK) { + blocks.push(crypto.createHash("sha256").update(data.subarray(i, i + BLOCK)).digest("hex")); + } + } + return { algorithm: "SHA256", hash, blockSize: BLOCK, blocks }; +} + +function readEntry(asarPath) { + const fd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(fd); + const entry = iterFiles(header).find((item) => item.path === ENTRY); + if (!entry) throw new Error(`asar is missing ${ENTRY}`); + const data = Buffer.alloc(entry.node.size); + fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset)); + return { header, base, entry: { ...entry, data } }; + } finally { + fs.closeSync(fd); + } +} + +export function inspectAsar(asarPath) { + const fd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(fd); + const files = iterFiles(header); + const entry = files.find((item) => item.path === ENTRY); + if (!entry) throw new Error(`asar is missing ${ENTRY}`); + const data = Buffer.alloc(entry.node.size); + fs.readSync(fd, data, 0, data.length, base + Number(entry.node.offset)); + const text = data.toString("utf8"); + INJECT_RE.lastIndex = 0; + return { + asarPath, + fileCount: files.length, + entrySize: data.length, + hasBalanceLine: INJECT_RE.test(text), + hasZusageLine: /\[zusage\] load failed/.test(text), + }; + } finally { + INJECT_RE.lastIndex = 0; + fs.closeSync(fd); + } +} + +/** + * Rebuild an asar with one replaced file. + * + * Critical invariant: `node.offset` is the SOURCE offset until the copy loop + * finishes. The new offsets are kept separately, otherwise every file after a + * size-changing replacement is read from the wrong place and the archive looks + * structurally valid while its contents are shifted. + */ +function repackAsar(asarPath, dstPath, replacements) { + const srcFd = fs.openSync(asarPath, "r"); + try { + const { header, base } = readHeader(srcFd); + const copy = JSON.parse(JSON.stringify(header)); + const entries = iterFiles(copy); + const missing = [...replacements.keys()].filter((p) => !entries.some((item) => item.path === p)); + if (missing.length) throw new Error(`asar paths not found: ${missing.join(", ")}`); + for (const { path: p, node } of entries) { + if (node.unpacked && replacements.has(p)) { + throw new Error(`cannot replace unpacked asar entry: ${p}`); + } + } + + // Immutable snapshot of the source layout. This is the fix for the + // 2026-11-13 corruption: never read from a rewritten offset. + const originalOffsets = new Map( + entries.map(({ path: p, node }) => [p, Number(node.offset)]), + ); + const newOffsets = new Map(); + let offset = 0; + for (const { path: p, node } of entries) { + if (node.unpacked) continue; + if (replacements.has(p)) { + const data = replacements.get(p); + node.size = data.length; + node.integrity = integrityOf(data); + } + newOffsets.set(p, offset); + offset += Number(node.size); + } + for (const { path: p, node } of entries) { + if (!node.unpacked) node.offset = String(newOffsets.get(p)); + } + + const headerBytes = Buffer.from(JSON.stringify(copy), "utf8"); + const d = headerBytes.length; + const c = Math.ceil((d + 4) / ALIGN) * ALIGN; + const b = c + 4; + const prefix = Buffer.alloc(16); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(b, 4); + prefix.writeUInt32LE(c, 8); + prefix.writeUInt32LE(d, 12); + + const dstFd = fs.openSync(dstPath, "w"); + try { + fs.writeSync(dstFd, prefix, 0, 16); + fs.writeSync(dstFd, headerBytes, 0, headerBytes.length); + const pad = Buffer.alloc(c - d - 4); + if (pad.length) fs.writeSync(dstFd, pad, 0, pad.length); + + const chunk = Buffer.alloc(1024 * 1024); + for (const { path: p, node } of entries) { + if (node.unpacked) continue; + if (replacements.has(p)) { + const data = replacements.get(p); + fs.writeSync(dstFd, data, 0, data.length); + continue; + } + let remaining = Number(node.size); + let srcOffset = base + originalOffsets.get(p); + while (remaining > 0) { + const size = Math.min(chunk.length, remaining); + fs.readSync(srcFd, chunk, 0, size, srcOffset); + fs.writeSync(dstFd, chunk, 0, size); + srcOffset += size; + remaining -= size; + } + } + fs.fsyncSync(dstFd); + } finally { + fs.closeSync(dstFd); + } + return { fileCount: entries.length, headerLength: d }; + } finally { + fs.closeSync(srcFd); + } +} + +/** + * Fully validate an asar: header layout, contiguous offsets, bounds, and the + * SHA-256 (plus block hashes) of every entry against the header's integrity. + * This is intentionally expensive; it is the gate before any file replacement. + */ +export function validateAsar(asarPath, options = {}) { + const fd = fs.openSync(asarPath, "r"); + let entryText = null; + try { + const stat = fs.fstatSync(fd); + const { header, base, headerLength, headerBlock } = readHeader(fd); + if (headerBlock !== Math.ceil((headerLength + 4) / ALIGN) * ALIGN) { + throw new Error(`invalid asar header alignment (d=${headerLength}, c=${headerBlock})`); + } + if (base !== 12 + headerBlock) { + throw new Error(`invalid asar data base (base=${base}, c=${headerBlock})`); + } + const entries = iterFiles(header).filter(({ node }) => !node.unpacked); + let expectedOffset = 0; + let entryCount = 0; + for (const { path: p, node } of entries) { + const off = Number(node.offset); + const size = Number(node.size); + if (!Number.isInteger(off) || !Number.isInteger(size) || off < 0 || size < 0) { + throw new Error(`invalid offset/size for ${p}`); + } + if (off !== expectedOffset) { + throw new Error(`non-contiguous offset for ${p}: ${off} != ${expectedOffset}`); + } + if (size > 64 * 1024 * 1024) throw new Error(`unreasonable entry size for ${p}: ${size}`); + if (base + off + size > stat.size) throw new Error(`entry out of bounds: ${p}`); + const data = Buffer.alloc(size); + if (size > 0) fs.readSync(fd, data, 0, size, base + off); + if (node.integrity && typeof node.integrity === "object") { + const actual = integrityOf(data); + if (node.integrity.hash && actual.hash !== node.integrity.hash) { + throw new Error(`integrity hash mismatch: ${p}`); + } + if (Array.isArray(node.integrity.blocks)) { + if (actual.blocks.length !== node.integrity.blocks.length) { + throw new Error(`integrity block count mismatch: ${p}`); + } + for (let i = 0; i < actual.blocks.length; i += 1) { + if (actual.blocks[i] !== node.integrity.blocks[i]) { + throw new Error(`integrity block mismatch: ${p} [${i}]`); + } + } + } + } + if (p === ENTRY) entryText = data.toString("utf8"); + expectedOffset += size; + entryCount += 1; + } + if (base + expectedOffset !== stat.size) { + throw new Error( + `asar length mismatch: header end ${base + expectedOffset}, file size ${stat.size}`, + ); + } + const hasBalanceLine = entryText ? /\[zcode-context\] load failed/.test(entryText) : false; + const hasZusageLine = entryText ? /\[zusage\] load failed/.test(entryText) : false; + if (options.expectBalance !== undefined && hasBalanceLine !== options.expectBalance) { + throw new Error( + `injection marker mismatch: expected balance=${options.expectBalance}, got ${hasBalanceLine}`, + ); + } + if (options.expectZusage !== undefined && hasZusageLine !== options.expectZusage) { + throw new Error( + `zusage marker mismatch: expected ${options.expectZusage}, got ${hasZusageLine}`, + ); + } + return { + ok: true, + asarPath, + fileCount: entryCount, + entrySize: entryText ? Buffer.byteLength(entryText) : 0, + hasBalanceLine, + hasZusageLine, + size: stat.size, + }; + } finally { + fs.closeSync(fd); + } +} + +export const BACKUP_SUFFIX = ".zcode-context.bak"; + +export function backupPathOf(asarPath) { + return `${asarPath}${BACKUP_SUFFIX}`; +} + +export function restoreBackup(asarPath) { + const backup = backupPathOf(asarPath); + if (!fs.existsSync(backup)) throw new Error(`backup not found: ${backup}`); + const check = validateAsar(backup); + fs.copyFileSync(backup, asarPath); + return { status: "restored", asarPath, backup, ...check }; +} + +export function replaceAsarWithBackup(stagePath, asarPath, options = {}) { + const stageCheck = validateAsar(stagePath, options.stageOptions || {}); + const backup = backupPathOf(asarPath); + try { + fs.unlinkSync(backup); + } catch { + // no previous backup + } + try { + fs.linkSync(asarPath, backup); + } catch { + fs.copyFileSync(asarPath, backup); + } + try { + fs.renameSync(stagePath, asarPath); + const postCheck = validateAsar(asarPath, options.postOptions || {}); + return { backup, stageCheck, postCheck }; + } catch (error) { + try { + fs.copyFileSync(backup, asarPath); + } catch { + // leave the backup for manual restoration + } + throw error; + } +} + +export function buildPatchedAsar({ asarPath, loaderPath = LOADER_PATH, stagePath }) { + const sourceCheck = validateAsar(asarPath); + const { entry } = readEntry(asarPath); + const text = entry.data.toString("utf8"); + const stripped = text.replace(INJECT_RE, ""); + const line = injectionLine(loaderPath); + const hadZusage = /\[zusage\] load failed/.test(text); + if (text.includes(line) || stripped + line === text) { + return { status: "already", asarPath, entrySize: entry.data.length, hasZusageLine: hadZusage }; + } + const newEntry = Buffer.from(stripped + line, "utf8"); + const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`; + const tmp = `${stage}.stage-${process.pid}`; + try { + repackAsar(asarPath, tmp, new Map([[ENTRY, newEntry]])); + const check = validateAsar(tmp, { expectBalance: true, expectZusage: hadZusage }); + fs.renameSync(tmp, stage); + return { status: "patched", stagePath: stage, sourceCheck, ...check, asarPath }; + } catch (error) { + try { + fs.unlinkSync(tmp); + } catch { + // ignore + } + throw error; + } +} + +export function buildUnpatchedAsar({ asarPath, stagePath }) { + const sourceCheck = validateAsar(asarPath); + const { entry } = readEntry(asarPath); + const text = entry.data.toString("utf8"); + INJECT_RE.lastIndex = 0; + if (!INJECT_RE.test(text)) { + INJECT_RE.lastIndex = 0; + return { status: "already-clean", asarPath, sourceCheck }; + } + INJECT_RE.lastIndex = 0; + const stripped = text.replace(INJECT_RE, ""); + const hadZusage = /\[zusage\] load failed/.test(text); + const stage = stagePath || `${asarPath}${STAGE_SUFFIX}`; + const tmp = `${stage}.stage-${process.pid}`; + try { + repackAsar(asarPath, tmp, new Map([[ENTRY, Buffer.from(stripped, "utf8")]])); + const check = validateAsar(tmp, { expectBalance: false, expectZusage: hadZusage }); + fs.renameSync(tmp, stage); + return { status: "unpatched", stagePath: stage, sourceCheck, ...check, asarPath }; + } catch (error) { + try { + fs.unlinkSync(tmp); + } catch { + // ignore + } + throw error; + } +} + +export function replaceAsar(stagePath, asarPath) { + return replaceAsarWithBackup(stagePath, asarPath); +} + +export function findAppAsar(explicit) { + if (explicit) return explicit; + if (process.env.ZCODE_ASAR) return process.env.ZCODE_ASAR; + const candidates = []; + const add = (p) => p && candidates.push(p); + if (process.platform === "win32") { + add(path.join(process.env.ProgramFiles || "C:\\Program Files", "ZCode", "resources", "app.asar")); + add(path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "ZCode", "resources", "app.asar")); + add(path.join(process.env.LOCALAPPDATA || "", "Programs", "ZCode", "resources", "app.asar")); + add("D:\\Program Files\\ZCode\\resources\\app.asar"); + add("D:\\ZCode\\resources\\app.asar"); + } else if (process.platform === "darwin") { + add("/Applications/ZCode.app/Contents/Resources/app.asar"); + } else { + add("/opt/ZCode/resources/app.asar"); + add(path.join(process.cwd(), "resources", "app.asar")); + } + return candidates.find((p) => { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } + }) || null; +} + +function argValue(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) { + const command = process.argv[2] || "check"; + const asar = findAppAsar(argValue("--asar")); + if (!asar) { + console.error("app.asar not found; pass --asar "); + process.exit(2); + } + try { + if (command === "check") { + console.log(JSON.stringify(inspectAsar(asar), null, 2)); + } else if (command === "validate") { + console.log(JSON.stringify(validateAsar(asar), null, 2)); + } else if (command === "patch") { + const result = buildPatchedAsar({ asarPath: asar, loaderPath: argValue("--loader") || LOADER_PATH }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === "unpatch") { + const result = buildUnpatchedAsar({ asarPath: asar }); + console.log(JSON.stringify(result, null, 2)); + } else if (command === "finalize") { + const stage = argValue("--stage"); + if (!stage) throw new Error("--stage is required"); + const result = replaceAsarWithBackup(stage, asar); + console.log(JSON.stringify({ status: "replaced", asar, stage, ...result }, null, 2)); + } else if (command === "restore") { + console.log(JSON.stringify(restoreBackup(asar), null, 2)); + } else { + throw new Error(`unknown command: ${command}`); + } + } catch (error) { + console.error(error.stack || error.message || String(error)); + process.exit(1); + } +} diff --git a/external_plugins/zcode-context/scripts/lib/common.mjs b/external_plugins/zcode-context/scripts/lib/common.mjs new file mode 100644 index 00000000..e3061813 --- /dev/null +++ b/external_plugins/zcode-context/scripts/lib/common.mjs @@ -0,0 +1,95 @@ +/** + * Shared helpers for zcode-context query scripts. + * + * Everything reads the local ZCode CLI database (read-only). Token estimates + * for context composition use a chars-per-token heuristic (about 3.5 bytes per + * token for mixed CJK/ASCII content), clearly marked as estimated in the UI. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +export function num(value, fallback = 0) { + const n = Number(value); + return Number.isFinite(n) ? n : fallback; +} + +export function dbPath() { + return ( + (process.env.ZCONTEXT_USAGE_DB && process.env.ZCONTEXT_USAGE_DB.trim()) || + path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite") + ); +} + +export async function openDb() { + const file = dbPath(); + if (!fs.existsSync(file)) { + return { error: { code: "db_missing", message: `用量数据库不存在:${file}` } }; + } + let DatabaseSync; + try { + ({ DatabaseSync } = await import("node:sqlite")); + } catch (error) { + return { + error: { + code: "sqlite_unavailable", + message: `当前运行时不支持 node:sqlite(${error.message})。`, + }, + }; + } + try { + return { db: new DatabaseSync(file, { readOnly: true }) }; + } catch (error) { + return { error: { code: "db_open_failed", message: error.message } }; + } +} + +/** Rough token estimate from a UTF-16 string length. Purely heuristic. */ +export function tokensOfBytes(chars) { + return Math.round(chars / 3.5); +} + +/** JSON byte size used consistently for composition estimates. */ +export function sizeOf(value) { + if (value === undefined || value === null) return 0; + const text = typeof value === "string" ? value : JSON.stringify(value); + return text ? text.length : 0; +} + +export function clampText(value, max = 160) { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +/** Role of a message row (message.data JSON has `role`; parts have `type`). */ +export function roleOfMessage(data) { + return String(data.role || ""); +} + +/** True for synthetic/injected user messages that should not count as turns. */ +export function isSyntheticMessage(data) { + return Boolean(data.synthetic) || data.visibility === "hidden"; +} + +/** Context-window lookup lives in pricing.mjs; re-export for callers. */ +export { windowOfModel } from "./pricing.mjs"; + +export function workspaceLabel(directory, projectId) { + const label = + String(directory || projectId || "") + .split(/[\\/]/) + .filter(Boolean) + .pop() || String(projectId || "default"); + return label; +} + +export function finish(result, startedAt) { + if (result && result.ok === false) return result; + return { ok: true, ...(result || {}), elapsedMs: Date.now() - startedAt }; +} diff --git a/external_plugins/zcode-context/scripts/lib/pricing.mjs b/external_plugins/zcode-context/scripts/lib/pricing.mjs new file mode 100644 index 00000000..f4f24720 --- /dev/null +++ b/external_plugins/zcode-context/scripts/lib/pricing.mjs @@ -0,0 +1,81 @@ +/** + * Model pricing and context-window tables for zcode-context. + * + * All costs are estimates computed from public list prices (USD per million + * tokens), converted to CNY for display. Models without an entry fall back to + * DEFAULT_PRICE_USD and are flagged as estimates by the UI. + */ + +export const USD_TO_CNY = 7.2; + +export const MODEL_PRICES_USD_PER_M = { + "deepseek-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4.1-flash": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-1-flash-261017": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-flash-vision-exp": { input: 0.15, output: 0.6, cacheRead: 0.003, cacheWrite: 0 }, + "deepseek-v4-pro": { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0 }, + "deepseek-reasoner": { input: 0.55, output: 1.66, cacheRead: 0.14, cacheWrite: 0 }, + "deepseek-chat": { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0 }, + "doubao-seed-2-0-code-preview-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 }, + "doubao-seed-2-0-pro-260215": { input: 0.475, output: 2.375, cacheRead: 0.095, cacheWrite: 0 }, + "doubao-seed-2-0-lite-260428": { input: 0.089, output: 0.534, cacheRead: 0.018, cacheWrite: 0 }, + "doubao-seed-2-0-mini-260428": { input: 0.03, output: 0.297, cacheRead: 0.006, cacheWrite: 0 }, + "doubao-seed-2-1-pro-260628": { input: 0.891, output: 4.453, cacheRead: 0.178, cacheWrite: 0 }, + "doubao-seed-2-1-turbo-260628": { input: 0.445, output: 2.227, cacheRead: 0.089, cacheWrite: 0 }, + "glm-5.3": { input: 0.4, output: 1.6, cacheRead: 0.08, cacheWrite: 0.5 }, + "glm-5.3-flash": { input: 0.05, output: 0.2, cacheRead: 0.01, cacheWrite: 0.05 }, +}; + +export const DEFAULT_PRICE_USD = { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25 }; + +/** Context window sizes (input tokens). Longest-suffix match on model id. */ +export const MODEL_WINDOWS = [ + { suffix: "deepseek-v4.1", window: 1048576 }, + { suffix: "deepseek-v4", window: 1048576 }, + { suffix: "glm-5.3", window: 204800 }, + { suffix: "glm-4", window: 131072 }, + { suffix: "doubao-seed", window: 262144 }, + { suffix: "claude", window: 204800 }, + { suffix: "gpt", window: 272000 }, + { suffix: "deepseek", window: 131072 }, +]; + +export const DEFAULT_WINDOW = 131072; + +export function priceOfModel(modelId) { + const key = String(modelId || "").toLowerCase(); + return MODEL_PRICES_USD_PER_M[key] || null; +} + +export function windowOfModel(modelId) { + const key = String(modelId || "").toLowerCase(); + for (const entry of MODEL_WINDOWS) { + if (key.includes(entry.suffix)) return entry.window; + } + return DEFAULT_WINDOW; +} + +/** + * Estimated cost (CNY) for one usage row. Cache-write tokens are billed on top + * of bare input; the caller passes already-split numbers. + */ +export function costOfUsage({ modelId, input = 0, output = 0, cacheRead = 0, cacheWrite = 0 }) { + const rates = priceOfModel(modelId) || DEFAULT_PRICE_USD; + const bareInput = Math.max(0, Number(input) - Number(cacheRead) - Number(cacheWrite)); + const usd = + (bareInput * rates.input + + Number(cacheRead) * rates.cacheRead + + Number(cacheWrite) * rates.cacheWrite + + Number(output) * rates.output) / + 1_000_000; + return usd * USD_TO_CNY; +} + +export function isKnownModel(modelId) { + return Boolean(priceOfModel(modelId)); +} + +export function round2(n) { + return Math.round((Number(n) + Number.EPSILON) * 100) / 100; +} diff --git a/external_plugins/zcode-context/scripts/query-agents.mjs b/external_plugins/zcode-context/scripts/query-agents.mjs new file mode 100644 index 00000000..f4b4d0dd --- /dev/null +++ b/external_plugins/zcode-context/scripts/query-agents.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Agent formation view for the zcode-context dashboard. + * + * Prints one JSON document: + * { ok, sessionId, agents: [...], events: [...] } + * + * Agents come from the per-agent metadata files stored under the agents dir + * usage joined from the local database via the child session ids. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { arg, num, openDb, clampText, finish } from "./lib/common.mjs"; +import { round2 } from "./lib/pricing.mjs"; + +function agentsDirOf(sessionId) { + return path.join(os.homedir(), ".zcode", "cli", "agents", sessionId); +} + +function readAgentMetadata(dir) { + try { + return JSON.parse(fs.readFileSync(path.join(dir, "metadata.json"), "utf8")); + } catch { + return null; + } +} + +function readAgentOutput(dir, max = 4000) { + for (const name of ["output.txt", "task.output"]) { + try { + const text = fs.readFileSync(path.join(dir, name), "utf8"); + if (text) return text.length > max ? `${text.slice(0, max)}…` : text; + } catch { + // try next + } + } + return ""; +} + +async function main() { + const started = Date.now(); + const sessionId = arg("--session-id") || ""; + if (!sessionId) { + return { ok: false, error: { code: "bad_args", message: "缺少 --session-id" } }; + } + + const opened = await openDb(); + if (opened.error) return opened; + const db = opened.db; + try { + const session = db.prepare("select * from session where id = ?").get(sessionId); + if (!session) { + return { + ok: false, + error: { code: "session_missing", message: `会话不存在:${sessionId}` }, + }; + } + + const root = agentsDirOf(sessionId); + const agents = []; + if (fs.existsSync(root)) { + for (const name of fs.readdirSync(root)) { + const dir = path.join(root, name); + if (!fs.statSync(dir).isDirectory()) continue; + const meta = readAgentMetadata(dir); + if (!meta) continue; + const childId = String(meta.childSessionId || ""); + const usage = childId + ? db + .prepare( + `select coalesce(sum(computed_total_tokens),0) as tokens, + coalesce(sum(duration_ms),0) as active_ms, + count(*) as requests, + max(started_at) as last_at + from model_usage where session_id = ? and status = 'completed'`, + ) + .get(childId) + : { tokens: 0, active_ms: 0, requests: 0, last_at: 0 }; + agents.push({ + agentId: String(meta.agentId || name), + childSessionId: childId, + profileId: String(meta.profileId || ""), + profileName: String(meta.profileSnapshot?.name || meta.profileId || ""), + description: clampText(meta.description || "", 160), + createdAt: num(Date.parse(meta.createdAt || "") || 0), + parentToolUseId: String(meta.parentToolUseId || ""), + tokens: num(usage.tokens), + activeMs: num(usage.active_ms), + requests: num(usage.requests), + lastAt: num(usage.last_at), + output: readAgentOutput(dir), + }); + } + } + agents.sort((a, b) => a.createdAt - b.createdAt); + + // Cross-agent events: model_usage rows tagged with an agent column or the + // child sessions' activity, capped for large formations. + const events = db + .prepare( + `select u.started_at, u.model_id, u.session_id, u.agent, u.status, + u.input_tokens, u.output_tokens + from model_usage u + where (u.session_id = ? or u.session_id in ( + select id from session where parent_id = ? + )) + and u.status in ('completed','error') + order by u.started_at asc + limit 400`, + ) + .all(sessionId, sessionId) + .map((row) => ({ + at: num(row.started_at), + sessionId: String(row.session_id), + agent: String(row.agent || ""), + modelId: String(row.model_id || ""), + status: String(row.status || ""), + inputTokens: num(row.input_tokens), + outputTokens: num(row.output_tokens), + })); + + return finish( + { + ok: true, + sessionId, + sessionTitle: session.title, + agents, + events, + }, + started, + ); + } finally { + db.close(); + } +} + +try { + const result = await main(); + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + ok: false, + error: { code: "agents_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/zcode-context/scripts/query-overview.mjs b/external_plugins/zcode-context/scripts/query-overview.mjs new file mode 100644 index 00000000..345d9c04 --- /dev/null +++ b/external_plugins/zcode-context/scripts/query-overview.mjs @@ -0,0 +1,426 @@ +#!/usr/bin/env node +/** + * Overview aggregation for the zcode-context dashboard. + * + * Prints one JSON document: + * { ok, range, summary, series, heatmap, tools, commands, models, projects, sessions } + * + * Reads the local ZCode CLI database (read-only) and keeps everything in + * local time so numbers match what the user sees elsewhere in the app. + */ +import { + arg, + num, + openDb, + clampText, + workspaceLabel, + finish, +} from "./lib/common.mjs"; +import { costOfUsage, round2 } from "./lib/pricing.mjs"; + +function dayExpr(columnMs) { + return `strftime('%Y-%m-%d', ${columnMs} / 1000, 'unixepoch', 'localtime')`; +} + +function costOfUsageRow(row) { + return costOfUsage({ + modelId: row.model_id, + input: num(row.input_tokens), + output: num(row.output_tokens), + cacheRead: num(row.cache_read_input_tokens), + cacheWrite: num(row.cache_creation_input_tokens), + }); +} + +async function main() { + const started = Date.now(); + const now = Date.now(); + const start = num(arg("--start"), now - 7 * 24 * 3600 * 1000); + const end = num(arg("--end"), now); + const directory = arg("--directory") || ""; + + const opened = await openDb(); + if (opened.error) return opened; + const db = opened.db; + try { + const rangeArgs = [start, end]; + let sessionFilter = ""; + if (directory) { + sessionFilter = " and s.directory = ? "; + rangeArgs.push(directory); + } + + const usageRows = db + .prepare( + `select u.session_id, u.provider_id, u.model_id, u.query_source, u.status, + u.duration_ms, u.time_to_first_token_ms, u.tool_call_count, + u.input_tokens, u.output_tokens, u.reasoning_tokens, + u.cache_creation_input_tokens, u.cache_read_input_tokens, + u.computed_total_tokens, u.retry_count, u.started_at + from model_usage u + join session s on s.id = u.session_id + where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter} + order by u.started_at asc`, + ) + .all(...rangeArgs); + + // 耗时统计:模型等待 = Σduration(按 ttft 拆等待/生成,生成再按 reasoning + // token 占比拆思考/回复);工具执行 ≈ 相邻请求之间的空闲间隔(下限 0)。 + const timeStats = { + modelWaitMs: 0, + thinkMs: 0, + replyMs: 0, + toolMs: 0, + otherMs: 0, + spanMs: 0, + outputSpeed: 0, + }; + { + let prevEnd = null; + let prevSid = null; + let ttftMs = 0; + let reasoningAll = 0; + let outputAll = 0; + const sessionSpan = new Map(); + for (const row of usageRows) { + const dur = num(row.duration_ms); + ttftMs += Math.max(0, num(row.time_to_first_token_ms)); + reasoningAll += num(row.reasoning_tokens); + outputAll += num(row.output_tokens); + const st = num(row.started_at); + const en = num(row.completed_at) || st + dur; + const sid = row.session_id; + const gap = prevEnd !== null && st > prevEnd ? st - prevEnd : 0; + if (gap > 0 && gap <= 600 * 1000) timeStats.toolMs += gap; + const span = sessionSpan.get(sid) || { span: 0, last: st, prev: null }; + if (span.prev !== null) { + const g = st - span.prev; + if (g <= 10 * 60 * 1000) span.span += g; + } + span.span += dur; + span.prev = en; + sessionSpan.set(sid, span); + prevEnd = en; + prevSid = sid; + } + for (const span of sessionSpan.values()) { + timeStats.spanMs += span.span; + } + const genMs = Math.max(0, timeStats.modelWaitMs - ttftMs); + const thinkShare = reasoningAll + outputAll > 0 ? reasoningAll / (reasoningAll + outputAll) : 0.2; + timeStats.modelWaitMs = ttftMs; + timeStats.thinkMs = Math.round(genMs * thinkShare); + timeStats.replyMs = genMs - timeStats.thinkMs; + timeStats.otherMs = Math.max( + 0, + timeStats.spanMs - timeStats.modelWaitMs - genMs - timeStats.toolMs, + ); + } + + // 命令执行 Top:取 Bash 命令的首词;同时统计总执行/失败次数。 + const commandMap = new Map(); + const commandStats = { exec: 0, failed: 0 }; + try { + const cmdRows = db + .prepare( + `select json_extract(p.data, '$.state.input.command') as cmd, + json_extract(p.data, '$.state.status') as status + from part p + join session s on s.id = p.session_id + where json_extract(p.data, '$.type') = 'tool' + and json_extract(p.data, '$.tool') in ('Bash','PowerShell','pwsh') + and p.time_created >= ? and p.time_created < ?${sessionFilter} + limit 4000`, + ) + .all(...rangeArgs); + for (const r of cmdRows) { + commandStats.exec += 1; + if (String(r.status || "") === "error") commandStats.failed += 1; + const first = String(r.cmd || "").trim().split(/\s+/)[0]?.replace(/^["']|["']$/g, ""); + if (!first) continue; + commandMap.set(first, (commandMap.get(first) || 0) + 1); + } + } catch { + // command breakdown is best-effort + } + const commands = [...commandMap.entries()] + .map(([cmd, calls]) => ({ cmd, calls })) + .sort((a, b) => b.calls - a.calls) + .slice(0, 10); + + const summary = { + sessions: 0, + requests: 0, + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + totalTokens: 0, + cost: 0, + cacheHitRate: 0, + toolCalls: 0, + commandExec: 0, + activeMs: 0, + outputSpeed: 0, + unknownPriceRequests: 0, + }; + const seriesMap = new Map(); + const modelMap = new Map(); + const projectMap = new Map(); + const sessionMap = new Map(); + let cacheReadTotal = 0; + let inputTotal = 0; + let outputTotal = 0; + let outputMs = 0; + + const sessionStmt = db.prepare( + "select id, title, directory, project_id, time_created, time_updated from session where id = ?", + ); + const lastTextStmt = db.prepare( + `select data from part + where session_id = ? and json_extract(data, '$.type') = 'text' + order by time_created desc limit 1`, + ); + const dayStmt = db.prepare( + "select strftime('%Y-%m-%d', ? / 1000, 'unixepoch', 'localtime') as d", + ); + + for (const row of usageRows) { + const input = num(row.input_tokens); + const output = num(row.output_tokens); + const total = num(row.computed_total_tokens) || input + output; + const cost = costOfUsageRow(row); + const modelKey = row.model_id; + + summary.requests += 1; + summary.inputTokens += input; + summary.outputTokens += output; + summary.reasoningTokens += num(row.reasoning_tokens); + summary.cacheReadTokens += num(row.cache_read_input_tokens); + summary.cacheWriteTokens += num(row.cache_creation_input_tokens); + summary.totalTokens += total; + summary.cost += cost; + summary.toolCalls += num(row.tool_call_count); + summary.activeMs += num(row.duration_ms); + cacheReadTotal += num(row.cache_read_input_tokens); + inputTotal += input; + outputTotal += output; + outputMs += num(row.duration_ms); + + const bucket = dayStmt.get(row.started_at).d; + const seriesEntry = seriesMap.get(bucket) || { + key: bucket, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cost: 0, + requests: 0, + }; + seriesEntry.tokens += total; + seriesEntry.inputTokens += input; + seriesEntry.outputTokens += output; + seriesEntry.cost += cost; + seriesEntry.requests += 1; + seriesMap.set(bucket, seriesEntry); + + const model = modelMap.get(modelKey) || { + modelId: modelKey, + providerId: String(row.provider_id || ""), + tokens: 0, + cost: 0, + requests: 0, + unknown: false, + }; + model.tokens += total; + model.cost += cost; + model.requests += 1; + modelMap.set(modelKey, model); + + const sess = sessionStmt.get(row.session_id); + if (sess) { + summary.sessions += sessionMap.has(sess.id) ? 0 : 1; + const card = sessionMap.get(sess.id) || { + id: sess.id, + title: sess.title, + directory: sess.directory, + workspace: workspaceLabel(sess.directory, sess.project_id), + startedAt: num(sess.time_created), + lastActiveAt: num(sess.time_updated), + tokens: 0, + cost: 0, + requests: 0, + toolCalls: 0, + }; + card.tokens += total; + card.cost += cost; + card.requests += 1; + card.toolCalls += num(row.tool_call_count); + card.lastActiveAt = Math.max(card.lastActiveAt, num(row.started_at)); + sessionMap.set(sess.id, card); + } + } + + summary.cost = round2(summary.cost); + summary.cacheHitRate = inputTotal > 0 ? cacheReadTotal / inputTotal : 0; + summary.outputSpeed = outputMs > 0 ? Math.round((outputTotal / outputMs) * 1000) : 0; + timeStats.outputSpeed = summary.outputSpeed; + + // Tool/command rankings come from the part table (authoritative). + const toolRows = db + .prepare( + `select json_extract(p.data, '$.tool') as tool, count(*) as calls + from part p + join session s on s.id = p.session_id + where json_extract(p.data, '$.type') = 'tool' + and p.time_created >= ? and p.time_created < ?${sessionFilter} + group by tool + order by calls desc + limit 12`, + ) + .all(...rangeArgs); + const tools = toolRows.map((row) => ({ + tool: String(row.tool || "?"), + calls: num(row.calls), + })); + summary.commandExec = num( + ( + db + .prepare( + `select count(*) as c from part p join session s on s.id = p.session_id + where json_extract(p.data, '$.type') = 'tool' + and json_extract(p.data, '$.tool') in ('Bash','PowerShell','pwsh') + and p.time_created >= ? and p.time_created < ?${sessionFilter}`, + ) + .get(...rangeArgs) || {} + ).c, + ); + + // Models aggregated without the probe rows used above. + const models = [...modelMap.values()] + .map((entry) => ({ ...entry, cost: round2(entry.cost) })) + .sort((a, b) => b.tokens - a.tokens) + .slice(0, 10); + + // Projects + const projectRows = db + .prepare( + `select s.directory, s.project_id, + count(distinct s.id) as sessions, + coalesce(sum(u.computed_total_tokens),0) as tokens + from model_usage u + join session s on s.id = u.session_id + where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter} + group by s.directory, s.project_id + order by tokens desc + limit 12`, + ) + .all(...rangeArgs); + const projects = projectRows.map((row) => ({ + directory: String(row.directory || ""), + workspace: workspaceLabel(row.directory, row.project_id), + sessions: num(row.sessions), + tokens: num(row.tokens), + })); + + // Heatmap: per-day tokens + sessions for the trailing 8 weeks. + const heatRows = db + .prepare( + `select ${dayExpr("u.started_at")} as day, + count(distinct u.session_id) as sessions, + coalesce(sum(u.computed_total_tokens),0) as tokens + from model_usage u + join session s on s.id = u.session_id + where u.status = 'completed' and u.started_at >= ? and u.started_at < ?${sessionFilter} + group by day`, + ) + .all(start - 7 * 24 * 3600 * 1000, end, ...(directory ? [directory] : [])); + const heatmap = heatRows.map((row) => ({ + day: String(row.day || ""), + sessions: num(row.sessions), + tokens: num(row.tokens), + })); + + const series = [...seriesMap.values()] + .map((entry) => ({ ...entry, cost: round2(entry.cost) })) + .sort((a, b) => (a.key < b.key ? -1 : 1)); + + // 每会话工具列表(供排行行点击筛选会话卡)。 + const toolsBySession = new Map(); + try { + const toolRows = db + .prepare( + `select p.session_id, json_extract(p.data, '$.tool') as tool, count(*) as calls + from part p + join session s on s.id = p.session_id + where json_extract(p.data, '$.type') = 'tool' + and p.time_created >= ? and p.time_created < ?${sessionFilter} + group by p.session_id, tool`, + ) + .all(start, end); + for (const r of toolRows) { + const sid = String(r.session_id || ""); + if (!sid) continue; + const list = toolsBySession.get(sid) || []; + list.push({ tool: String(r.tool || "?"), calls: num(r.calls) }); + toolsBySession.set(sid, list); + } + for (const list of toolsBySession.values()) { + list.sort((a, b) => b.calls - a.calls); + list.length = Math.min(list.length, 8); + } + } catch { + // tool lists stay empty + } + + // Session cards (top 24 by tokens) with a last-message snippet. + const sessionCards = [...sessionMap.values()] + .sort((a, b) => b.tokens - a.tokens || b.lastActiveAt - a.lastActiveAt) + .slice(0, 24) + .map((card) => { + const last = lastTextStmt.get(card.id); + let snippet = ""; + if (last) { + try { + snippet = clampText(JSON.parse(last.data).text || ""); + } catch { + snippet = ""; + } + } + return { ...card, cost: round2(card.cost), lastMessage: snippet, tools: toolsBySession.get(card.id) || [] }; + }); + + return finish( + { + ok: true, + range: { start, end, directory: directory || "all" }, + summary, + timeStats, + commandStats, + series, + heatmap, + tools, + commands, + models, + projects, + sessions: sessionCards, + }, + started, + ); + } finally { + db.close(); + } +} + +try { + const result = await main(); + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + ok: false, + error: { code: "overview_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/zcode-context/scripts/query-session.mjs b/external_plugins/zcode-context/scripts/query-session.mjs new file mode 100644 index 00000000..068a8b12 --- /dev/null +++ b/external_plugins/zcode-context/scripts/query-session.mjs @@ -0,0 +1,494 @@ +#!/usr/bin/env node +/** + * Per-session detail for the zcode-context dashboard. + * + * Prints one JSON document: + * { ok, session, usage, turns, trend, composition, events, files, stats } + * + * The composition is estimated from the message/part tables (char counts and + * the ~3.5 chars/token heuristic) and clearly marked as an estimate. Events + * are capped so huge sessions stay responsive. + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + arg, + num, + openDb, + sizeOf, + tokensOfBytes, + clampText, + workspaceLabel, + finish, +} from "./lib/common.mjs"; +import { costOfUsage, round2, windowOfModel } from "./lib/pricing.mjs"; + +const MAX_EVENTS = 400; +const MAX_TREND_POINTS = 400; + +async function main() { + const started = Date.now(); + const sessionId = arg("--session-id") || ""; + if (!sessionId) { + return { ok: false, error: { code: "bad_args", message: "缺少 --session-id" } }; + } + + const opened = await openDb(); + if (opened.error) return opened; + const db = opened.db; + try { + const session = db + .prepare("select * from session where id = ?") + .get(sessionId); + if (!session) { + return { + ok: false, + error: { code: "session_missing", message: `会话不存在:${sessionId}` }, + }; + } + + const usage = db + .prepare( + `select started_at, completed_at, duration_ms, time_to_first_token_ms, + provider_id, model_id, query_source, status, finish_reason, + tool_call_count, input_tokens, output_tokens, reasoning_tokens, + cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens, retry_count, error_message + from model_usage + where session_id = ? and status = 'completed' + order by started_at asc`, + ) + .all(sessionId); + + // Turn boundaries (visible user messages) for 轮次 aggregation. + const turnBoundaries = []; + try { + const turnRows = db + .prepare("select data, time_created from message where session_id = ? order by time_created asc") + .all(sessionId); + for (const row of turnRows) { + try { + const d = JSON.parse(row.data); + if (d.role === "user" && !d.synthetic && d.visibility !== "hidden") { + turnBoundaries.push(num(row.time_created)); + } + } catch { + // ignore malformed rows + } + } + } catch { + // turn boundaries stay empty + } + const turnIndexOf = (ts) => { + let n = 0; + for (const t of turnBoundaries) if (t <= ts) n += 1; + return Math.max(1, n); + }; + + // --- Trend: per-request context size (input) and output. --- + const step = Math.max(1, Math.ceil(usage.length / MAX_TREND_POINTS)); + const trend = []; + for (let i = 0; i < usage.length; i += step) { + const rows = usage.slice(i, i + step); + const input = rows.reduce((acc, r) => acc + num(r.input_tokens), 0) / rows.length; + const output = rows.reduce((acc, r) => acc + num(r.output_tokens), 0); + const reasoning = rows.reduce((acc, r) => acc + num(r.reasoning_tokens), 0); + const cacheRead = rows.reduce((acc, r) => acc + num(r.cache_read_input_tokens), 0); + const cacheWrite = rows.reduce((acc, r) => acc + num(r.cache_creation_input_tokens), 0); + trend.push({ + index: i, + turn: turnIndexOf(num(rows[0].started_at)), + startedAt: num(rows[0].started_at), + inputTokens: Math.round(input), + outputTokens: output, + reasoningTokens: reasoning, + cacheReadTokens: cacheRead, + cacheWriteTokens: cacheWrite, + cacheRate: input + cacheRead + cacheWrite > 0 ? cacheRead / (input + cacheRead + cacheWrite) : 0, + durationMs: Math.round(rows.reduce((acc, r) => acc + num(r.duration_ms), 0) / rows.length), + compact: rows.some((r) => String(r.query_source || "") === "compact"), + }); + } + + // --- Current context composition (estimate from the last user message's + // snapshot of system prompt + tools, plus part sizes up to the end). --- + const snapshotEntry = db + .prepare( + `select data from session_entry + where session_id = ? and type = 'runtime/prompt_snapshot' + order by time_updated desc limit 1`, + ) + .get(sessionId); + const messageTools = db + .prepare( + `select data from message + where session_id = ? and json_extract(data, '$.tools') is not null + order by time_created desc limit 1`, + ) + .get(sessionId); + + let systemTokens = 0; + let toolDefTokens = 0; + let promptPreview = ""; + let toolNames = []; + try { + if (snapshotEntry) { + const snap = JSON.parse(snapshotEntry.data); + const system = Array.isArray(snap.system) ? snap.system : []; + systemTokens = tokensOfBytes(system.reduce((acc, m) => acc + sizeOf(m.content), 0)); + const sysText = system + .map((m) => (typeof m.content === "string" ? m.content : "")) + .filter(Boolean) + .join("\n"); + promptPreview = clampText(sysText, 400); + } + if (messageTools) { + const msg = JSON.parse(messageTools.data); + toolDefTokens = tokensOfBytes(sizeOf(msg.tools || [])); + toolNames = (msg.tools || []) + .map((t) => String(t && (t.name || t.function?.name) || "")) + .filter(Boolean) + .slice(0, 40); + } + } catch { + // keep zeros; composition stays an estimate + } + + const partRows = db + .prepare( + `select data, time_created from part where session_id = ? order by time_created asc`, + ) + .all(sessionId); + + const composition = { + systemPrompt: { tokens: systemTokens, items: systemTokens > 0 ? 1 : 0, estimate: true }, + toolDefs: { tokens: toolDefTokens, items: 0, estimate: true }, + userMessages: { tokens: 0, items: 0, estimate: true }, + assistantText: { tokens: 0, items: 0, estimate: true }, + reasoning: { tokens: 0, items: 0, estimate: true }, + toolResults: { tokens: 0, items: 0, estimate: true }, + }; + let toolDefsSeen = false; + const events = []; + const fileMap = new Map(); + const ioCounts = { user: 0, read: 0, write: 0, edit: 0, search: 0, bash: 0, tools: 0 }; + const commandStats = { exec: 0, failed: 0 }; + const toolCount = new Map(); + + for (const row of partRows) { + let part; + try { + part = JSON.parse(row.data); + } catch { + continue; + } + const type = String(part.type || ""); + if (type === "text") { + const tokens = tokensOfBytes(sizeOf(part.text)); + // assistant text parts appear after a reasoning/tool part; user text + // parts come directly after a step-start following a user turn — the + // split below uses the part sequence heuristics from the local store. + composition.userMessages.tokens += tokens; + composition.userMessages.items += 1; + events.push({ kind: "message", role: "user", at: num(row.time_created), text: clampText(part.text, 500) }); + } else if (type === "reasoning") { + const tokens = tokensOfBytes(sizeOf(part.text)); + composition.reasoning.tokens += tokens; + composition.reasoning.items += 1; + } else if (type === "tool") { + const input = part.state?.input || {}; + const output = part.state?.output; + const tool = String(part.tool || "?"); + composition.toolResults.tokens += tokensOfBytes(sizeOf(output)); + composition.toolResults.items += 1; + ioCounts.tools += 1; + toolCount.set(tool, (toolCount.get(tool) || 0) + 1); + if (tool === "Bash" || tool === "PowerShell" || tool === "pwsh") { + ioCounts.bash += 1; + commandStats.exec += 1; + if (String(part.state?.status || "") === "error") commandStats.failed += 1; + } + if (["WebFetch", "WebSearch", "Grep", "Glob"].includes(tool)) ioCounts.search += 1; + if (["Read", "Write", "Edit", "NotebookEdit"].includes(tool)) { + if (tool === "Read") ioCounts.read += 1; + if (tool === "Write") ioCounts.write += 1; + if (tool === "Edit") ioCounts.edit += 1; + const file = String(input.file_path || input.notebook_path || ""); + if (file) { + const entry = fileMap.get(file) || { file, read: 0, write: 0, edit: 0, at: 0 }; + if (tool === "Read") entry.read += 1; + if (tool === "Write") entry.write += 1; + if (tool === "Edit") entry.edit += 1; + entry.at = Math.max(entry.at, num(row.time_created)); + fileMap.set(file, entry); + } + } + if (events.length < MAX_EVENTS) { + events.push({ + kind: "tool", + tool, + at: num(row.time_created), + status: String(part.state?.status || ""), + text: clampText( + input.command || input.description || input.file_path || input.query || "", + 500, + ), + }); + } + } else if (type === "step-start" && !toolDefsSeen) { + // nothing; tool defs are taken from the message snapshot above + } + } + + // Distinguish user vs assistant text: message roles from the message table. + const msgRows = db + .prepare("select data, time_created from message where session_id = ? order by time_created asc") + .all(sessionId); + const assistantMsgCount = msgRows.filter((m) => { + try { + return JSON.parse(m.data).role === "assistant"; + } catch { + return false; + } + }).length; + + const turnCount = msgRows.filter((m) => { + try { + const d = JSON.parse(m.data); + return d.role === "user" && !d.synthetic && d.visibility !== "hidden"; + } catch { + return false; + } + }).length; + + // Move roughly half of text tokens to the assistant bucket (heuristic). + const half = Math.round(composition.userMessages.tokens * 0.55); + composition.assistantText.tokens = half; + composition.assistantText.items = assistantMsgCount; + composition.userMessages.tokens -= half; + ioCounts.user = turnCount; + + // --- 费用构成估算:各类 token 按最近模型牌价折算为 CNY。 --- + const compPartsForCost = [ + { label: "系统提示词", tokens: composition.systemPrompt.tokens }, + { label: "工具定义", tokens: composition.toolDefs.tokens }, + { label: "用户消息", tokens: composition.userMessages.tokens }, + { label: "助手回复", tokens: composition.assistantText.tokens }, + { label: "思考", tokens: composition.reasoning.tokens }, + { label: "工具结果", tokens: composition.toolResults.tokens }, + ]; + const lastModelForCost = usage.length ? String(usage[usage.length - 1].model_id || "") : ""; + const compCostTotal = compPartsForCost.reduce( + (acc, g) => + acc + costOfUsage({ modelId: lastModelForCost, input: g.tokens, output: 0, cacheRead: 0, cacheWrite: 0 }), + 0, + ); + const costComposition = { + totalTokens: compPartsForCost.reduce((acc, g) => acc + g.tokens, 0), + totalCost: Math.round(compCostTotal * 100000) / 100000, + parts: compPartsForCost.map((g) => { + const c = + costOfUsage({ modelId: lastModelForCost, input: g.tokens, output: 0, cacheRead: 0, cacheWrite: 0 }); + return { + label: g.label, + cost: Math.round(c * 100000) / 100000, + pct: compCostTotal > 0 ? c / compCostTotal : 0, + }; + }), + }; + + // --- 工具调用 Top(当前会话)。 --- + const toolTop = [...toolCount.entries()] + .map(([tool, calls]) => ({ tool, calls })) + .sort((a, b) => b.calls - a.calls) + .slice(0, 10); + + // --- Events: errors, compaction markers. --- + for (const row of usage) { + if (String(row.query_source || "") === "compact") { + events.push({ + kind: "compact", + at: num(row.started_at), + text: "上下文压缩(compact)请求", + }); + } + if (row.error_message) { + events.push({ + kind: "error", + at: num(row.started_at), + text: clampText(row.error_message, 120), + }); + } + } + events.sort((a, b) => a.at - b.at); + + // --- Stats. --- + const totalTokens = usage.reduce((acc, r) => acc + (num(r.computed_total_tokens) || 0), 0); + const cost = usage.reduce( + (acc, r) => + acc + + costOfUsage({ + modelId: r.model_id, + input: num(r.input_tokens), + output: num(r.output_tokens), + cacheRead: num(r.cache_read_input_tokens), + cacheWrite: num(r.cache_creation_input_tokens), + }), + 0, + ); + const lastUsage = usage[usage.length - 1] || null; + const lastModelId = lastUsage ? String(lastUsage.model_id) : ""; + const window = windowOfModel(lastModelId); + const currentContextTokens = lastUsage ? num(lastUsage.input_tokens) : 0; + + // --- 耗时分解(对齐 dsh statsTiming):模型等待=Σttft;生成时长拆思考/回复 + // (按 reasoning/output token 占比);工具执行=相邻请求间隙(≤120s);其他=span−上述。 + let modelWaitMs = 0; + let genMs = 0; + let reasoningTokensAll = 0; + let outputTokensAll = 0; + for (const r of usage) { + const ttft = Math.max(0, num(r.time_to_first_token_ms)); + const dur = Math.max(0, num(r.duration_ms)); + modelWaitMs += ttft; + genMs += Math.max(0, dur - ttft); + reasoningTokensAll += num(r.reasoning_tokens); + outputTokensAll += num(r.output_tokens); + } + const thinkShare = + reasoningTokensAll + outputTokensAll > 0 + ? reasoningTokensAll / (reasoningTokensAll + outputTokensAll) + : 0.2; + const thinkMs = Math.round(genMs * thinkShare); + const replyMs = genMs - thinkMs; + let spanMs = 0; + { + let prev = null; + for (const r of usage) { + const st = num(r.started_at); + const en = num(r.completed_at) || st + num(r.duration_ms); + if (prev !== null && st > prev && st - prev <= 10 * 60 * 1000) spanMs += st - prev; + spanMs += num(r.duration_ms); + prev = en; + } + } + let toolMs = 0; + { + let prevEnd = null; + for (const r of usage) { + const st = num(r.started_at); + const en = num(r.completed_at) || st + num(r.duration_ms); + if (prevEnd !== null && st > prevEnd && st - prevEnd <= 600 * 1000) toolMs += st - prevEnd; + prevEnd = en; + } + } + const timing = { + modelWaitMs, + thinkMs, + replyMs, + toolMs, + otherMs: Math.max(0, spanMs - modelWaitMs - genMs - toolMs), + spanMs, + }; + + const ttft = usage + .map((r) => num(r.time_to_first_token_ms)) + .filter((v) => v > 0); + const outputTokens = outputTokensAll; + const modelMs = usage.reduce((acc, r) => acc + num(r.duration_ms), 0); + + const cacheReadTotal = usage.reduce((acc, r) => acc + num(r.cache_read_input_tokens), 0); + const cacheWriteTotal = usage.reduce((acc, r) => acc + num(r.cache_creation_input_tokens), 0); + const inputTotal = usage.reduce((acc, r) => acc + num(r.input_tokens), 0); + + const files = [...fileMap.values()] + .sort((a, b) => b.read + b.write + b.edit - (a.read + a.write + a.edit)) + .slice(0, 400) + .map((entry) => ({ + ...entry, + file: entry.file.replace(/^.*[\\/]/, ""), + full: entry.file, + })); + + return finish( + { + ok: true, + session: { + id: session.id, + title: session.title, + directory: session.directory, + workspace: workspaceLabel(session.directory, session.project_id), + startedAt: num(session.time_created), + lastActiveAt: num(session.time_updated), + additions: num(session.summary_additions), + deletions: num(session.summary_deletions), + compacted: Boolean(session.time_compacting), + }, + turns: turnCount, + requests: usage.length, + steps: usage.length, + usage: usage.map((row) => ({ + startedAt: num(row.started_at), + completedAt: num(row.completed_at), + durationMs: num(row.duration_ms), + ttftMs: num(row.time_to_first_token_ms), + modelId: String(row.model_id), + providerId: String(row.provider_id), + querySource: String(row.query_source || ""), + inputTokens: num(row.input_tokens), + outputTokens: num(row.output_tokens), + reasoningTokens: num(row.reasoning_tokens), + cacheReadTokens: num(row.cache_read_input_tokens), + toolCalls: num(row.tool_call_count), + retryCount: num(row.retry_count), + })), + trend, + composition, + promptPreview, + toolNames, + costComposition, + ioCounts, + toolTop, + events: events.slice(-MAX_EVENTS), + files, + stats: { + totalTokens, + cost: round2(cost), + currentContextTokens, + contextWindow: window, + cacheReadTokens: cacheReadTotal, + cacheWriteTokens: cacheWriteTotal, + cacheRate: inputTotal + cacheReadTotal + cacheWriteTotal > 0 + ? cacheReadTotal / (inputTotal + cacheReadTotal + cacheWriteTotal) + : 0, + contextPct: window > 0 ? Math.min(1, currentContextTokens / window) : 0, + lastModelId, + modelMs, + timing, + commandStats, + ttftAvgMs: ttft.length ? Math.round(ttft.reduce((a, b) => a + b, 0) / ttft.length) : 0, + outputSpeed: modelMs > 0 ? Math.round((outputTokens / modelMs) * 1000) : 0, + compositionTokens: Object.values(composition).reduce((acc, g) => acc + g.tokens, 0), + estimate: true, + }, + }, + started, + ); + } finally { + db.close(); + } +} + +try { + const result = await main(); + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + ok: false, + error: { code: "session_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/zcode-context/scripts/query-sessions.mjs b/external_plugins/zcode-context/scripts/query-sessions.mjs new file mode 100644 index 00000000..61a296a6 --- /dev/null +++ b/external_plugins/zcode-context/scripts/query-sessions.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Session list for the zcode-context dashboard. + * + * Prints one JSON document: { ok, sessions: [...] } with per-session usage, + * sub-agent counts, and last-context usage (estimated from the most recent + * completed request's input tokens vs. the model window). + */ +import { arg, num, openDb, workspaceLabel, finish } from "./lib/common.mjs"; +import { costOfUsage, round2, windowOfModel } from "./lib/pricing.mjs"; + +async function main() { + const started = Date.now(); + const now = Date.now(); + const start = num(arg("--start"), now - 7 * 24 * 3600 * 1000); + const end = num(arg("--end"), now); + const directory = arg("--directory") || ""; + const order = arg("--order") || "tokens"; + const limit = Math.min(num(arg("--limit"), 60), 120); + + const opened = await openDb(); + if (opened.error) return opened; + const db = opened.db; + try { + const args = [start, end]; + let dirFilter = ""; + if (directory) { + dirFilter = " and s.directory = ? "; + args.push(directory); + } + + const rows = db + .prepare( + `select s.id, s.title, s.directory, s.project_id, s.parent_id, + s.time_created, s.time_updated, + count(u.id) as requests, + coalesce(sum(u.computed_total_tokens), 0) as tokens, + coalesce(sum(u.input_tokens), 0) as input_tokens, + coalesce(sum(u.output_tokens), 0) as output_tokens, + coalesce(sum(u.tool_call_count), 0) as tool_calls, + coalesce(sum(u.duration_ms), 0) as duration_ms, + coalesce(max(u.started_at), 0) as last_request_at + from session s + join model_usage u on u.session_id = s.id and u.status = 'completed' + and u.started_at >= ? and u.started_at < ?${dirFilter} + where s.parent_id is null + and s.id not like 'sess_subagent%' + and s.time_deleted is null + group by s.id`, + ) + .all(...args); + + // 每会话工具调用列表(排行点击筛选会话卡用):一次分组 SQL。 + const toolsBySession = new Map(); + try { + const toolRows = db + .prepare( + `select p.session_id, json_extract(p.data, '$.tool') as tool, count(*) as calls + from part p + join session s on s.id = p.session_id + where json_extract(p.data, '$.type') = 'tool' + and p.time_created >= ? and p.time_created < ?${dirFilter} + group by p.session_id, tool`, + ) + .all(...args); + for (const r of toolRows) { + const sid = String(r.session_id || ""); + if (!sid) continue; + const list = toolsBySession.get(sid) || []; + list.push({ tool: String(r.tool || "?"), calls: num(r.calls) }); + toolsBySession.set(sid, list); + } + for (const list of toolsBySession.values()) { + list.sort((a, b) => b.calls - a.calls); + list.length = Math.min(list.length, 8); + } + } catch { + // tool lists stay empty; filtering by tool then yields nothing + } + + const sessions = rows.map((row) => { + const last = db + .prepare( + `select model_id, input_tokens, completed_at + from model_usage + where session_id = ? and status = 'completed' + order by started_at desc limit 1`, + ) + .get(row.id); + const children = db + .prepare("select count(*) as c from session where parent_id = ?") + .get(row.id).c; + const modelId = last ? String(last.model_id) : ""; + const contextTokens = last ? num(last.input_tokens) : 0; + const window = windowOfModel(modelId); + return { + id: String(row.id), + title: String(row.title || row.id), + directory: String(row.directory || ""), + workspace: workspaceLabel(row.directory, row.project_id), + startedAt: num(row.time_created), + lastActiveAt: Math.max(num(row.time_updated), num(row.last_request_at)), + requests: num(row.requests), + steps: num(row.requests), + tools: toolsBySession.get(String(row.id)) || [], + tokens: num(row.tokens), + cost: round2( + costOfUsage({ + modelId: modelId, + input: num(row.input_tokens), + output: num(row.output_tokens), + }), + ), + toolCalls: num(row.tool_calls), + activeMs: num(row.duration_ms), + subAgents: num(children.c ?? children), + contextTokens, + contextWindow: window, + contextPct: window > 0 && last ? Math.min(1, contextTokens / window) : 0, + lastModelId: modelId, + compacted: Boolean( + db + .prepare("select time_compacting from session where id = ?") + .get(row.id)?.time_compacting, + ), + }; + }); + + sessions.sort((a, b) => { + if (order === "recent") return b.lastActiveAt - a.lastActiveAt; + if (order === "context") return b.contextPct - a.contextPct; + return b.tokens - a.tokens; + }); + + return finish({ ok: true, sessions: sessions.slice(0, limit) }, started); + } finally { + db.close(); + } +} + +try { + const result = await main(); + process.stdout.write(JSON.stringify(result)); +} catch (error) { + process.stdout.write( + JSON.stringify({ + ok: false, + error: { code: "sessions_failed", message: error?.message || String(error) }, + }), + ); + process.exitCode = 1; +} diff --git a/external_plugins/zcode-context/scripts/restore-backup.mjs b/external_plugins/zcode-context/scripts/restore-backup.mjs new file mode 100644 index 00000000..3b63c1b3 --- /dev/null +++ b/external_plugins/zcode-context/scripts/restore-backup.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Restore app.asar from the pre-patch backup created by replaceAsarWithBackup. + * Use this if ZCode cannot start after an injection attempt. + * + * Quit ZCode completely before running. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const RUNTIME = + (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) || + path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "zcode-context", "runtime"); + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const patchMod = await import(pathToFileURL(path.join(HERE, "asar-patch.mjs")).href); + const asar = patchMod.findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + console.error("未找到 app.asar;可用 --asar 指定。"); + process.exitCode = 2; + return; + } + const backup = patchMod.backupPathOf(asar); + if (!fs.existsSync(backup)) { + console.error(`备份不存在:${backup}`); + process.exitCode = 2; + return; + } + let check; + try { + check = patchMod.validateAsar(backup); + } catch (error) { + console.error(`备份本身校验失败,未恢复:${error.message}`); + process.exitCode = 1; + return; + } + try { + fs.copyFileSync(backup, asar); + const post = patchMod.validateAsar(asar); + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "status.json"), + JSON.stringify( + { + state: "restored", + message: "已从备份恢复 app.asar;重启 ZCode 后生效。", + updatedAt: new Date().toISOString(), + asarPath: asar, + backup, + injected: false, + pending: false, + }, + null, + 2, + ), + ); + } catch { + // diagnostic only + } + console.log(JSON.stringify({ status: "restored", asar, backup, check, post }, null, 2)); + } catch (error) { + console.error( + `恢复失败:${error.message}\n请完全退出 ZCode 后重试:\n node "${path.join(HERE, "restore-backup.mjs")}"`, + ); + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/external_plugins/zcode-context/scripts/status.mjs b/external_plugins/zcode-context/scripts/status.mjs new file mode 100644 index 00000000..828a5ca4 --- /dev/null +++ b/external_plugins/zcode-context/scripts/status.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Print the current injection status of zcode-context in a human-readable + * form, plus the recommended next step. Read-only. + * + * Usage: node status.mjs [--json] + */ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const RUNTIME = + (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) || + path.join(os.homedir(), ".zcode", "zcode-context", "runtime"); +const STATUS_PATH = path.join(RUNTIME, "status.json"); +const ASAR_HINTS = [ + "D:\Program Files\ZCode\resources\app.asar", + path.join(process.env.LOCALAPPDATA || "", "Programs", "ZCode", "resources", "app.asar"), +]; + +const GUIDANCE = { + injected: + "✅ 已注入:侧边栏入口应已可用。若刚更新过 ZCode/插件,请完全退出并重开 ZCode。", + "pending-restart": + "⏳ 等待重启:补丁已备好,但 app.asar 被运行中的 ZCode 占用。请完全退出 ZCode(包括托盘进程)后重新打开,入口即会出现。若重开后仍没有,再重启一次,或运行 /remove-panel 后重新安装。", + "needs-admin": + "🔒 需要管理员权限:写入 app.asar 被拒绝。请以管理员身份运行一次 ZCode,或设置 ZCODE_ASAR 环境变量指向实际的 app.asar 路径。", + "waiting-zusage": + "⏳ 排队中:token 用量悬浮条正在更新 app.asar,本插件已排定在它完成后自动注入。请稍后重启 ZCode。", + error: "❌ 出错:详见 status.json 的 message 字段;可尝试完全重启 ZCode 后重新打开会话触发自检。", + unknown: + "❓ 未找到 app.asar:请确认 ZCode 安装位置,或设置 ZCODE_ASAR 环境变量后重开会话。", +}; + +function findAsarState() { + for (const p of ASAR_HINTS) { + try { + if (p && fs.existsSync(p)) { + const s = fs.readFileSync(p).toString("latin1"); + return { p, injected: s.includes("zcode-context") }; + } + } catch { + // ignore unreadable + } + } + return null; +} + +async function main() { + let status = null; + try { + status = JSON.parse(fs.readFileSync(STATUS_PATH, "utf8")); + } catch { + console.log("尚未运行过注入自检(status.json 不存在)。请重开一个会话触发 SessionStart 自检,或重新安装插件。"); + return; + } + const asar = findAsarState(); + const lines = []; + lines.push(`状态:${status.state}`); + lines.push(`说明:${status.message}`); + lines.push(`插件版本:${status.version} 更新时间:${status.updatedAt}`); + if (asar) { + lines.push(`app.asar:${asar.p}${asar.injected ? "(包含 zcode-context 注入)" : "(尚未包含注入)"}`); + } + const g = GUIDANCE[status.state] || "请完全重启 ZCode 后再试;问题持续请运行 /remove-panel 并重新安装。"; + lines.push(g); + if (process.argv.includes("--json")) { + console.log(JSON.stringify({ ...status, guidance: g, asar }, null, 2)); + } else { + console.log(lines.join("\n")); + } +} + +try { + await main(); +} catch (error) { + console.log(`读取状态失败:${error.message}`); +} diff --git a/external_plugins/zcode-context/scripts/unpatch.mjs b/external_plugins/zcode-context/scripts/unpatch.mjs new file mode 100644 index 00000000..03c5b9e9 --- /dev/null +++ b/external_plugins/zcode-context/scripts/unpatch.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Remove only the zcode-context loader line from app.asar. + * Other injection lines (including the token-usage statusbar) are preserved. + * + * Run with ZCode fully quit when possible. If app.asar is locked, the command + * leaves a stage file and explains how to finish. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const RUNTIME = + (process.env.ZCONTEXT_RUNTIME_DIR && process.env.ZCONTEXT_RUNTIME_DIR.trim()) || + path.join(process.env.USERPROFILE || process.env.HOME || "", ".zcode", "zcode-context", "runtime"); + +function arg(name) { + const i = process.argv.indexOf(name); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main() { + const { findAppAsar, inspectAsar, buildUnpatchedAsar, replaceAsarWithBackup } = await import( + pathToFileURL(path.join(HERE, "asar-patch.mjs")).href + ); + const asar = findAppAsar(arg("--asar") || process.env.ZCODE_ASAR || process.env.ZCODE_APP_ASAR); + if (!asar) { + console.error("未找到 app.asar;可用 --asar 指定。"); + process.exitCode = 2; + return; + } + const before = inspectAsar(asar); + if (!before.hasBalanceLine) { + console.log(JSON.stringify({ status: "already-clean", asar }, null, 2)); + return; + } + const result = buildUnpatchedAsar({ asarPath: asar }); + if (result.status === "already-clean") { + console.log(JSON.stringify({ status: "already-clean", asar }, null, 2)); + return; + } + try { + const replaced = replaceAsarWithBackup(result.stagePath, asar, { + stageOptions: { expectBalance: false }, + postOptions: { expectBalance: false }, + }); + try { + fs.unlinkSync(`${result.stagePath}.json`); + } catch { + // ignore + } + try { + fs.mkdirSync(RUNTIME, { recursive: true }); + fs.writeFileSync( + path.join(RUNTIME, "status.json"), + JSON.stringify( + { + state: "disabled", + message: "上下文洞察入口已移除;重启 ZCode 后生效。", + updatedAt: new Date().toISOString(), + asarPath: asar, + injected: false, + pending: false, + backup: replaced.backup, + }, + null, + 2, + ), + ); + } catch { + // ignore + } + console.log(JSON.stringify({ status: "removed", asar, restartRequired: true }, null, 2)); + } catch (error) { + console.error( + `app.asar 当前被 ZCode 占用,未能替换(${error.message})。\n` + + `请完全退出 ZCode 后重新运行:\n node "${path.join(HERE, "unpatch.mjs")}"\n` + + `临时补丁文件保留在:${result.stagePath}`, + ); + process.exitCode = 1; + } +} + +main().catch((error) => { + console.error(error.stack || error.message || String(error)); + process.exitCode = 1; +}); diff --git a/marketplace.json b/marketplace.json index 95b67706..789cc698 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,33 @@ "image-to-video", "mcp" ] + }, + { + "name": "zcode-context", + "source": "./external_plugins/zcode-context", + "description": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.", + "description_i18n": { + "en": "Context-usage insight dashboard for ZCode: overview KPIs (sessions, tokens, cost estimate, cache hit rate, tool calls, active time), 7-day token+cost chart, 8-week activity heatmap, tool/command/project rankings with click-to-filter, per-session context composition bar, collapsible context browser, trend chart with pinned request detail, event log and file activity filters, cost composition, and an agent formation view. Purely local read-only SQLite queries; no other plugins or system Node.js required.", + "zh-CN": "ZCode 上下文洞察仪表盘:洞察总览(会话/Token/费用估算/缓存命中率/工具调用/活跃时长 KPI)、近 7 天 Token+费用图、近 8 周活跃热力图、可点击筛选的工具/命令/项目排行、单会话上下文构成条、可折叠上下文浏览器、趋势图(步进/轮次 × 总计/增量,可固定单步详情)、事件日志与文件活动筛选、费用构成、Agent 编队视图。纯本地只读 SQLite 查询,不依赖其他插件,无需系统 Node.js。" + }, + "version": "0.2.0", + "author": { + "name": "AwenJackson", + "url": "https://github.com/AwenJackson" + }, + "category": "developer-tools", + "keywords": [ + "context-window", + "token-usage", + "token-statistics", + "cost-estimation", + "cache-hit-rate", + "llm-observability", + "dashboard", + "agent-monitoring", + "context-insight", + "dsh-context" + ] } ] -} \ No newline at end of file +}