dsh-ws-recency 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +14 -2
  2. package/client.js +292 -5
  3. package/index.js +299 -11
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-ws-recency
2
2
 
3
- DSH 侧栏的五件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**。单独装上就有效,不依赖其它插件。
3
+ DSH 侧栏的六件事:**工作区分组按最近活跃排序**、**过期历史自动折叠**、**运行中/未读/需跟进三色标记**、**所有置顶会话汇总到顶部一个区**、**删除会话到可恢复的回收站**、**按工具分类的 token 消耗统计**。单独装上就有效,不依赖其它插件。
4
4
 
5
5
  ## 一、分组排序
6
6
 
@@ -63,6 +63,18 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
63
63
 
64
64
  **不是物理删除**:目录只是被改名搬走,恢复就是把 `.trash/ws-recency/<id>-<时间>/` 移回 `sessions/<project>/<id>/`。这条删除走的是宿主侧路由(`POST /ws-recency/session-trash`,仅接受回环来源),因为浏览器侧碰不到文件系统。
65
65
 
66
+ ## 六、Token 消耗统计
67
+
68
+ 会话「…」菜单 →「Token 消耗统计」:
69
+
70
+ - **真实账单**(每步记录的 usage):步数、输入(未缓存)、缓存命中、输出、合计;
71
+ - **结构占比**(按内容估算):主 agent 自己(思考 / 回复 / 用户消息 / 系统提示)vs 每个工具,一条占比条 + 图例;
72
+ - **工具表**:调用次数、参数估算、结果估算、合计、占比,按合计降序。
73
+
74
+ 口径说明:prompt 的 token 是「整段上下文每一步都计费」,没法精确归属到某一条消息,所以「主 agent / 工具」的拆分是按内容长度估算(中文≈1 token/字,英文≈4 字符/token),用来比较占比;**总量是真的**。数据从会话日志算,跨重启也能看。大会话第一次打开约 1–2 秒(29MB 日志实测 1.2 秒),10 秒内重复打开走缓存。
75
+
76
+ 宿主半(`index.js`)把统计与删除的路由挂在 webServer 上;如果面板报“宿主半未加载”,说明应用还没重启加载新版本。0.10.1 起面板失败时会带上 HTTP 状态和 `/ws-recency/health` 的自检结果,便于定位。
77
+
66
78
  ## 设置
67
79
 
68
80
  **设置 → 通用**:
@@ -98,7 +110,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
98
110
  或固定版本的预构建包:
99
111
 
100
112
  ```powershell
101
- dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.9.2/dsh-ws-recency-0.9.2.tgz
113
+ dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.10.1/dsh-ws-recency-0.10.1.tgz
102
114
  ```
103
115
 
104
116
  装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
package/client.js CHANGED
@@ -104,6 +104,29 @@
104
104
  ".wsr-pin-unpin{flex:none;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:11px;padding:2px 4px;border-radius:4px;visibility:hidden}",
105
105
  ".wsr-pin-row:hover .wsr-pin-unpin{visibility:visible}",
106
106
  ".wsr-pin-unpin:hover{color:var(--dsw-alias-label-primary)}",
107
+ ".wsr-modal-backdrop{position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.45)}",
108
+ ".wsr-modal{width:min(720px,92vw);max-height:84vh;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1,#1f2226);color:var(--dsw-alias-label-primary,#e8e8e8);border:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.14));border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden}",
109
+ ".wsr-modal-head{display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(255,255,255,.1))}",
110
+ ".wsr-modal-title{font-size:14px;font-weight:600}",
111
+ ".wsr-modal-close{border:none;background:transparent;color:var(--dsw-alias-label-tertiary,#9aa0a6);font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px}",
112
+ ".wsr-modal-close:hover{color:var(--dsw-alias-label-primary,#e8e8e8)}",
113
+ ".wsr-modal-body{padding:14px;overflow:auto;display:flex;flex-direction:column;gap:12px;font-size:12px}",
114
+ ".wsr-stats-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}",
115
+ ".wsr-stats-card{background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.04));border-radius:8px;padding:8px 10px}",
116
+ ".wsr-stats-value{font-size:15px;font-weight:600}",
117
+ ".wsr-stats-label{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
118
+ ".wsr-stats-bar{display:flex;height:10px;border-radius:5px;overflow:hidden;background:rgba(255,255,255,.06)}",
119
+ ".wsr-stats-seg{height:100%}",
120
+ ".wsr-stats-legend{display:flex;flex-wrap:wrap;gap:10px}",
121
+ ".wsr-stats-legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--dsw-alias-label-tertiary)}",
122
+ ".wsr-stats-swatch{width:8px;height:8px;border-radius:2px}",
123
+ ".wsr-stats-table{display:flex;flex-direction:column}",
124
+ ".wsr-stats-row{display:grid;grid-template-columns:1.4fr .6fr .8fr .8fr .8fr .6fr;gap:6px;padding:6px 4px;border-bottom:.5px solid rgba(255,255,255,.06)}",
125
+ ".wsr-stats-head{color:var(--dsw-alias-label-tertiary);font-size:11px}",
126
+ ".wsr-td{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
127
+ ".wsr-td-name{font-weight:500}",
128
+ ".wsr-td-strong{font-weight:600}",
129
+ ".wsr-stats-note{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.5}",
107
130
  ".wsr-toggle{width:100%;height:28px;padding:0 12px 0 calc(28px + var(--dsh-workspace-indent,0px));cursor:pointer;text-align:left;color:var(--dsw-alias-label-tertiary);background:0 0;border:none;font-size:12px}",
