dsh-ws-recency 0.10.0 → 0.10.2
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 +4 -2
- package/client.js +117 -20
- package/index.js +101 -83
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
|
|
|
65
65
|
|
|
66
66
|
## 六、Token 消耗统计
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
会话下方统计条上的「用量分析」,或会话「…」菜单 →「Token 消耗统计」:
|
|
69
69
|
|
|
70
70
|
- **真实账单**(每步记录的 usage):步数、输入(未缓存)、缓存命中、输出、合计;
|
|
71
71
|
- **结构占比**(按内容估算):主 agent 自己(思考 / 回复 / 用户消息 / 系统提示)vs 每个工具,一条占比条 + 图例;
|
|
@@ -73,6 +73,8 @@ DSH 原生的置顶只把会话顶到**它所属工作区分组的第一行**,
|
|
|
73
73
|
|
|
74
74
|
口径说明:prompt 的 token 是「整段上下文每一步都计费」,没法精确归属到某一条消息,所以「主 agent / 工具」的拆分是按内容长度估算(中文≈1 token/字,英文≈4 字符/token),用来比较占比;**总量是真的**。数据从会话日志算,跨重启也能看。大会话第一次打开约 1–2 秒(29MB 日志实测 1.2 秒),10 秒内重复打开走缓存。
|
|
75
75
|
|
|
76
|
+
宿主半(`index.js`)把统计与删除的路由挂在 webServer 上;如果面板报“宿主半未加载”,说明应用还没重启加载新版本。0.10.1 起面板失败时会带上 HTTP 状态和 `/ws-recency/health` 的自检结果,便于定位。
|
|
77
|
+
|
|
76
78
|
## 设置
|
|
77
79
|
|
|
78
80
|
**设置 → 通用**:
|
|
@@ -108,7 +110,7 @@ dsh plugin --profile <profile> add github:chyxhtc/dsh-ws-recency
|
|
|
108
110
|
或固定版本的预构建包:
|
|
109
111
|
|
|
110
112
|
```powershell
|
|
111
|
-
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.10.
|
|
113
|
+
dsh plugin --profile <profile> add https://github.com/chyxhtc/dsh-ws-recency/releases/download/v0.10.2/dsh-ws-recency-0.10.2.tgz
|
|
112
114
|
```
|
|
113
115
|
|
|
114
116
|
装完把 `dsh-ws-recency` 加进 `~/.hypers-work/profiles/<profile>/package.json` 的 `dsh.profile.bundles`,重启应用。
|
package/client.js
CHANGED
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
var pinHidden = new Set();
|
|
86
86
|
var pinHost = null;
|
|
87
87
|
var expandClicks = new Map();
|
|
88
|
+
var composerPill = null;
|
|
88
89
|
var expandedSections = new Set();
|
|
89
90
|
var headerMarked = new WeakSet();
|
|
90
91
|
var disposed = false;
|
|
@@ -104,6 +105,8 @@
|
|
|
104
105
|
".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
106
|
".wsr-pin-row:hover .wsr-pin-unpin{visibility:visible}",
|
|
106
107
|
".wsr-pin-unpin:hover{color:var(--dsw-alias-label-primary)}",
|
|
108
|
+
".wsr-pill{box-sizing:border-box;border:none;background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;font-variant-numeric:tabular-nums;line-height:inherit;white-space:nowrap;border-radius:var(--dsw-radius-sm,6px);display:inline-flex;align-items:center;gap:6px;padding:1px 8px;cursor:pointer}",
|
|
109
|
+
".wsr-pill:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(255,255,255,.08));color:var(--dsw-alias-label-secondary,#cfd3d8)}",
|
|
107
110
|
".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
111
|
".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
112
|
".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))}",
|
|
@@ -179,6 +182,9 @@
|
|
|
179
182
|
"stats.col.share": "\u5360\u6bd4",
|
|
180
183
|
"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
184
|
"stats.agentDetail": "主 agent 构成:思考 {reasoning} · 回复 {text} · 用户消息 {user} · 系统提示 {system}",
|
|
185
|
+
"stats.diagnose.unmounted": "\n\n(宿主半已加载,但统计路由没挂上——重启应用再试)",
|
|
186
|
+
"stats.diagnose.noHost": "\n\n(宿主半未加载:需要重启应用加载新版本)",
|
|
187
|
+
"composer.analysis": "用量分析",
|
|
182
188
|
"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",
|
|
183
189
|
"trash.failed": "\u5220\u9664\u5931\u8d25\uff1a",
|
|
184
190
|
};
|
|
@@ -217,6 +223,9 @@
|
|
|
217
223
|
"stats.col.share": "share",
|
|
218
224
|
"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
225
|
"stats.agentDetail": "Agent composition: reasoning {reasoning} \u00b7 reply {text} \u00b7 user messages {user} \u00b7 system prompt {system}",
|
|
226
|
+
"stats.diagnose.unmounted": "\n\n(the host half is loaded but the statistics route did not mount - restart the app and retry)",
|
|
227
|
+
"stats.diagnose.noHost": "\n\n(the host half is not loaded: restart the app to pick up the new version)",
|
|
228
|
+
"composer.analysis": "Usage analysis",
|
|
220
229
|
"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).",
|
|
221
230
|
"trash.failed": "Delete failed: ",
|
|
222
231
|
};
|
|
@@ -781,20 +790,72 @@
|
|
|
781
790
|
}
|
|
782
791
|
}
|
|
783
792
|
|
|
793
|
+
/** The session the conversation view is showing, from its own DOM hook. */
|
|
794
|
+
function currentConversationId() {
|
|
795
|
+
if (typeof document === "undefined") return null;
|
|
796
|
+
var element = document.querySelector("[data-conversation-session]");
|
|
797
|
+
if (element === null || typeof element.getAttribute !== "function") return null;
|
|
798
|
+
var id = element.getAttribute("data-conversation-session");
|
|
799
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Add one pill to the composer's statistics row ("turns / steps / tokens")
|
|
804
|
+
* that opens the same analysis panel as the session menu.
|
|
805
|
+
*
|
|
806
|
+
* The row is host-rendered and re-renders often, so the pill is re-attached
|
|
807
|
+
* on every pass and dropped as soon as the row or the session goes away.
|
|
808
|
+
*/
|
|
809
|
+
function decorateComposerStats() {
|
|
810
|
+
if (typeof document === "undefined") return;
|
|
811
|
+
var existing = document.querySelector("[data-wsr-composer-analysis]");
|
|
812
|
+
var seat = document.querySelector("[data-composer-seat]");
|
|
813
|
+
var anchors = seat === null || typeof seat.querySelectorAll !== "function" ? [] : seat.querySelectorAll("[data-composer-stat]");
|
|
814
|
+
if (anchors.length === 0 || currentConversationId() === null) {
|
|
815
|
+
if (existing !== null) existing.remove();
|
|
816
|
+
composerPill = null;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
var row = anchors[0].parentElement;
|
|
820
|
+
if (row === null) return;
|
|
821
|
+
if (existing === null) {
|
|
822
|
+
var button = document.createElement("button");
|
|
823
|
+
button.type = "button";
|
|
824
|
+
button.className = "wsr-pill";
|
|
825
|
+
button.setAttribute("data-wsr-composer-analysis", "true");
|
|
826
|
+
button.textContent = t("composer.analysis");
|
|
827
|
+
button.addEventListener("click", function () {
|
|
828
|
+
var id = currentConversationId();
|
|
829
|
+
if (id !== null) openStatsPanel(id);
|
|
830
|
+
});
|
|
831
|
+
composerPill = button;
|
|
832
|
+
} else {
|
|
833
|
+
composerPill = existing;
|
|
834
|
+
}
|
|
835
|
+
if (composerPill.parentElement !== row) row.appendChild(composerPill);
|
|
836
|
+
}
|
|
837
|
+
|
|
784
838
|
function apply() {
|
|
785
839
|
if (disposed) return;
|
|
786
840
|
var sessions = data.sessions;
|
|
787
841
|
var workspaces = data.workspaces;
|
|
788
|
-
if (!ready(sessions) || !ready(workspaces)) return;
|
|
789
|
-
|
|
790
842
|
var settings = settingsOf();
|
|
843
|
+
if (!ready(sessions) || !ready(workspaces)) {
|
|
844
|
+
decorateComposerStats();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
791
848
|
var entries = collect();
|
|
792
|
-
if (entries.length === 0)
|
|
849
|
+
if (entries.length === 0) {
|
|
850
|
+
decorateComposerStats();
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
793
853
|
|
|
794
854
|
orderGroups(entries, latestByWorkspace(sessions, workspaces));
|
|
795
855
|
foldRows(entries, settings, sessions, Date.now());
|
|
796
856
|
renderPinned(entries, sessions, workspaces, settings);
|
|
797
857
|
if (settings.colors) decorateRows(entries, sessions, data.statuses, settings);
|
|
858
|
+
decorateComposerStats();
|
|
798
859
|
}
|
|
799
860
|
|
|
800
861
|
function schedule() {
|
|
@@ -885,16 +946,22 @@
|
|
|
885
946
|
var question = t("trash.confirm");
|
|
886
947
|
var confirmed = typeof window.confirm === "function" ? window.confirm(question) : false;
|
|
887
948
|
if (!confirmed) return;
|
|
888
|
-
fetch("/ws-recency/session-trash", {
|
|
949
|
+
fetch("/ws-recency/session-trash?sessionId=" + encodeURIComponent(props.sessionId), {
|
|
889
950
|
method: "POST",
|
|
890
951
|
headers: { "content-type": "application/json" },
|
|
891
952
|
body: JSON.stringify({ sessionId: props.sessionId }),
|
|
892
953
|
})
|
|
893
|
-
.then(function (response) {
|
|
954
|
+
.then(function (response) {
|
|
955
|
+
return response.text().then(function (text) {
|
|
956
|
+
var parsed = null;
|
|
957
|
+
try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
|
|
958
|
+
return { status: response.status, text: text, json: parsed };
|
|
959
|
+
});
|
|
960
|
+
})
|
|
894
961
|
.then(function (result) {
|
|
895
|
-
if (result !== null && result.ok
|
|
896
|
-
|
|
897
|
-
|
|
962
|
+
if (result.json !== null && result.json.ok === true) return;
|
|
963
|
+
var reason = (result.json !== null && result.json.error) || (result.text || "").trim().slice(0, 120) || "(empty response)";
|
|
964
|
+
if (typeof window.alert === "function") window.alert(t("trash.failed") + "HTTP " + result.status + " \u00b7 " + reason);
|
|
898
965
|
})
|
|
899
966
|
.catch(function (error) {
|
|
900
967
|
if (typeof window.alert === "function") window.alert(t("trash.failed") + (error && error.message ? error.message : error));
|
|
@@ -1054,22 +1121,50 @@
|
|
|
1054
1121
|
statsModal = backdrop;
|
|
1055
1122
|
document.addEventListener("keydown", onStatsKey);
|
|
1056
1123
|
|
|
1057
|
-
|
|
1124
|
+
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
1125
|
+
var timer = controller === null ? null : window.setTimeout(function () { controller.abort(); }, 180000);
|
|
1126
|
+
var request = fetch("/ws-recency/session-stats?sessionId=" + encodeURIComponent(sessionId), {
|
|
1058
1127
|
method: "POST",
|
|
1059
1128
|
headers: { "content-type": "application/json" },
|
|
1060
1129
|
body: JSON.stringify({ sessionId: sessionId }),
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
}
|
|
1068
|
-
renderStats(body, result.stats);
|
|
1069
|
-
})
|
|
1070
|
-
.catch(function (error) {
|
|
1071
|
-
body.textContent = t("stats.failed") + (error && error.message ? error.message : error);
|
|
1130
|
+
signal: controller === null ? undefined : controller.signal,
|
|
1131
|
+
}).then(function (response) {
|
|
1132
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
1133
|
+
return response.text().then(function (text) {
|
|
1134
|
+
var parsed = null;
|
|
1135
|
+
try { parsed = JSON.parse(text); } catch (error) { parsed = null; }
|
|
1136
|
+
return { status: response.status, text: text, json: parsed };
|
|
1072
1137
|
});
|
|
1138
|
+
}, function (error) {
|
|
1139
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
1140
|
+
throw error;
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
request.then(function (result) {
|
|
1144
|
+
if (result.status === 200 && result.json !== null && result.json.ok === true) {
|
|
1145
|
+
renderStats(body, result.json.stats);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
var reason = (result.json !== null && result.json.error)
|
|
1149
|
+
|| (result.text || "").trim().slice(0, 120)
|
|
1150
|
+
|| "(empty response)";
|
|
1151
|
+
body.textContent = t("stats.failed") + "HTTP " + result.status + " \u00b7 " + reason;
|
|
1152
|
+
/* A 405 here is the SPA fallback answering an unknown POST: the host
|
|
1153
|
+
half never mounted. Ask the health route which part is missing. */
|
|
1154
|
+
return fetch("/ws-recency/health").then(function (response) {
|
|
1155
|
+
return response.text();
|
|
1156
|
+
}).then(function (text) {
|
|
1157
|
+
var health = null;
|
|
1158
|
+
try { health = JSON.parse(text); } catch (error) { health = null; }
|
|
1159
|
+
body.textContent += health !== null && health.ok === true
|
|
1160
|
+
? t("stats.diagnose.unmounted")
|
|
1161
|
+
: t("stats.diagnose.noHost");
|
|
1162
|
+
}).catch(function () {
|
|
1163
|
+
body.textContent += t("stats.diagnose.noHost");
|
|
1164
|
+
});
|
|
1165
|
+
}).catch(function (error) {
|
|
1166
|
+
body.textContent = t("stats.failed") + (error && error.message ? error.message : error);
|
|
1167
|
+
});
|
|
1073
1168
|
}
|
|
1074
1169
|
|
|
1075
1170
|
/** Session "..." menu row: token statistics of that session. */
|
|
@@ -1198,6 +1293,8 @@
|
|
|
1198
1293
|
});
|
|
1199
1294
|
pinHidden.clear();
|
|
1200
1295
|
if (pinHost !== null && pinHost.parentElement !== null) pinHost.remove();
|
|
1296
|
+
if (composerPill !== null && typeof composerPill.remove === "function") composerPill.remove();
|
|
1297
|
+
composerPill = null;
|
|
1201
1298
|
pinHost = null;
|
|
1202
1299
|
expandClicks.clear();
|
|
1203
1300
|
roots.forEach(function (root) { root.removeAttribute(ROOT_ATTR); });
|
package/index.js
CHANGED
|
@@ -19,24 +19,41 @@
|
|
|
19
19
|
*
|
|
20
20
|
* @module dsh-ws-recency
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { mkdir, readdir, rename } from "node:fs/promises";
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
|
-
import { zstdDecompressSync } from "node:zlib";
|
|
25
24
|
|
|
26
25
|
/** Cordis plugin name shown by the loader. */
|
|
27
26
|
export const name = "ws-recency";
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
export const
|
|
28
|
+
/** Version marker reported by the health route. */
|
|
29
|
+
export const HOST_VERSION = "0.10.1";
|
|
31
30
|
|
|
32
|
-
/**
|
|
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
|
+
*/
|
|
33
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";
|
|
34
38
|
|
|
35
39
|
const STOP_SETTLE_MS = 500;
|
|
36
40
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
37
41
|
const RENAME_RETRY = 4;
|
|
38
42
|
const RENAME_GAP_MS = 150;
|
|
39
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. */
|
|
40
57
|
function loopbackOrigin(req) {
|
|
41
58
|
const origin = req.headers.origin;
|
|
42
59
|
if (origin === undefined) return true;
|
|
@@ -197,7 +214,7 @@ export async function handleTrashRequest(ctx, req, res) {
|
|
|
197
214
|
}
|
|
198
215
|
try {
|
|
199
216
|
const body = await readJsonBody(req);
|
|
200
|
-
const sessionId =
|
|
217
|
+
const sessionId = sessionIdFrom(req, body);
|
|
201
218
|
if (sessionId === "") {
|
|
202
219
|
sendJson(res, 400, { ok: false, error: "sessionId required" });
|
|
203
220
|
return;
|
|
@@ -224,9 +241,6 @@ export async function handleTrashRequest(ctx, req, res) {
|
|
|
224
241
|
* decoding each frame is what makes the whole transcript readable.
|
|
225
242
|
* ------------------------------------------------------------------------- */
|
|
226
243
|
|
|
227
|
-
export const STATS_SESSION_PATH = "/ws-recency/session-stats";
|
|
228
|
-
|
|
229
|
-
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
230
244
|
const STATS_CACHE_MS = 10 * 1000;
|
|
231
245
|
const statsCache = new Map();
|
|
232
246
|
|
|
@@ -362,86 +376,61 @@ export function aggregateSession(events) {
|
|
|
362
376
|
return summary;
|
|
363
377
|
}
|
|
364
378
|
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
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) {
|
|
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 = [];
|
|
397
390
|
try {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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;
|
|
403
402
|
}
|
|
404
|
-
}
|
|
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");
|
|
403
|
+
} finally {
|
|
418
404
|
try {
|
|
419
|
-
await
|
|
420
|
-
return candidate;
|
|
405
|
+
if (typeof handle.close === "function") await handle.close();
|
|
421
406
|
} catch {
|
|
422
|
-
/*
|
|
407
|
+
/* closing is best effort */
|
|
423
408
|
}
|
|
424
409
|
}
|
|
425
|
-
return
|
|
410
|
+
return all;
|
|
426
411
|
}
|
|
427
412
|
|
|
428
413
|
/** Statistics for one session, cached briefly so re-opening the panel is cheap. */
|
|
429
414
|
export async function sessionStatistics(ctx, sessionId) {
|
|
430
|
-
|
|
431
|
-
if (path === undefined) return { ok: false, error: "session log not found" };
|
|
432
|
-
let info;
|
|
415
|
+
let stat;
|
|
433
416
|
try {
|
|
434
|
-
|
|
417
|
+
stat = await ctx.sessionPersistence.stat(sessionId);
|
|
435
418
|
} catch {
|
|
436
|
-
return { ok: false, error: "session
|
|
419
|
+
return { ok: false, error: "session not found" };
|
|
437
420
|
}
|
|
438
|
-
|
|
421
|
+
if (stat === undefined || stat === null) return { ok: false, error: "session not found" };
|
|
422
|
+
const cacheKey = `${stat.sizeBytes ?? 0}:${stat.revision ?? ""}`;
|
|
439
423
|
const cached = statsCache.get(sessionId);
|
|
440
424
|
if (cached !== undefined && cached.cacheKey === cacheKey && Date.now() - cached.at < STATS_CACHE_MS) return { ok: true, stats: cached.stats };
|
|
441
425
|
try {
|
|
442
|
-
const events = await readSessionEvents(
|
|
426
|
+
const events = await readSessionEvents(ctx, sessionId);
|
|
443
427
|
const stats = aggregateSession(events);
|
|
444
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
|
+
}
|
|
445
434
|
statsCache.set(sessionId, { cacheKey, stats, at: Date.now() });
|
|
446
435
|
return { ok: true, stats };
|
|
447
436
|
} catch (error) {
|
|
@@ -461,7 +450,7 @@ export async function handleStatsRequest(ctx, req, res) {
|
|
|
461
450
|
}
|
|
462
451
|
try {
|
|
463
452
|
const body = await readJsonBody(req);
|
|
464
|
-
const sessionId =
|
|
453
|
+
const sessionId = sessionIdFrom(req, body);
|
|
465
454
|
if (sessionId === "") {
|
|
466
455
|
sendJson(res, 400, { ok: false, error: "sessionId required" });
|
|
467
456
|
return;
|
|
@@ -473,24 +462,53 @@ export async function handleStatsRequest(ctx, req, res) {
|
|
|
473
462
|
}
|
|
474
463
|
}
|
|
475
464
|
|
|
476
|
-
/**
|
|
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
|
+
*/
|
|
477
474
|
export function apply(ctx) {
|
|
478
475
|
try {
|
|
479
|
-
if (typeof ctx.inject
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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({
|
|
487
493
|
kind: "exact",
|
|
488
494
|
path: STATS_SESSION_PATH,
|
|
489
|
-
handler: (req, res) => handleStatsRequest(
|
|
495
|
+
handler: (req, res) => handleStatsRequest(statsCtx, req, res),
|
|
490
496
|
}), "ws-recency: stats route");
|
|
491
497
|
});
|
|
492
|
-
|
|
498
|
+
|
|
499
|
+
webCtx.inject(["sessionPersistence", "workspaceRegistry", "sessions"], (trashCtx) => {
|
|
500
|
+
trashMounted = true;
|
|
501
|
+
webCtx.effect(() => server.register({
|
|
502
|
+
kind: "exact",
|
|
503
|
+
path: TRASH_SESSION_PATH,
|
|
504
|
+
handler: (req, res) => handleTrashRequest(trashCtx, req, res),
|
|
505
|
+
}), "ws-recency: trash route");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
493
508
|
} catch {
|
|
494
509
|
/* a headless profile simply gets no routes */
|
|
495
510
|
}
|
|
496
511
|
}
|
|
512
|
+
|
|
513
|
+
let statsMounted = false;
|
|
514
|
+
let trashMounted = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ws-recency",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
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",
|