dsh-ws-recency 0.9.1 → 0.10.0
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.
- package/README.md +15 -3
- package/client.js +305 -8
- package/index.js +273 -3
- 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
|
|
|
@@ -12,7 +12,9 @@ DSH 已经把**组内**会话按 `updatedAt` 倒序排好了(排序方式 =
|
|
|
12
12
|
|
|
13
13
|
会话超过 `foldDays`(默认 **3 天**)没有更新就折叠起来;整个工作区都过期时,它只剩一行工作区标题——需要翻历史时点开就行。
|
|
14
14
|
|
|
15
|
-
-
|
|
15
|
+
- **每组一个控件**:宿主的「展开其余 N 条会话」被隐藏,由插件的 `展开历史 N` / `收起历史` 接管。N = 宿主还没渲染的行 + 已折叠的过期行;
|
|
16
|
+
- 点「展开历史」→ 插件逐轮驱动宿主那个隐藏按钮(每轮它会多渲染 5 条,剩下 ≤5 条时一次全出),直到整组都渲染出来,同时展开过期行——**一次点到全展开**;
|
|
17
|
+
- 点「收起历史」→ 重新折叠过期行(宿主已渲染的部分仍由插件按时间折叠,不会丢失);
|
|
16
18
|
- 选中 / 运行中 / 等待处理的会话**永不折叠**(可在设置里关掉这个豁免);
|
|
17
19
|
- 空白草稿行(新会话)不参与折叠判断;
|
|
18
20
|
- 每 5 分钟自动重算一次,所以跨过 3 天边界的会话会自己折叠,不用刷新。
|
|
@@ -61,6 +63,16 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
|
|
|
61
63
|
|
|
62
64
|
**不是物理删除**:目录只是被改名搬走,恢复就是把 `.trash/ws-recency/<id>-<时间>/` 移回 `sessions/<project>/<id>/`。这条删除走的是宿主侧路由(`POST /ws-recency/session-trash`,仅接受回环来源),因为浏览器侧碰不到文件系统。
|
|
63
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
|
+
|
|
64
76
|
## 设置
|
|
65
77
|
|
|
66
78
|
**设置 → 通用**:
|
|
@@ -96,7 +108,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
|
|
|
96
108
|
或固定版本的预构建包:
|
|
97
109
|
|
|
98
110
|
```powershell
|
|
99
|
-
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.
|
|
111
|
+
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.10.0/dsh-ws-recency-0.10.0.tgz
|
|
100
112
|
```
|
|
101
113
|
|
|
102
114
|
装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
|
package/client.js
CHANGED
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
var MIN_DAYS = 1;
|
|
70
70
|
var MAX_DAYS = 90;
|
|
71
71
|
var DEFAULT_DAYS = 3;
|
|
72
|
+
/** Safety stop for driving the host's own "show more" (5 rows per click). */
|
|
73
|
+
var MAX_EXPAND_CLICKS = 40;
|
|
72
74
|
var TICK_MS = 5 * 60 * 1000;
|
|
73
75
|
|
|
74
76
|
var data = { sessions: null, workspaces: null, settings: null, statuses: null, actions: null, service: null, t: null };
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
var dots = new Set();
|
|
83
85
|
var pinHidden = new Set();
|
|
84
86
|
var pinHost = null;
|
|
87
|
+
var expandClicks = new Map();
|
|
85
88
|
var expandedSections = new Set();
|
|
86
89
|
var headerMarked = new WeakSet();
|
|
87
90
|
var disposed = false;
|
|
@@ -91,6 +94,7 @@
|
|
|
91
94
|
"[" + ROOT_ATTR + '="true"]>*{flex:0 0 auto}',
|
|
92
95
|
"[" + HIDDEN_ATTR + '="true"]{display:none !important}',
|
|
93
96
|
"[" + PIN_HIDDEN_ATTR + '="true"]{display:none !important}',
|
|
97
|
+
'[class*="' + OVERFLOW_TOKEN + '"]{display:none !important}',
|
|
94
98
|
".wsr-pin{display:flex;flex-direction:column;flex:0 0 auto;padding:2px 0 6px;margin-bottom:4px;border-bottom:.5px solid var(--dsw-alias-border-l2)}",
|
|
95
99
|
".wsr-pin-head{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);padding:4px 12px 2px}",
|
|
96
100
|
".wsr-pin-row{display:flex;align-items:center;gap:8px;height:30px;padding:0 12px;border-radius:6px;cursor:pointer}",
|
|
@@ -100,6 +104,29 @@
|
|
|
100
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}",
|
|
101
105
|
".wsr-pin-row:hover .wsr-pin-unpin{visibility:visible}",
|
|
102
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}",
|
|
103
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}",
|
|
104
131
|
".wsr-toggle:hover{color:var(--dsw-alias-label-secondary)}",
|
|
105
132
|
'[' + STATE_ATTR + '="running"] [class*="_title"]{color:#4f8ef7}',
|
|
@@ -134,6 +161,24 @@
|
|
|
134
161
|
"menu.flag": "\u6807\u8bb0\u9700\u8981\u8ddf\u8fdb",
|
|
135
162
|
"menu.unflag": "\u53d6\u6d88\u8ddf\u8fdb\u6807\u8bb0",
|
|
136
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}",
|
|
137
182
|
"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",
|
|
138
183
|
"trash.failed": "\u5220\u9664\u5931\u8d25\uff1a",
|
|
139
184
|
};
|
|
@@ -154,6 +199,24 @@
|
|
|
154
199
|
"menu.flag": "Mark as needs follow-up",
|
|
155
200
|
"menu.unflag": "Clear follow-up mark",
|
|
156
201
|
"menu.trash": "Delete session (move to trash)",
|
|
202
|
+
"menu.stats": "Token statistics",
|
|
203
|
+
"stats.title": "Token statistics",
|
|
204
|
+
"stats.loading": "Counting\u2026 (a large session takes a second or two)",
|
|
205
|
+
"stats.failed": "Statistics failed: ",
|
|
206
|
+
"stats.steps": "steps",
|
|
207
|
+
"stats.input": "input (uncached)",
|
|
208
|
+
"stats.cache": "cache read",
|
|
209
|
+
"stats.output": "output",
|
|
210
|
+
"stats.total": "total",
|
|
211
|
+
"stats.agent": "main agent",
|
|
212
|
+
"stats.col.source": "source",
|
|
213
|
+
"stats.col.calls": "calls",
|
|
214
|
+
"stats.col.args": "args",
|
|
215
|
+
"stats.col.results": "results",
|
|
216
|
+
"stats.col.total": "estimate",
|
|
217
|
+
"stats.col.share": "share",
|
|
218
|
+
"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.",
|
|
219
|
+
"stats.agentDetail": "Agent composition: reasoning {reasoning} \u00b7 reply {text} \u00b7 user messages {user} \u00b7 system prompt {system}",
|
|
157
220
|
"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).",
|
|
158
221
|
"trash.failed": "Delete failed: ",
|
|
159
222
|
};
|
|
@@ -364,6 +427,22 @@
|
|
|
364
427
|
return session !== undefined && session !== null && session.blank === true;
|
|
365
428
|
}
|
|
366
429
|
|
|
430
|
+
/** The host's own "show more" button, hidden by CSS but still clickable. */
|
|
431
|
+
function overflowButtonOf(section) {
|
|
432
|
+
if (typeof section.querySelector !== "function") return null;
|
|
433
|
+
return section.querySelector('button[class*="' + OVERFLOW_TOKEN + '"]');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The count the host reports on its own button, 0 when there is none. */
|
|
437
|
+
function labelNumber(button) {
|
|
438
|
+
if (button === null || button === undefined) return 0;
|
|
439
|
+
var text = typeof button.textContent === "string" ? button.textContent : "";
|
|
440
|
+
var match = /\d+/.exec(text);
|
|
441
|
+
if (match === null) return 0;
|
|
442
|
+
var value = Number(match[0]);
|
|
443
|
+
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
367
446
|
/** Collapsing a group resets its fold state, so the click is observed. */
|
|
368
447
|
function markHeader(header, key) {
|
|
369
448
|
if (typeof header.addEventListener !== "function" || headerMarked.has(header)) return;
|
|
@@ -411,6 +490,13 @@
|
|
|
411
490
|
* Fold the rows the host rendered that have not changed for `foldDays`, and
|
|
412
491
|
* offer the group's own control to bring them back. A workspace whose every
|
|
413
492
|
* session is that old therefore renders as a bare header.
|
|
493
|
+
*
|
|
494
|
+
* The host's own "show more" button is hidden (CSS) and driven from here: it
|
|
495
|
+
* only renders five rows at a time and cannot collapse once everything is
|
|
496
|
+
* rendered, which made "show more" look broken next to a fold. Expanding the
|
|
497
|
+
* group now clicks that hidden button once per pass until the host has
|
|
498
|
+
* rendered every session, so one click reveals everything; collapsing just
|
|
499
|
+
* folds the stale rows again.
|
|
414
500
|
*/
|
|
415
501
|
function foldRows(entries, settings, sessions, now) {
|
|
416
502
|
var cutoff = now - settings.foldDays * DAY;
|
|
@@ -418,9 +504,29 @@
|
|
|
418
504
|
var entry = entries[i];
|
|
419
505
|
var section = entry.section;
|
|
420
506
|
var rows = rowElements(section);
|
|
507
|
+
var hostButton = overflowButtonOf(section);
|
|
421
508
|
markHeader(entry.header, entry.key);
|
|
422
509
|
|
|
423
510
|
var expanded = expandedSections.has(entry.key);
|
|
511
|
+
var hostHidden = hostButton === null ? 0 : labelNumber(hostButton);
|
|
512
|
+
var hostExpanded = hostButton !== null
|
|
513
|
+
&& typeof hostButton.getAttribute === "function"
|
|
514
|
+
&& hostButton.getAttribute("aria-expanded") === "true";
|
|
515
|
+
if (expanded && hostButton !== null && !hostExpanded) {
|
|
516
|
+
/* The host renders five more per click (or everything when five or
|
|
517
|
+
fewer remain) and reports `aria-expanded` once nothing is hidden.
|
|
518
|
+
Stopping on that flag is what keeps the loop from collapsing the
|
|
519
|
+
very thing it just expanded - a click on an expanded host button
|
|
520
|
+
resets the watermark. */
|
|
521
|
+
var used = expandClicks.get(entry.key) || 0;
|
|
522
|
+
if (used < MAX_EXPAND_CLICKS) {
|
|
523
|
+
expandClicks.set(entry.key, used + 1);
|
|
524
|
+
hostButton.click();
|
|
525
|
+
}
|
|
526
|
+
} else if (!expanded) {
|
|
527
|
+
expandClicks.delete(entry.key);
|
|
528
|
+
}
|
|
529
|
+
|
|
424
530
|
var folded = 0;
|
|
425
531
|
for (var j = 0; j < rows.length; j += 1) {
|
|
426
532
|
var row = rows[j];
|
|
@@ -439,22 +545,22 @@
|
|
|
439
545
|
}
|
|
440
546
|
}
|
|
441
547
|
|
|
442
|
-
/*
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"show history" control whenever something is folded. */
|
|
548
|
+
/* ONE control per group: its count is everything the group still hides,
|
|
549
|
+
both the rows the host did not render and the stale rows this plugin
|
|
550
|
+
folded. Expanding drives the hidden host button until nothing is left
|
|
551
|
+
unrendered, so one click reveals the whole group. */
|
|
552
|
+
var remaining = folded + hostHidden;
|
|
448
553
|
if (expanded) {
|
|
449
554
|
(function (entryKey) {
|
|
450
555
|
ensureToggle(section, entryKey, t("fold.hide"), function () {
|
|
451
556
|
expandedSections.delete(entryKey);
|
|
557
|
+
expandClicks.delete(entryKey);
|
|
452
558
|
schedule();
|
|
453
559
|
});
|
|
454
560
|
})(entry.key);
|
|
455
|
-
} else if (
|
|
561
|
+
} else if (remaining > 0) {
|
|
456
562
|
(function (entryKey) {
|
|
457
|
-
ensureToggle(section, entryKey, t("fold.show", { n:
|
|
563
|
+
ensureToggle(section, entryKey, t("fold.show", { n: remaining }), function () {
|
|
458
564
|
expandedSections.add(entryKey);
|
|
459
565
|
schedule();
|
|
460
566
|
});
|
|
@@ -797,6 +903,186 @@
|
|
|
797
903
|
}, props.t("menu.trash"));
|
|
798
904
|
}
|
|
799
905
|
|
|
906
|
+
/* ------------------------- token statistics ------------------------- */
|
|
907
|
+
|
|
908
|
+
var statsModal = null;
|
|
909
|
+
|
|
910
|
+
function formatTokens(value) {
|
|
911
|
+
var n = Number(value) || 0;
|
|
912
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
|
913
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
914
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
915
|
+
return String(Math.round(n));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function formatShare(share) {
|
|
919
|
+
if (typeof share !== "number" || !Number.isFinite(share)) return "-";
|
|
920
|
+
return (share * 100).toFixed(1) + "%";
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function closeStatsPanel() {
|
|
924
|
+
if (statsModal !== null) {
|
|
925
|
+
if (typeof statsModal.remove === "function") statsModal.remove();
|
|
926
|
+
statsModal = null;
|
|
927
|
+
}
|
|
928
|
+
if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
|
|
929
|
+
document.removeEventListener("keydown", onStatsKey);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function onStatsKey(event) {
|
|
934
|
+
if (event !== undefined && event.key === "Escape") closeStatsPanel();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function statsDiv(className, text) {
|
|
938
|
+
var node = document.createElement("div");
|
|
939
|
+
node.className = className;
|
|
940
|
+
if (text !== undefined) node.textContent = text;
|
|
941
|
+
return node;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** One table row: source, calls, argument tokens, result tokens, total, share. */
|
|
945
|
+
function statsRow(label, calls, args, results, total, share) {
|
|
946
|
+
var row = document.createElement("div");
|
|
947
|
+
row.className = "wsr-stats-row";
|
|
948
|
+
var name = statsDiv("wsr-td wsr-td-name", label);
|
|
949
|
+
row.appendChild(name);
|
|
950
|
+
row.appendChild(statsDiv("wsr-td", calls === null ? "-" : String(calls)));
|
|
951
|
+
row.appendChild(statsDiv("wsr-td", args === null ? "-" : formatTokens(args)));
|
|
952
|
+
row.appendChild(statsDiv("wsr-td", results === null ? "-" : formatTokens(results)));
|
|
953
|
+
row.appendChild(statsDiv("wsr-td wsr-td-strong", formatTokens(total)));
|
|
954
|
+
row.appendChild(statsDiv("wsr-td", formatShare(share)));
|
|
955
|
+
return row;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function renderStats(body, stats) {
|
|
959
|
+
body.textContent = "";
|
|
960
|
+
while (body.firstChild !== null) body.removeChild(body.firstChild);
|
|
961
|
+
var totals = stats.totals || {};
|
|
962
|
+
var estimate = stats.estimate || {};
|
|
963
|
+
var agent = estimate.agent || {};
|
|
964
|
+
|
|
965
|
+
var grid = document.createElement("div");
|
|
966
|
+
grid.className = "wsr-stats-grid";
|
|
967
|
+
var cards = [
|
|
968
|
+
[t("stats.steps"), formatTokens(stats.steps)],
|
|
969
|
+
[t("stats.input"), formatTokens(totals.inputTokens)],
|
|
970
|
+
[t("stats.cache"), formatTokens(totals.cacheReadTokens)],
|
|
971
|
+
[t("stats.output"), formatTokens(totals.outputTokens)],
|
|
972
|
+
[t("stats.total"), formatTokens(totals.totalTokens)],
|
|
973
|
+
];
|
|
974
|
+
for (var i = 0; i < cards.length; i += 1) {
|
|
975
|
+
var card = statsDiv("wsr-stats-card");
|
|
976
|
+
card.appendChild(statsDiv("wsr-stats-value", cards[i][1]));
|
|
977
|
+
card.appendChild(statsDiv("wsr-stats-label", cards[i][0]));
|
|
978
|
+
grid.appendChild(card);
|
|
979
|
+
}
|
|
980
|
+
body.appendChild(grid);
|
|
981
|
+
|
|
982
|
+
var tools = estimate.tools || [];
|
|
983
|
+
var palette = ["#d97706", "#059669", "#7c3aed", "#db2777", "#0891b2", "#65a30d", "#9333ea", "#dc2626"];
|
|
984
|
+
var segments = [{ name: t("stats.agent"), share: agent.share || 0, color: "#4b8bf5" }];
|
|
985
|
+
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] });
|
|
986
|
+
|
|
987
|
+
var bar = statsDiv("wsr-stats-bar");
|
|
988
|
+
for (var k = 0; k < segments.length; k += 1) {
|
|
989
|
+
if (segments[k].share <= 0) continue;
|
|
990
|
+
var piece = statsDiv("wsr-stats-seg");
|
|
991
|
+
piece.style.width = (segments[k].share * 100).toFixed(2) + "%";
|
|
992
|
+
piece.style.background = segments[k].color;
|
|
993
|
+
piece.setAttribute("title", segments[k].name + " " + formatShare(segments[k].share));
|
|
994
|
+
bar.appendChild(piece);
|
|
995
|
+
}
|
|
996
|
+
body.appendChild(bar);
|
|
997
|
+
|
|
998
|
+
var legend = statsDiv("wsr-stats-legend");
|
|
999
|
+
for (var m = 0; m < segments.length; m += 1) {
|
|
1000
|
+
if (segments[m].share <= 0) continue;
|
|
1001
|
+
var item = statsDiv("wsr-stats-legend-item");
|
|
1002
|
+
var swatch = statsDiv("wsr-stats-swatch");
|
|
1003
|
+
swatch.style.background = segments[m].color;
|
|
1004
|
+
item.appendChild(swatch);
|
|
1005
|
+
item.appendChild(statsDiv("wsr-stats-legend-text", segments[m].name + " " + formatShare(segments[m].share)));
|
|
1006
|
+
legend.appendChild(item);
|
|
1007
|
+
}
|
|
1008
|
+
body.appendChild(legend);
|
|
1009
|
+
|
|
1010
|
+
var table = statsDiv("wsr-stats-table");
|
|
1011
|
+
var head = document.createElement("div");
|
|
1012
|
+
head.className = "wsr-stats-row wsr-stats-head";
|
|
1013
|
+
var columns = ["stats.col.source", "stats.col.calls", "stats.col.args", "stats.col.results", "stats.col.total", "stats.col.share"];
|
|
1014
|
+
for (var n = 0; n < columns.length; n += 1) head.appendChild(statsDiv("wsr-td", t(columns[n])));
|
|
1015
|
+
table.appendChild(head);
|
|
1016
|
+
table.appendChild(statsRow(t("stats.agent"), stats.steps, null, null, agent.total, agent.share));
|
|
1017
|
+
for (var p = 0; p < tools.length; p += 1) {
|
|
1018
|
+
var tool = tools[p];
|
|
1019
|
+
table.appendChild(statsRow(tool.name, tool.calls, tool.args, tool.results, tool.total, tool.share));
|
|
1020
|
+
}
|
|
1021
|
+
body.appendChild(table);
|
|
1022
|
+
|
|
1023
|
+
body.appendChild(statsDiv("wsr-stats-detail", t("stats.agentDetail", {
|
|
1024
|
+
reasoning: formatTokens(agent.reasoning),
|
|
1025
|
+
text: formatTokens(agent.text),
|
|
1026
|
+
user: formatTokens(agent.user),
|
|
1027
|
+
system: formatTokens(agent.system),
|
|
1028
|
+
})));
|
|
1029
|
+
body.appendChild(statsDiv("wsr-stats-note", t("stats.note")));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function openStatsPanel(sessionId) {
|
|
1033
|
+
if (typeof document === "undefined") return;
|
|
1034
|
+
closeStatsPanel();
|
|
1035
|
+
var backdrop = statsDiv("wsr-modal-backdrop");
|
|
1036
|
+
var panel = statsDiv("wsr-modal");
|
|
1037
|
+
backdrop.appendChild(panel);
|
|
1038
|
+
var head = statsDiv("wsr-modal-head");
|
|
1039
|
+
head.appendChild(statsDiv("wsr-modal-title", t("stats.title")));
|
|
1040
|
+
var close = document.createElement("button");
|
|
1041
|
+
close.type = "button";
|
|
1042
|
+
close.className = "wsr-modal-close";
|
|
1043
|
+
close.textContent = "\u00d7";
|
|
1044
|
+
close.addEventListener("click", closeStatsPanel);
|
|
1045
|
+
head.appendChild(close);
|
|
1046
|
+
panel.appendChild(head);
|
|
1047
|
+
var body = statsDiv("wsr-modal-body");
|
|
1048
|
+
body.textContent = t("stats.loading");
|
|
1049
|
+
panel.appendChild(body);
|
|
1050
|
+
backdrop.addEventListener("click", function (event) {
|
|
1051
|
+
if (event !== undefined && event.target === backdrop) closeStatsPanel();
|
|
1052
|
+
});
|
|
1053
|
+
document.body.appendChild(backdrop);
|
|
1054
|
+
statsModal = backdrop;
|
|
1055
|
+
document.addEventListener("keydown", onStatsKey);
|
|
1056
|
+
|
|
1057
|
+
fetch("/ws-recency/session-stats", {
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
headers: { "content-type": "application/json" },
|
|
1060
|
+
body: JSON.stringify({ sessionId: sessionId }),
|
|
1061
|
+
})
|
|
1062
|
+
.then(function (response) { return response.json(); })
|
|
1063
|
+
.then(function (result) {
|
|
1064
|
+
if (result === null || result.ok !== true) {
|
|
1065
|
+
body.textContent = t("stats.failed") + (result && result.error ? result.error : "unknown");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
renderStats(body, result.stats);
|
|
1069
|
+
})
|
|
1070
|
+
.catch(function (error) {
|
|
1071
|
+
body.textContent = t("stats.failed") + (error && error.message ? error.message : error);
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Session "..." menu row: token statistics of that session. */
|
|
1076
|
+
function StatsMenuItem(props) {
|
|
1077
|
+
var Item = ui.MenuItemButton;
|
|
1078
|
+
if (typeof Item !== "function") return null;
|
|
1079
|
+
var Icon = ui.IconDataOutlineRegular;
|
|
1080
|
+
return h(Item, {
|
|
1081
|
+
icon: typeof Icon === "function" ? h(Icon, {}) : undefined,
|
|
1082
|
+
onSelect: function () { openStatsPanel(props.sessionId); },
|
|
1083
|
+
}, props.t("menu.stats"));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
800
1086
|
/** Settings -> General row: how old a session must be before it is folded. */
|
|
801
1087
|
function SettingsRow(props) {
|
|
802
1088
|
var settings = props.useStore(function (state) { return state; });
|
|
@@ -913,6 +1199,7 @@
|
|
|
913
1199
|
pinHidden.clear();
|
|
914
1200
|
if (pinHost !== null && pinHost.parentElement !== null) pinHost.remove();
|
|
915
1201
|
pinHost = null;
|
|
1202
|
+
expandClicks.clear();
|
|
916
1203
|
roots.forEach(function (root) { root.removeAttribute(ROOT_ATTR); });
|
|
917
1204
|
roots.clear();
|
|
918
1205
|
var marked = document.querySelectorAll("[" + STATE_ATTR + "]");
|
|
@@ -1029,6 +1316,16 @@
|
|
|
1029
1316
|
}, FollowUpDot);
|
|
1030
1317
|
});
|
|
1031
1318
|
|
|
1319
|
+
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1320
|
+
return ctx.slots.register({
|
|
1321
|
+
name: "sidebar.workspaces.session.menu.item",
|
|
1322
|
+
id: "ws-recency-stats",
|
|
1323
|
+
order: 710,
|
|
1324
|
+
store: prefs,
|
|
1325
|
+
locale: NS,
|
|
1326
|
+
}, StatsMenuItem);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1032
1329
|
ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
|
|
1033
1330
|
return ctx.slots.register({
|
|
1034
1331
|
name: "sidebar.workspaces.session.menu.item",
|
package/index.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module dsh-ws-recency
|
|
21
21
|
*/
|
|
22
|
-
import { mkdir, readdir, rename } from "node:fs/promises";
|
|
22
|
+
import { access, mkdir, readFile, readdir, rename, stat } from "node:fs/promises";
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
24
25
|
|
|
25
26
|
/** Cordis plugin name shown by the loader. */
|
|
26
27
|
export const name = "ws-recency";
|
|
@@ -208,7 +209,271 @@ export async function handleTrashRequest(ctx, req, res) {
|
|
|
208
209
|
}
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
|
|
212
|
+
/* ------------------------------------------------------------------------- *
|
|
213
|
+
* Token statistics
|
|
214
|
+
*
|
|
215
|
+
* The Client knows nothing about what a finished session cost, and a session may
|
|
216
|
+
* be months old, so this reads the session's own log. Recorded usage is exact
|
|
217
|
+
* (per assistant step: input / cache-read / output); the split between the agent
|
|
218
|
+
* itself and each tool is an estimate over the recorded content, because a
|
|
219
|
+
* prompt's tokens cannot be attributed to a single message - the whole context
|
|
220
|
+
* is billed every step.
|
|
221
|
+
*
|
|
222
|
+
* The log is a sequence of independent Zstandard frames, one per append, and the
|
|
223
|
+
* standard decoder stops after the first one. Splitting on the frame magic and
|
|
224
|
+
* decoding each frame is what makes the whole transcript readable.
|
|
225
|
+
* ------------------------------------------------------------------------- */
|
|
226
|
+
|
|
227
|
+
export const STATS_SESSION_PATH = "/ws-recency/session-stats";
|
|
228
|
+
|
|
229
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
230
|
+
const STATS_CACHE_MS = 10 * 1000;
|
|
231
|
+
const statsCache = new Map();
|
|
232
|
+
|
|
233
|
+
/** bytes/4 per Latin character, ~1 token per CJK character. */
|
|
234
|
+
export function estimateTokens(text) {
|
|
235
|
+
if (typeof text !== "string" || text === "") return 0;
|
|
236
|
+
let cjk = 0;
|
|
237
|
+
let other = 0;
|
|
238
|
+
for (const character of text) {
|
|
239
|
+
const code = character.codePointAt(0);
|
|
240
|
+
if ((code >= 0x2e80 && code <= 0x9fff) || (code >= 0xf900 && code <= 0xfaff) || (code >= 0x3000 && code <= 0x303f) || (code >= 0xff00 && code <= 0xffef)) cjk += 1;
|
|
241
|
+
else other += 1;
|
|
242
|
+
}
|
|
243
|
+
return Math.ceil(cjk + other / 4);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Text of one content block / a whole content array, whatever shape it takes. */
|
|
247
|
+
function blockText(content) {
|
|
248
|
+
if (typeof content === "string") return content;
|
|
249
|
+
if (!Array.isArray(content)) return "";
|
|
250
|
+
let text = "";
|
|
251
|
+
for (const block of content) {
|
|
252
|
+
if (block === null || typeof block !== "object") continue;
|
|
253
|
+
if (typeof block.text === "string") text += block.text;
|
|
254
|
+
else if (typeof block.arguments === "string") text += block.arguments;
|
|
255
|
+
else if (typeof block.content === "string") text += block.content;
|
|
256
|
+
}
|
|
257
|
+
return text;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Aggregate one session's events into costs.
|
|
262
|
+
*
|
|
263
|
+
* Pure on purpose: the tests call it with hand-written events, and the route
|
|
264
|
+
* calls it with the decoded log.
|
|
265
|
+
*/
|
|
266
|
+
export function aggregateSession(events) {
|
|
267
|
+
const summary = {
|
|
268
|
+
session: { id: "", title: "", cwd: "", createdAt: 0, lastAt: 0 },
|
|
269
|
+
steps: 0,
|
|
270
|
+
totals: { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
271
|
+
estimate: { agent: { reasoning: 0, text: 0, user: 0, system: 0, total: 0 }, tools: [], toolsTotal: 0, grandTotal: 0 },
|
|
272
|
+
};
|
|
273
|
+
const tools = new Map();
|
|
274
|
+
const callNames = new Map();
|
|
275
|
+
const toolOf = (name) => {
|
|
276
|
+
const key = typeof name === "string" && name !== "" ? name : "unknown";
|
|
277
|
+
let entry = tools.get(key);
|
|
278
|
+
if (entry === undefined) {
|
|
279
|
+
entry = { name: key, calls: 0, args: 0, results: 0, total: 0, share: 0 };
|
|
280
|
+
tools.set(key, entry);
|
|
281
|
+
}
|
|
282
|
+
return entry;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const event of events) {
|
|
286
|
+
if (event === null || typeof event !== "object") continue;
|
|
287
|
+
const data = event.data ?? {};
|
|
288
|
+
if (typeof event.time === "number") {
|
|
289
|
+
if (summary.session.createdAt === 0 || event.time < summary.session.createdAt) summary.session.createdAt = event.time;
|
|
290
|
+
if (event.time > summary.session.lastAt) summary.session.lastAt = event.time;
|
|
291
|
+
}
|
|
292
|
+
switch (event.type) {
|
|
293
|
+
case "session": {
|
|
294
|
+
if (typeof event.id === "string") summary.session.id = event.id;
|
|
295
|
+
if (typeof event.cwd === "string") summary.session.cwd = event.cwd;
|
|
296
|
+
if (typeof event.createdAt === "number") summary.session.createdAt = event.createdAt;
|
|
297
|
+
if (typeof data.createdAt === "number") summary.session.createdAt = data.createdAt;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
case "session/title": {
|
|
301
|
+
const title = typeof data === "string" ? data : data.title;
|
|
302
|
+
if (typeof title === "string" && title !== "") summary.session.title = title;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case "assistant/message": {
|
|
306
|
+
const usage = data.usage;
|
|
307
|
+
if (usage !== null && typeof usage === "object") {
|
|
308
|
+
summary.steps += 1;
|
|
309
|
+
summary.totals.inputTokens += Number(usage.inputTokens) || 0;
|
|
310
|
+
summary.totals.cacheReadTokens += Number(usage.cacheReadTokens) || 0;
|
|
311
|
+
summary.totals.outputTokens += Number(usage.outputTokens) || 0;
|
|
312
|
+
summary.totals.totalTokens += Number(usage.totalTokens) || 0;
|
|
313
|
+
}
|
|
314
|
+
const content = data.message !== null && data.message !== undefined ? data.message.content : undefined;
|
|
315
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
316
|
+
if (block === null || typeof block !== "object") continue;
|
|
317
|
+
if (block.type === "reasoning") summary.estimate.agent.reasoning += estimateTokens(block.text);
|
|
318
|
+
else if (block.type === "text") summary.estimate.agent.text += estimateTokens(block.text);
|
|
319
|
+
else if (block.type === "tool-call" && block.id !== undefined) callNames.set(String(block.id), block.name);
|
|
320
|
+
}
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
case "tool/call": {
|
|
324
|
+
const name = data.name;
|
|
325
|
+
if (data.callId !== undefined) callNames.set(String(data.callId), name);
|
|
326
|
+
const entry = toolOf(name);
|
|
327
|
+
entry.calls += 1;
|
|
328
|
+
entry.args += estimateTokens(data.arguments);
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
case "tool/result": {
|
|
332
|
+
const name = callNames.get(String(data.toolCallId ?? data.message?.source?.callId ?? ""));
|
|
333
|
+
const entry = toolOf(name);
|
|
334
|
+
entry.results += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "user/message": {
|
|
338
|
+
summary.estimate.agent.user += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
case "system/message": {
|
|
342
|
+
summary.estimate.agent.system += estimateTokens(blockText(data.message !== null && data.message !== undefined ? data.message.content : data.content));
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
default:
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const agent = summary.estimate.agent;
|
|
351
|
+
agent.total = agent.reasoning + agent.text + agent.user + agent.system;
|
|
352
|
+
const list = [...tools.values()].sort((a, b) => (b.args + b.results) - (a.args + a.results));
|
|
353
|
+
for (const entry of list) entry.total = entry.args + entry.results;
|
|
354
|
+
summary.estimate.tools = list;
|
|
355
|
+
summary.estimate.toolsTotal = list.reduce((sum, entry) => sum + entry.total, 0);
|
|
356
|
+
summary.estimate.grandTotal = agent.total + summary.estimate.toolsTotal;
|
|
357
|
+
const grand = summary.estimate.grandTotal;
|
|
358
|
+
if (grand > 0) {
|
|
359
|
+
for (const entry of list) entry.share = entry.total / grand;
|
|
360
|
+
summary.estimate.agent.share = agent.total / grand;
|
|
361
|
+
}
|
|
362
|
+
return summary;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Decode every Zstandard frame of a session log and parse its JSONL records. */
|
|
366
|
+
export async function readSessionEvents(path) {
|
|
367
|
+
const bytes = await readFile(path);
|
|
368
|
+
const offsets = [];
|
|
369
|
+
let at = bytes.indexOf(ZSTD_MAGIC, 0);
|
|
370
|
+
while (at !== -1) {
|
|
371
|
+
offsets.push(at);
|
|
372
|
+
at = bytes.indexOf(ZSTD_MAGIC, at + 4);
|
|
373
|
+
}
|
|
374
|
+
const parts = [];
|
|
375
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
376
|
+
const end = index + 1 < offsets.length ? offsets[index + 1] : bytes.length;
|
|
377
|
+
try {
|
|
378
|
+
parts.push(zstdDecompressSync(bytes.subarray(offsets[index], end)));
|
|
379
|
+
} catch {
|
|
380
|
+
/* a torn tail: the rest of the log is still usable */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const events = [];
|
|
384
|
+
for (const line of Buffer.concat(parts).toString("utf8").split("\n")) {
|
|
385
|
+
if (line === "") continue;
|
|
386
|
+
try {
|
|
387
|
+
events.push(JSON.parse(line));
|
|
388
|
+
} catch {
|
|
389
|
+
/* a torn final record */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return events;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Absolute path of a session log, or undefined when it is gone. */
|
|
396
|
+
async function sessionLogPath(ctx, sessionId) {
|
|
397
|
+
try {
|
|
398
|
+
const stat = await ctx.sessionPersistence.stat(sessionId);
|
|
399
|
+
const meta = stat !== undefined && stat !== null ? stat.header ?? stat : undefined;
|
|
400
|
+
if (meta !== undefined && typeof ctx.sessionPersistence.locate === "function") {
|
|
401
|
+
const located = ctx.sessionPersistence.locate(meta);
|
|
402
|
+
if (located !== undefined && located !== null && typeof located.path === "string") return located.path;
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
/* fall back to the scan */
|
|
406
|
+
}
|
|
407
|
+
const home = await dshHome();
|
|
408
|
+
const root = join(home, "sessions");
|
|
409
|
+
let projects;
|
|
410
|
+
try {
|
|
411
|
+
projects = await readdir(root, { withFileTypes: true });
|
|
412
|
+
} catch {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
for (const project of projects) {
|
|
416
|
+
if (!project.isDirectory()) continue;
|
|
417
|
+
const candidate = join(root, project.name, sessionId, "session.v4.jsonl.zstd");
|
|
418
|
+
try {
|
|
419
|
+
await access(candidate);
|
|
420
|
+
return candidate;
|
|
421
|
+
} catch {
|
|
422
|
+
/* keep scanning */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Statistics for one session, cached briefly so re-opening the panel is cheap. */
|
|
429
|
+
export async function sessionStatistics(ctx, sessionId) {
|
|
430
|
+
const path = await sessionLogPath(ctx, sessionId);
|
|
431
|
+
if (path === undefined) return { ok: false, error: "session log not found" };
|
|
432
|
+
let info;
|
|
433
|
+
try {
|
|
434
|
+
info = await stat(path);
|
|
435
|
+
} catch {
|
|
436
|
+
return { ok: false, error: "session log not found" };
|
|
437
|
+
}
|
|
438
|
+
const cacheKey = `${info.size}:${info.mtimeMs}`;
|
|
439
|
+
const cached = statsCache.get(sessionId);
|
|
440
|
+
if (cached !== undefined && cached.cacheKey === cacheKey && Date.now() - cached.at < STATS_CACHE_MS) return { ok: true, stats: cached.stats };
|
|
441
|
+
try {
|
|
442
|
+
const events = await readSessionEvents(path);
|
|
443
|
+
const stats = aggregateSession(events);
|
|
444
|
+
if (stats.session.id === "") stats.session.id = sessionId;
|
|
445
|
+
statsCache.set(sessionId, { cacheKey, stats, at: Date.now() });
|
|
446
|
+
return { ok: true, stats };
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** POST { sessionId } -> token statistics for that session. */
|
|
453
|
+
export async function handleStatsRequest(ctx, req, res) {
|
|
454
|
+
if ((req.method ?? "POST").toUpperCase() !== "POST") {
|
|
455
|
+
sendJson(res, 405, { ok: false, error: "method not allowed" });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (!loopbackOrigin(req)) {
|
|
459
|
+
sendJson(res, 403, { ok: false, error: "origin not trusted" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const body = await readJsonBody(req);
|
|
464
|
+
const sessionId = body !== null && typeof body.sessionId === "string" ? body.sessionId.trim() : "";
|
|
465
|
+
if (sessionId === "") {
|
|
466
|
+
sendJson(res, 400, { ok: false, error: "sessionId required" });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const result = await sessionStatistics(ctx, sessionId);
|
|
470
|
+
sendJson(res, result.ok ? 200 : 404, result);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Cordis entry point: mount the routes when the host has a web server. */
|
|
212
477
|
export function apply(ctx) {
|
|
213
478
|
try {
|
|
214
479
|
if (typeof ctx.inject === "function") {
|
|
@@ -218,9 +483,14 @@ export function apply(ctx) {
|
|
|
218
483
|
path: TRASH_SESSION_PATH,
|
|
219
484
|
handler: (req, res) => handleTrashRequest(ctx, req, res),
|
|
220
485
|
}), "ws-recency: trash route");
|
|
486
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
487
|
+
kind: "exact",
|
|
488
|
+
path: STATS_SESSION_PATH,
|
|
489
|
+
handler: (req, res) => handleStatsRequest(ctx, req, res),
|
|
490
|
+
}), "ws-recency: stats route");
|
|
221
491
|
});
|
|
222
492
|
}
|
|
223
493
|
} catch {
|
|
224
|
-
/* a headless profile simply gets no
|
|
494
|
+
/* a headless profile simply gets no routes */
|
|
225
495
|
}
|
|
226
496
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ws-recency",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Sidebar session management for DeepSeek Harness: workspace groups sorted by latest activity, stale history folded, a global pinned section, follow-up marks,
|
|
3
|
+
"version": "0.10.0",
|
|
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": {
|