108
131
  ".wsr-toggle:hover{color:var(--dsw-alias-label-secondary)}",
109
132
  '[' + STATE_ATTR + '="running"] [class*="_title"]{color:#4f8ef7}',
@@ -138,6 +161,26 @@
138
161
  "menu.flag": "\u6807\u8bb0\u9700\u8981\u8ddf\u8fdb",
139
162
  "menu.unflag": "\u53d6\u6d88\u8ddf\u8fdb\u6807\u8bb0",
140
163
  "menu.trash": "\u5220\u9664\u4f1a\u8bdd\uff08\u79fb\u5165\u56de\u6536\u7ad9\uff09",
164
+ "menu.stats": "Token \u6d88\u8017\u7edf\u8ba1",
165
+ "stats.title": "Token \u6d88\u8017\u7edf\u8ba1",
166
+ "stats.loading": "\u7edf\u8ba1\u4e2d\u2026\uff08\u5927\u4f1a\u8bdd\u9700\u8981\u4e00\u4e24\u79d2\uff09",
167
+ "stats.failed": "\u7edf\u8ba1\u5931\u8d25\uff1a",
168
+ "stats.steps": "\u6b65\u6570",
169
+ "stats.input": "\u8f93\u5165\uff08\u672a\u7f13\u5b58\uff09",
170
+ "stats.cache": "\u7f13\u5b58\u547d\u4e2d",
171
+ "stats.output": "\u8f93\u51fa",
172
+ "stats.total": "\u5408\u8ba1",
173
+ "stats.agent": "\u4e3b agent",
174
+ "stats.col.source": "\u6765\u6e90",
175
+ "stats.col.calls": "\u8c03\u7528",
176
+ "stats.col.args": "\u53c2\u6570",
177
+ "stats.col.results": "\u7ed3\u679c",
178
+ "stats.col.total": "\u4f30\u7b97",
179
+ "stats.col.share": "\u5360\u6bd4",
180
+ "stats.note": "\u5408\u8ba1\u6765\u81ea\u6bcf\u6b65\u8bb0\u5f55\u7684\u771f\u5b9e usage\uff08\u7f13\u5b58\u547d\u4e2d + \u8f93\u5165 + \u8f93\u51fa\uff09\uff1b\u672c\u8868\u6309\u6765\u6e90\u7684\u62c6\u5206\u662f\u6309\u5185\u5bb9\u957f\u5ea6\u4f30\u7b97\uff08\u4e2d\u6587\u7ea6 1 token/\u5b57\uff0c\u82f1\u6587\u7ea6 4 \u5b57\u7b26/token\uff09\uff0c\u7528\u4e8e\u6bd4\u8f83\u5360\u6bd4\uff0c\u4e0d\u662f\u8ba1\u8d39\u53e3\u5f84\u3002",
181
+ "stats.agentDetail": "主 agent 构成:思考 {reasoning} · 回复 {text} · 用户消息 {user} · 系统提示 {system}",
182
+ "stats.diagnose.unmounted": "\n\n(宿主半已加载,但统计路由没挂上——重启应用再试)",
183
+ "stats.diagnose.noHost": "\n\n(宿主半未加载:需要重启应用加载新版本)",
141
184
  "trash.confirm": "\u5220\u9664\u8fd9\u4e2a\u4f1a\u8bdd\uff1f\n\n\u4f1a\u5148\u505c\u6b62\u5b83\u6b63\u5728\u8fdb\u884c\u7684\u5de5\u4f5c\uff0c\u518d\u628a\u65e5\u5fd7\u76ee\u5f55\u79fb\u5165\u56de\u6536\u7ad9\uff08\u4e0d\u662f\u7269\u7406\u5220\u9664\uff0c\u53ef\u4ee5\u624b\u52a8\u6062\u590d\uff09\u3002",
142
185
  "trash.failed": "\u5220\u9664\u5931\u8d25\uff1a",
143
186
  };
@@ -158,6 +201,26 @@
158
201
  "menu.flag": "Mark as needs follow-up",
159
202
  "menu.unflag": "Clear follow-up mark",
160
203
  "menu.trash": "Delete session (move to trash)",
204
+ "menu.stats": "Token statistics",
205
+ "stats.title": "Token statistics",
206
+ "stats.loading": "Counting\u2026 (a large session takes a second or two)",
207
+ "stats.failed": "Statistics failed: ",
208
+ "stats.steps": "steps",
209
+ "stats.input": "input (uncached)",
210
+ "stats.cache": "cache read",
211
+ "stats.output": "output",
212
+ "stats.total": "total",
213
+ "stats.agent": "main agent",
214
+ "stats.col.source": "source",
215
+ "stats.col.calls": "calls",
216
+ "stats.col.args": "args",
217
+ "stats.col.results": "results",
218
+ "stats.col.total": "estimate",
219
+ "stats.col.share": "share",
220
+ "stats.note": "Totals come from the recorded per-step usage (cache read + input + output). The per-source split is estimated from content length (about 1 token per CJK character, 4 characters per token otherwise) so the shares are comparable, not billable.",
221
+ "stats.agentDetail": "Agent composition: reasoning {reasoning} \u00b7 reply {text} \u00b7 user messages {user} \u00b7 system prompt {system}",
222
+ "stats.diagnose.unmounted": "\n\n(the host half is loaded but the statistics route did not mount - restart the app and retry)",
223
+ "stats.diagnose.noHost": "\n\n(the host half is not loaded: restart the app to pick up the new version)",
161
224
  "trash.confirm": "Delete this session?\n\nIt stops whatever the session is running, then moves the log directory into the trash folder (a move, not an erase: you can restore it by hand).",
162
225
  "trash.failed": "Delete failed: ",
163
226
  };
@@ -826,16 +889,22 @@
826
889
  var question = t("trash.confirm");
827
890
  var confirmed = typeof window.confirm === "function" ? window.confirm(question) : false;
828
891
  if (!confirmed) return;
829
- fetch("/ws-recency/session-trash", {
892
+ fetch("/ws-recency/session-trash?sessionId=" + encodeURIComponent(props.sessionId), {
830
893
  method: "POST",
831
894
  headers: { "content-type": "application/json" },
832
895
  body: JSON.stringify({ sessionId: props.sessionId }),
833
896
  })
834
- .then(function (response) { return response.json(); })
897
+ .then(function (response) {
898
+ return response.text().then(function (text) {
899
+ var parsed = null;
900
+ try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
901
+ return { status: response.status, text: text, json: parsed };
902
+ });
903
+ })
835
904
  .then(function (result) {
836
- if (result !== null && result.ok !== true && typeof window.alert === "function") {
837
- window.alert(t("trash.failed") + (result.error || "unknown"));
838
- }
905
+ if (result.json !== null && result.json.ok === true) return;
906
+ var reason = (result.json !== null && result.json.error) || (result.text || "").trim().slice(0, 120) || "(empty response)";
907
+ if (typeof window.alert === "function") window.alert(t("trash.failed") + "HTTP " + result.status + " \u00b7 " + reason);
839
908
  })
840
909
  .catch(function (error) {
841
910
  if (typeof window.alert === "function") window.alert(t("trash.failed") + (error && error.message ? error.message : error));
@@ -844,6 +913,214 @@
844
913
  }, props.t("menu.trash"));
845
914
  }
846
915
 
916
+ /* ------------------------- token statistics ------------------------- */
917
+
918
+ var statsModal = null;
919
+
920
+ function formatTokens(value) {
921
+ var n = Number(value) || 0;
922
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
923
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
924
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
925
+ return String(Math.round(n));
926
+ }
927
+
928
+ function formatShare(share) {
929
+ if (typeof share !== "number" || !Number.isFinite(share)) return "-";
930
+ return (share * 100).toFixed(1) + "%";
931
+ }
932
+
933
+ function closeStatsPanel() {
934
+ if (statsModal !== null) {
935
+ if (typeof statsModal.remove === "function") statsModal.remove();
936
+ statsModal = null;
937
+ }
938
+ if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
939
+ document.removeEventListener("keydown", onStatsKey);
940
+ }
941
+ }
942
+
943
+ function onStatsKey(event) {
944
+ if (event !== undefined && event.key === "Escape") closeStatsPanel();
945
+ }
946
+
947
+ function statsDiv(className, text) {
948
+ var node = document.createElement("div");
949
+ node.className = className;
950
+ if (text !== undefined) node.textContent = text;
951
+ return node;
952
+ }
953
+
954
+ /** One table row: source, calls, argument tokens, result tokens, total, share. */
955
+ function statsRow(label, calls, args, results, total, share) {
956
+ var row = document.createElement("div");
957
+ row.className = "wsr-stats-row";
958
+ var name = statsDiv("wsr-td wsr-td-name", label);
959
+ row.appendChild(name);
960
+ row.appendChild(statsDiv("wsr-td", calls === null ? "-" : String(calls)));
961
+ row.appendChild(statsDiv("wsr-td", args === null ? "-" : formatTokens(args)));
962
+ row.appendChild(statsDiv("wsr-td", results === null ? "-" : formatTokens(results)));
963
+ row.appendChild(statsDiv("wsr-td wsr-td-strong", formatTokens(total)));
964
+ row.appendChild(statsDiv("wsr-td", formatShare(share)));
965
+ return row;
966
+ }
967
+
968
+ function renderStats(body, stats) {
969
+ body.textContent = "";
970
+ while (body.firstChild !== null) body.removeChild(body.firstChild);
971
+ var totals = stats.totals || {};
972
+ var estimate = stats.estimate || {};
973
+ var agent = estimate.agent || {};
974
+
975
+ var grid = document.createElement("div");
976
+ grid.className = "wsr-stats-grid";
977
+ var cards = [
978
+ [t("stats.steps"), formatTokens(stats.steps)],
979
+ [t("stats.input"), formatTokens(totals.inputTokens)],
980
+ [t("stats.cache"), formatTokens(totals.cacheReadTokens)],
981
+ [t("stats.output"), formatTokens(totals.outputTokens)],
982
+ [t("stats.total"), formatTokens(totals.totalTokens)],
983
+ ];
984
+ for (var i = 0; i < cards.length; i += 1) {
985
+ var card = statsDiv("wsr-stats-card");
986
+ card.appendChild(statsDiv("wsr-stats-value", cards[i][1]));
987
+ card.appendChild(statsDiv("wsr-stats-label", cards[i][0]));
988
+ grid.appendChild(card);
989
+ }
990
+ body.appendChild(grid);
991
+
992
+ var tools = estimate.tools || [];
993
+ var palette = ["#d97706", "#059669", "#7c3aed", "#db2777", "#0891b2", "#65a30d", "#9333ea", "#dc2626"];
994
+ var segments = [{ name: t("stats.agent"), share: agent.share || 0, color: "#4b8bf5" }];
995
+ for (var j = 0; j < tools.length; j += 1) segments.push({ name: tools[j].name, share: tools[j].share || 0, color: palette[j % palette.length] });
996
+
997
+ var bar = statsDiv("wsr-stats-bar");
998
+ for (var k = 0; k < segments.length; k += 1) {
999
+ if (segments[k].share <= 0) continue;
1000
+ var piece = statsDiv("wsr-stats-seg");
1001
+ piece.style.width = (segments[k].share * 100).toFixed(2) + "%";
1002
+ piece.style.background = segments[k].color;
1003
+ piece.setAttribute("title", segments[k].name + " " + formatShare(segments[k].share));
1004
+ bar.appendChild(piece);
1005
+ }
1006
+ body.appendChild(bar);
1007
+
1008
+ var legend = statsDiv("wsr-stats-legend");
1009
+ for (var m = 0; m < segments.length; m += 1) {
1010
+ if (segments[m].share <= 0) continue;
1011
+ var item = statsDiv("wsr-stats-legend-item");
1012
+ var swatch = statsDiv("wsr-stats-swatch");
1013
+ swatch.style.background = segments[m].color;
1014
+ item.appendChild(swatch);
1015
+ item.appendChild(statsDiv("wsr-stats-legend-text", segments[m].name + " " + formatShare(segments[m].share)));
1016
+ legend.appendChild(item);
1017
+ }
1018
+ body.appendChild(legend);
1019
+
1020
+ var table = statsDiv("wsr-stats-table");
1021
+ var head = document.createElement("div");
1022
+ head.className = "wsr-stats-row wsr-stats-head";
1023
+ var columns = ["stats.col.source", "stats.col.calls", "stats.col.args", "stats.col.results", "stats.col.total", "stats.col.share"];
1024
+ for (var n = 0; n < columns.length; n += 1) head.appendChild(statsDiv("wsr-td", t(columns[n])));
1025
+ table.appendChild(head);
1026
+ table.appendChild(statsRow(t("stats.agent"), stats.steps, null, null, agent.total, agent.share));
1027
+ for (var p = 0; p < tools.length; p += 1) {
1028
+ var tool = tools[p];
1029
+ table.appendChild(statsRow(tool.name, tool.calls, tool.args, tool.results, tool.total, tool.share));
1030
+ }
1031
+ body.appendChild(table);
1032
+
1033
+ body.appendChild(statsDiv("wsr-stats-detail", t("stats.agentDetail", {
1034
+ reasoning: formatTokens(agent.reasoning),
1035
+ text: formatTokens(agent.text),
1036
+ user: formatTokens(agent.user),
1037
+ system: formatTokens(agent.system),
1038
+ })));
1039
+ body.appendChild(statsDiv("wsr-stats-note", t("stats.note")));
1040
+ }
1041
+
1042
+ function openStatsPanel(sessionId) {
1043
+ if (typeof document === "undefined") return;
1044
+ closeStatsPanel();
1045
+ var backdrop = statsDiv("wsr-modal-backdrop");
1046
+ var panel = statsDiv("wsr-modal");
1047
+ backdrop.appendChild(panel);
1048
+ var head = statsDiv("wsr-modal-head");
1049
+ head.appendChild(statsDiv("wsr-modal-title", t("stats.title")));
1050
+ var close = document.createElement("button");
1051
+ close.type = "button";
1052
+ close.className = "wsr-modal-close";
1053
+ close.textContent = "\u00d7";
1054
+ close.addEventListener("click", closeStatsPanel);
1055
+ head.appendChild(close);
1056
+ panel.appendChild(head);
1057
+ var body = statsDiv("wsr-modal-body");
1058
+ body.textContent = t("stats.loading");
1059
+ panel.appendChild(body);
1060
+ backdrop.addEventListener("click", function (event) {
1061
+ if (event !== undefined && event.target === backdrop) closeStatsPanel();
1062
+ });
1063
+ document.body.appendChild(backdrop);
1064
+ statsModal = backdrop;
1065
+ document.addEventListener("keydown", onStatsKey);
1066
+
1067
+ var controller = typeof AbortController === "function" ? new AbortController() : null;
1068
+ var timer = controller === null ? null : window.setTimeout(function () { controller.abort(); }, 180000);
1069
+ var request = fetch("/ws-recency/session-stats?sessionId=" + encodeURIComponent(sessionId), {
1070
+ method: "POST",
1071
+ headers: { "content-type": "application/json" },
1072
+ body: JSON.stringify({ sessionId: sessionId }),
1073
+ signal: controller === null ? undefined : controller.signal,
1074
+ }).then(function (response) {
1075
+ if (timer !== null) window.clearTimeout(timer);
1076
+ return response.text().then(function (text) {
1077
+ var parsed = null;
1078
+ try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
1079
+ return { status: response.status, text: text, json: parsed };
1080
+ });
1081
+ }, function (error) {
1082
+ if (timer !== null) window.clearTimeout(timer);
1083
+ throw error;
1084
+ });
1085
+
1086
+ request.then(function (result) {
1087
+ if (result.status === 200 && result.json !== null && result.json.ok === true) {
1088
+ renderStats(body, result.json.stats);
1089
+ return;
1090
+ }
1091
+ var reason = (result.json !== null && result.json.error)
1092
+ || (result.text || "").trim().slice(0, 120)
1093
+ || "(empty response)";
1094
+ body.textContent = t("stats.failed") + "HTTP " + result.status + " \u00b7 " + reason;
1095
+ /* A 405 here is the SPA fallback answering an unknown POST: the host
1096
+ half never mounted. Ask the health route which part is missing. */
1097
+ return fetch("/ws-recency/health").then(function (response) {
1098
+ return response.text();
1099
+ }).then(function (text) {
1100
+ var health = null;
1101
+ try { health = JSON.parse(text); } catch (error) { health = null; }
1102
+ body.textContent += health !== null && health.ok === true
1103
+ ? t("stats.diagnose.unmounted")
1104
+ : t("stats.diagnose.noHost");
1105
+ }).catch(function () {
1106
+ body.textContent += t("stats.diagnose.noHost");
1107
+ });
1108
+ }).catch(function (error) {
1109
+ body.textContent = t("stats.failed") + (error && error.message ? error.message : error);
1110
+ });
1111
+ }
1112
+
1113
+ /** Session "..." menu row: token statistics of that session. */
1114
+ function StatsMenuItem(props) {
1115
+ var Item = ui.MenuItemButton;
1116
+ if (typeof Item !== "function") return null;
1117
+ var Icon = ui.IconDataOutlineRegular;
1118
+ return h(Item, {
1119
+ icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
1120
+ onSelect: function () { openStatsPanel(props.sessionId); },
1121
+ }, props.t("menu.stats"));
1122
+ }
1123
+
847
1124
  /** Settings -> General row: how old a session must be before it is folded. */
848
1125
  function SettingsRow(props) {
849
1126
  var settings = props.useStore(function (state) { return state; });
@@ -1077,6 +1354,16 @@
1077
1354
  }, FollowUpDot);
1078
1355
  });
1079
1356
 
1357
+ ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
1358
+ return ctx.slots.register({
1359
+ name: "sidebar.workspaces.session.menu.item",
1360
+ id: "ws-recency-stats",
1361
+ order: 710,
1362
+ store: prefs,
1363
+ locale: NS,
1364
+ }, StatsMenuItem);
1365
+ });
1366
+
1080
1367
  ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
1081
1368
  return ctx.slots.register({
1082
1369
  name: "sidebar.workspaces.session.menu.item",
package/index.js CHANGED
@@ -25,17 +25,35 @@ import { dirname, join } from "node:path";
25
25
  /** Cordis plugin name shown by the loader. */
26
26
  export const name = "ws-recency";
27
27
 
28
- /** Host services this module asks for; `webServer` is injected separately. */
29
- export const inject = ["sessionPersistence", "workspaceRegistry", "sessions"];
28
+ /** Version marker reported by the health route. */
29
+ export const HOST_VERSION = "0.10.1";
30
30
 
31
- /** Exact route the client half calls. */
31
+ /**
32
+ * Exact routes the client half calls. Registered on the bare web server, so they
33
+ * win over the SPA's fallback (which answers unknown POSTs with an empty 405).
34
+ */
32
35
  export const TRASH_SESSION_PATH = "/ws-recency/session-trash";
36
+ export const STATS_SESSION_PATH = "/ws-recency/session-stats";
37
+ export const HEALTH_PATH = "/ws-recency/health";
33
38
 
34
39
  const STOP_SETTLE_MS = 500;
35
40
  const MAX_BODY_BYTES = 16 * 1024;
36
41
  const RENAME_RETRY = 4;
37
42
  const RENAME_GAP_MS = 150;
38
43
 
44
+ /** `?sessionId=` first, then the JSON body: a middleware may have consumed the body. */
45
+ function sessionIdFrom(req, body) {
46
+ try {
47
+ const url = new URL(req.url ?? "/", "http://localhost");
48
+ const fromQuery = url.searchParams.get("sessionId");
49
+ if (typeof fromQuery === "string" && fromQuery.trim() !== "") return fromQuery.trim();
50
+ } catch {
51
+ /* fall through to the body */
52
+ }
53
+ return body !== null && body !== undefined && typeof body.sessionId === "string" ? body.sessionId.trim() : "";
54
+ }
55
+
56
+ /** Only the app itself may call these routes: a foreign origin is refused. */
39
57
  function loopbackOrigin(req) {
40
58
  const origin = req.headers.origin;
41
59
  if (origin === undefined) return true;
@@ -196,7 +214,7 @@ export async function handleTrashRequest(ctx, req, res) {
196
214
  }
197
215
  try {
198
216
  const body = await readJsonBody(req);
199
- const sessionId = body !== null && typeof body.sessionId === "string" ? body.sessionId.trim() : "";
217
+ const sessionId = sessionIdFrom(req, body);
200
218
  if (sessionId === "") {
201
219
  sendJson(res, 400, { ok: false, error: "sessionId required" });
202
220
  return;
@@ -208,19 +226,289 @@ export async function handleTrashRequest(ctx, req, res) {
208
226
  }
209
227
  }
210
228
 
211
- /** Cordis entry point: mount the route when the host has a web server. */
229
+ /* ------------------------------------------------------------------------- *
230
+ * Token statistics
231
+ *
232
+ * The Client knows nothing about what a finished session cost, and a session may
233
+ * be months old, so this reads the session's own log. Recorded usage is exact
234
+ * (per assistant step: input / cache-read / output); the split between the agent
235
+ * itself and each tool is an estimate over the recorded content, because a
236
+ * prompt's tokens cannot be attributed to a single message - the whole context
237
+ * is billed every step.
238
+ *
239
+ * The log is a sequence of independent Zstandard frames, one per append, and the
240
+ * standard decoder stops after the first one. Splitting on the frame magic and
241
+ * decoding each frame is what makes the whole transcript readable.
242
+ * ------------------------------------------------------------------------- */
243
+
244
+ const STATS_CACHE_MS = 10 * 1000;
245
+ const statsCache = new Map();
246
+
247
+ /** bytes/4 per Latin character, ~1 token per CJK character. */
248
+ export function estimateTokens(text) {
249
+ if (typeof text !== "string" || text === "") return 0;
250
+ let cjk = 0;
251
+ let other = 0;
252
+ for (const character of text) {
253
+ const code = character.codePointAt(0);
254
+ if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0x3000 && code <= 0x303f) || (code >= 0xff00 && code <= 0xffef)) cjk += 1;
255
+ else other += 1;
256
+ }
257
+ return Math.ceil(cjk + other / 4);
258
+ }
259
+
260
+ /** Text of one content block / a whole content array, whatever shape it takes. */
261
+ function blockText(content) {
262
+ if (typeof content === "string") return content;
263
+ if (!Array.isArray(content)) return "";
264
+ let text = "";
265
+ for (const block of content) {
266
+ if (block === null || typeof block !== "object") continue;
267
+ if (typeof block.text === "string") text += block.text;
268
+ else if (typeof block.arguments === "string") text += block.arguments;
269
+ else if (typeof block.content === "string") text += block.content;
270
+ }
271
+ return text;
272
+ }
273
+
274
+ /**
275
+ * Aggregate one session's events into costs.
276
+ *
277
+ * Pure on purpose: the tests call it with hand-written events, and the route
278
+ * calls it with the decoded log.
279
+ */
280
+ export function aggregateSession(events) {
281
+ const summary = {
282
+ session: { id: "", title: "", cwd: "", createdAt: 0, lastAt: 0 },
283
+ steps: 0,
284
+ totals: { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
285
+ estimate: { agent: { reasoning: 0, text: 0, user: 0, system: 0, total: 0 }, tools: [], toolsTotal: 0, grandTotal: 0 },
286
+ };
287
+ const tools = new Map();
288
+ const callNames = new Map();
289
+ const toolOf = (name) => {
290
+ const key = typeof name === "string" && name !== "" ? name : "unknown";
291
+ let entry = tools.get(key);
292
+ if (entry === undefined) {
293
+ entry = { name: key, calls: 0, args: 0, results: 0, total: 0, share: 0 };
294
+ tools.set(key, entry);
295
+ }
296
+ return entry;
297
+ };
298
+
299
+ for (const event of events) {
300
+ if (event === null || typeof event !== "object") continue;
301
+ const data = event.data ?? {};
302
+ if (typeof event.time === "number") {
303
+ if (summary.session.createdAt === 0 || event.time < summary.session.createdAt) summary.session.createdAt = event.time;
304
+ if (event.time > summary.session.lastAt) summary.session.lastAt = event.time;
305
+ }
306
+ switch (event.type) {
307
+ case "session": {
308
+ if (typeof event.id === "string") summary.session.id = event.id;
309
+ if (typeof event.cwd === "string") summary.session.cwd = event.cwd;
310
+ if (typeof event.createdAt === "number") summary.session.createdAt = event.createdAt;
311
+ if (typeof data.createdAt === "number") summary.session.createdAt = data.createdAt;
312
+ break;
313
+ }
314
+ case "session/title": {
315
+ const title = typeof data === "string" ? data : data.title;
316
+ if (typeof title === "string" && title !== "") summary.session.title = title;
317
+ break;
318
+ }
319
+ case "assistant/message": {
320
+ const usage = data.usage;
321
+ if (usage !== null && typeof usage === "object") {
322
+ summary.steps += 1;
323
+ summary.totals.inputTokens += Number(usage.inputTokens) || 0;
324
+ summary.totals.cacheReadTokens += Number(usage.cacheReadTokens) || 0;
325
+ summary.totals.outputTokens += Number(usage.outputTokens) || 0;
326
+ summary.totals.totalTokens += Number(usage.totalTokens) || 0;
327
+ }
328
+ const content = data.message !== null && data.message !== undefined ? data.message.content : undefined;
329
+ for (const block of Array.isArray(content) ? content : []) {
330
+ if (block === null || typeof block !== "object") continue;
331
+ if (block.type === "reasoning") summary.estimate.agent.reasoning += estimateTokens(block.text);
332
+ else if (block.type === "text") summary.estimate.agent.text += estimateTokens(block.text);
333
+ else if (block.type === "tool-call" && block.id !== undefined) callNames.set(String(block.id), block.name);
334
+ }
335
+ break;
336
+ }
337
+ case "tool/call": {
338
+ const name = data.name;
339
+ if (data.callId !== undefined) callNames.set(String(data.callId), name);
340
+ const entry = toolOf(name);
341
+ entry.calls += 1;
342
+ entry.args += estimateTokens(data.arguments);
343
+ break;
344
+ }
345
+ case "tool/result": {
346
+ const name = callNames.get(String(data.toolCallId ?? data.message?.source?.callId ?? ""));
347
+ const entry = toolOf(name);
348
+ entry.results += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
349
+ break;
350
+ }
351
+ case "user/message": {
352
+ summary.estimate.agent.user += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
353
+ break;
354
+ }
355
+ case "system/message": {
356
+ summary.estimate.agent.system += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
357
+ break;
358
+ }
359
+ default:
360
+ break;
361
+ }
362
+ }
363
+
364
+ const agent = summary.estimate.agent;
365
+ agent.total = agent.reasoning + agent.text + agent.user + agent.system;
366
+ const list = [...tools.values()].sort((a, b) => (b.args + b.results) - (a.args + a.results));
367
+ for (const entry of list) entry.total = entry.args + entry.results;
368
+ summary.estimate.tools = list;
369
+ summary.estimate.toolsTotal = list.reduce((sum, entry) => sum + entry.total, 0);
370
+ summary.estimate.grandTotal = agent.total + summary.estimate.toolsTotal;
371
+ const grand = summary.estimate.grandTotal;
372
+ if (grand > 0) {
373
+ for (const entry of list) entry.share = entry.total / grand;
374
+ summary.estimate.agent.share = agent.total / grand;
375
+ }
376
+ return summary;
377
+ }
378
+
379
+ /**
380
+ * Read a session's events through the host's own persistence seam.
381
+ *
382
+ * Decoding the log by hand looked simpler, but it is a chain of independent
383
+ * Zstandard frames, and the host's Node build is not guaranteed to ship a zstd
384
+ * decoder. The persistence service owns both concerns (it carries its own frame
385
+ * decoder), so ask it instead of touching the file.
386
+ */
387
+ async function readSessionEvents(ctx, sessionId) {
388
+ const handle = await ctx.sessionPersistence.open(sessionId, "read");
389
+ const all = [];
390
+ try {
391
+ /* Page through in slices: one huge read holds the whole transcript plus its
392
+ parse alive at the same time, which is a lot for a months-old session. */
393
+ let offset = 0;
394
+ const page = 5000;
395
+ for (;;) {
396
+ const slice = await handle.read(offset, page);
397
+ const events = slice !== null && slice !== undefined && Array.isArray(slice.events) ? slice.events : [];
398
+ for (const event of events) all.push(event);
399
+ if (events.length < page) break;
400
+ offset += events.length;
401
+ if (offset >= 400000) break;
402
+ }
403
+ } finally {
404
+ try {
405
+ if (typeof handle.close === "function") await handle.close();
406
+ } catch {
407
+ /* closing is best effort */
408
+ }
409
+ }
410
+ return all;
411
+ }
412
+
413
+ /** Statistics for one session, cached briefly so re-opening the panel is cheap. */
414
+ export async function sessionStatistics(ctx, sessionId) {
415
+ let stat;
416
+ try {
417
+ stat = await ctx.sessionPersistence.stat(sessionId);
418
+ } catch {
419
+ return { ok: false, error: "session not found" };
420
+ }
421
+ if (stat === undefined || stat === null) return { ok: false, error: "session not found" };
422
+ const cacheKey = `${stat.sizeBytes ?? 0}:${stat.revision ?? ""}`;
423
+ const cached = statsCache.get(sessionId);
424
+ if (cached !== undefined && cached.cacheKey === cacheKey && Date.now() - cached.at < STATS_CACHE_MS) return { ok: true, stats: cached.stats };
425
+ try {
426
+ const events = await readSessionEvents(ctx, sessionId);
427
+ const stats = aggregateSession(events);
428
+ if (stats.session.id === "") stats.session.id = sessionId;
429
+ const header = stat.header;
430
+ if (header !== undefined && header !== null) {
431
+ if (stats.session.createdAt === 0 && typeof header.createdAt === "number") stats.session.createdAt = header.createdAt;
432
+ if (stats.session.cwd === "" && typeof header.cwd === "string") stats.session.cwd = header.cwd;
433
+ }
434
+ statsCache.set(sessionId, { cacheKey, stats, at: Date.now() });
435
+ return { ok: true, stats };
436
+ } catch (error) {
437
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
438
+ }
439
+ }
440
+
441
+ /** POST { sessionId } -> token statistics for that session. */
442
+ export async function handleStatsRequest(ctx, req, res) {
443
+ if ((req.method ?? "POST").toUpperCase() !== "POST") {
444
+ sendJson(res, 405, { ok: false, error: "method not allowed" });
445
+ return;
446
+ }
447
+ if (!loopbackOrigin(req)) {
448
+ sendJson(res, 403, { ok: false, error: "origin not trusted" });
449
+ return;
450
+ }
451
+ try {
452
+ const body = await readJsonBody(req);
453
+ const sessionId = sessionIdFrom(req, body);
454
+ if (sessionId === "") {
455
+ sendJson(res, 400, { ok: false, error: "sessionId required" });
456
+ return;
457
+ }
458
+ const result = await sessionStatistics(ctx, sessionId);
459
+ sendJson(res, result.ok ? 200 : 404, result);
460
+ } catch (error) {
461
+ sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Cordis entry point.
467
+ *
468
+ * `apply` itself must not wait for services: the Client half is served from this
469
+ * same package, and a plugin whose row never applies still shows its sidebar
470
+ * menu while none of its routes exist. So every capability is injected in its
471
+ * own nested scope, and the health route reports what actually mounted - the
472
+ * panel calls it to say "host plugin not loaded" instead of an empty 405.
473
+ */
212
474
  export function apply(ctx) {
213
475
  try {
214
- if (typeof ctx.inject === "function") {
215
- ctx.inject(["webServer"], (webCtx) => {
216
- webCtx.effect(() => webCtx.webServer.register({
476
+ if (typeof ctx.inject !== "function") return;
477
+ ctx.inject(["webServer"], (webCtx) => {
478
+ const server = webCtx.webServer;
479
+ if (server === undefined || server === null) return;
480
+ webCtx.effect(() => server.register({
481
+ kind: "exact",
482
+ path: HEALTH_PATH,
483
+ handler: (req, res) => sendJson(res, 200, {
484
+ ok: true,
485
+ version: HOST_VERSION,
486
+ routes: { stats: statsMounted, trash: trashMounted },
487
+ }),
488
+ }), "ws-recency: health route");
489
+
490
+ webCtx.inject(["sessionPersistence"], (statsCtx) => {
491
+ statsMounted = true;
492
+ webCtx.effect(() => server.register({
493
+ kind: "exact",
494
+ path: STATS_SESSION_PATH,
495
+ handler: (req, res) => handleStatsRequest(statsCtx, req, res),
496
+ }), "ws-recency: stats route");
497
+ });
498
+
499
+ webCtx.inject(["sessionPersistence", "workspaceRegistry", "sessions"], (trashCtx) => {
500
+ trashMounted = true;
501
+ webCtx.effect(() => server.register({
217
502
  kind: "exact",
218
503
  path: TRASH_SESSION_PATH,
219
- handler: (req, res) => handleTrashRequest(ctx, req, res),
504
+ handler: (req, res) => handleTrashRequest(trashCtx, req, res),
220
505
  }), "ws-recency: trash route");
221
506
  });
222
- }
507
+ });
223
508
  } catch {
224
- /* a headless profile simply gets no route */
509
+ /* a headless profile simply gets no routes */
225
510
  }
226
511
  }
512
+
513
+ let statsMounted = false;
514
+ let trashMounted = false;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-ws-recency",
3
- "version": "0.9.2",
4
- "description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, and session delete into a recoverable trash.",
3
+ "version": "0.10.1",
4
+ "description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks, session delete into a recoverable trash, and per-session token statistics split by tool.",
5
5
  "license": "MIT",
6
6
  "author": "chyxhtc",
7
7
  "repository": {