dsh-session-path 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +177 -2
- package/cordis.patch.yml +15 -0
- package/lib/client.js +324 -0
- package/lib/config.js +88 -0
- package/lib/copy-text.js +28 -0
- package/lib/handoff.js +219 -0
- package/lib/index.js +63 -0
- package/lib/paths.js +154 -0
- package/lib/prompt.js +26 -0
- package/lib/route.js +88 -0
- package/lib/session-info.js +124 -0
- package/lib/tools.js +162 -0
- package/lib/trust.js +81 -0
- package/lib/zstd.js +201 -0
- package/package.json +72 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
- Sidebar session rows: 「复制会话路径」 in the row's `⋯` / right-click menu
|
|
6
|
+
(`sidebar.workspaces.session.menu.item`, order 500), so any session's path can
|
|
7
|
+
be copied without opening it; a transient toast confirms the write.
|
|
8
|
+
- Menu rows reuse the host's `MenuItemButton` when available and fall back to a
|
|
9
|
+
plain `role="menuitem"` button.
|
|
10
|
+
- Self-test: added a client-bundle structure check; container checks now skip
|
|
11
|
+
gracefully on runtimes without Zstandard (CI-friendly), and the real-log pass
|
|
12
|
+
is skipped when the machine has no session logs.
|
|
13
|
+
|
|
14
|
+
## 0.1.0
|
|
15
|
+
|
|
16
|
+
- First release.
|
|
17
|
+
- Session header action (⧉) copies a paste-ready handoff block: title, session
|
|
18
|
+
id, workspace directory and the absolute log path.
|
|
19
|
+
- `session_path` tool: read-only path/info lookup for any session.
|
|
20
|
+
- `session_handoff` tool: renders a session log into a readable Markdown
|
|
21
|
+
transcript (or plain JSONL) and returns its path, keeping the newest part
|
|
22
|
+
within the configured character budget.
|
|
23
|
+
- Reads DSH's multi-frame Zstandard session logs frame by frame.
|
|
24
|
+
- Read-only HTTP route `/api/dsh-session-path/resolve` behind a loopback /
|
|
25
|
+
trusted-host fence.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-session-path contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,178 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-session-path
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-session-path)
|
|
4
|
+
[](https://github.com/439436269-ctrl/dsh-session-path/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
给 DSH 的**会话**加一条「路径」:复制当前会话的日志路径(或一段可直接粘贴的接续指令),
|
|
8
|
+
让另一个会话能读到这段对话并接着往下干。
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
会话 header 里的 ⧉ 按钮 ──► 剪贴板里的接续块(标题 / 会话 ID / 工作目录 / 日志路径)
|
|
12
|
+
│
|
|
13
|
+
另一个会话 ──► session_handoff({ sessionId }) ──► 解压后的可读文件 ──► read 它,继续
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 它解决什么问题
|
|
17
|
+
|
|
18
|
+
DSH 每个会话的原始记录在 `$DSH_HOME/sessions/<工作区>/<会话目录>/session[.vN].jsonl[.zstd]`。
|
|
19
|
+
这个文件有两个麻烦:
|
|
20
|
+
|
|
21
|
+
1. 路径不好拿 —— GUI 里没有「复制会话路径」的入口;
|
|
22
|
+
2. 拿到了也不好读 —— `.zstd` 是**多帧** Zstandard 容器(每次追加一个独立 frame),
|
|
23
|
+
`zcat` 之类只解第一帧,直接 `read` 会失败。
|
|
24
|
+
|
|
25
|
+
这个插件把两件事都补上:一个按钮复制路径/接续块,一个工具把日志转成可读文件。
|
|
26
|
+
|
|
27
|
+
## 装法
|
|
28
|
+
|
|
29
|
+
从 npm 装进某个 profile:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile desktop add dsh-session-path
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
本地开发时也可以直接装插件目录(见下面的「开发循环」,注意 `link:` 装法对本插件不可用):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
dsh plugin --profile desktop add ./dsh-session-path
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
或者手工两步(可热载入、不必重启 DSH):
|
|
42
|
+
|
|
43
|
+
1. 在 profile 的 `package.json` 里加依赖
|
|
44
|
+
`"dsh-session-path": "file:<tgz 或目录>"`,然后在 profile 目录跑一次 `pnpm install`;
|
|
45
|
+
2. 在 `~/.dsh/profiles/<profile>/cordis.patch.yml` 里加一行挂载:
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
- insert:
|
|
49
|
+
- id: dsh-session-path
|
|
50
|
+
name: dsh-session-path
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
> 注意:`insert` 不去重。走用户补丁层就别再往 `dsh.profile.bundles` 里加同一个包,
|
|
54
|
+
> 否则同一 id 插两行、槽位重复注册。
|
|
55
|
+
|
|
56
|
+
## 用法
|
|
57
|
+
|
|
58
|
+
### 1. GUI:会话 header 上的 ⧉ 按钮(当前会话)
|
|
59
|
+
|
|
60
|
+
会话标题栏右侧多一个 ⧉ 按钮,点一下把下面这段放进剪贴板(复制成功变 ✓,失败变 ⚠):
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
【接续会话】<标题>
|
|
64
|
+
会话 ID:session-xxxxxxxx-…
|
|
65
|
+
工作目录:/Users/you/project
|
|
66
|
+
会话日志:/Users/you/.dsh/sessions/…/session.v4.jsonl.zstd
|
|
67
|
+
|
|
68
|
+
提示:日志是 zstd 多帧压缩的 JSONL。在任意 DSH 会话里调用工具
|
|
69
|
+
session_handoff({ sessionId: "session-xxxxxxxx-…" }) 可直接得到解压后的可读记录路径,再 read 它即可接续。
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
配置 `copyFormat: path` 时只复制裸路径一行。
|
|
73
|
+
|
|
74
|
+
### 2. GUI:侧栏会话行右键菜单里的「复制会话路径」(任意会话)
|
|
75
|
+
|
|
76
|
+
侧栏任何一条会话的 `⋯` 菜单 / 右键菜单里,最后一项是**「复制会话路径」**(在「归档会话」下面,
|
|
77
|
+
带一条分隔线)——**不用先打开那个会话**。点击后菜单关闭,顶部弹一条「会话路径已复制」提示。
|
|
78
|
+
|
|
79
|
+
注册用的是宿主自己的 `list` 槽位 `sidebar.workspaces.session.menu.item`(和「置顶/重命名/分叉/归档」
|
|
80
|
+
同一套机制),`order: 500` 排在归档(400)之后,`separatorBefore` 让本项自成一组。
|
|
81
|
+
|
|
82
|
+
### 3. 工具:`session_path`
|
|
83
|
+
|
|
84
|
+
拿路径和会话信息,只读、不落盘。
|
|
85
|
+
|
|
86
|
+
| 参数 | 说明 |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `sessionId` | 目标会话;省略=当前会话 |
|
|
89
|
+
| `format` | `path`(裸路径)或 `block`(接续块,默认取插件配置) |
|
|
90
|
+
|
|
91
|
+
### 4. 工具:`session_handoff`
|
|
92
|
+
|
|
93
|
+
把会话渲染成**解压后的可读文件**,返回绝对路径 —— 这是「其他会话读取并接续」的正路。
|
|
94
|
+
|
|
95
|
+
| 参数 | 说明 |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `sessionId` | 目标会话;省略=当前会话 |
|
|
98
|
+
| `format` | `md`(默认,转录:用户/助手文本 + 工具调用一行摘要)或 `jsonl`(解压后的原始事件流) |
|
|
99
|
+
| `maxChars` | 字符预算;**超出时保留最近的部分**(接续只需要最近上下文),默认取插件配置 |
|
|
100
|
+
|
|
101
|
+
输出示例:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
会话记录已写出:/Users/you/.dsh/session-handoff/session-xxxxxxxx-….md
|
|
105
|
+
格式 md · 18432 字符 · 46 条(仅最近部分,前文已截断)
|
|
106
|
+
工作目录:/Users/you/project
|
|
107
|
+
原始日志:/Users/you/.dsh/sessions/…/session.v4.jsonl.zstd
|
|
108
|
+
下一步:用 read 读取 … 即可接续该会话。
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 配置
|
|
112
|
+
|
|
113
|
+
| 键 | 默认 | 说明 |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `enabled` | `true` | 关掉则不注册任何工具与路由 |
|
|
116
|
+
| `copyFormat` | `block` | 按钮复制的内容:`block` 接续块 / `path` 裸路径 |
|
|
117
|
+
| `handoffFormat` | `md` | `session_handoff` 的默认格式 |
|
|
118
|
+
| `handoffDir` | `""` | 交接文件目录;空=`$DSH_HOME/session-handoff` |
|
|
119
|
+
| `maxHandoffChars` | `200000` | 单个交接文件的字符预算 |
|
|
120
|
+
| `titleScanBytes` | `2097152` | 从日志里读标题时的压缩字节预算 |
|
|
121
|
+
| `promptEnabled` | `true` | 是否往 system prompt 里加那段引导 |
|
|
122
|
+
| `promptOrder` | `64` | 该段的排序 |
|
|
123
|
+
|
|
124
|
+
注意:profile 的 `cordis.patch.yml` 对 `config` 是**整块替换**,只想改一个键也要把想保留的键写全。
|
|
125
|
+
|
|
126
|
+
## 结构
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
lib/index.js 入口:装配 settings / systemPrompt / 路由 / 工具
|
|
130
|
+
lib/config.js schemastery Config + 代码侧默认值
|
|
131
|
+
lib/paths.js $DSH_HOME、sessions 根、会话目录与日志代际
|
|
132
|
+
lib/zstd.js 多帧 Zstandard 边界扫描 + 逐帧读取
|
|
133
|
+
lib/session-info.js 会话 id → 路径 / cwd / 标题
|
|
134
|
+
lib/handoff.js 转录渲染与交接文件写出
|
|
135
|
+
lib/copy-text.js 剪贴板载荷
|
|
136
|
+
lib/route.js /api/dsh-session-path/resolve(只读,带信任围栏)
|
|
137
|
+
lib/trust.js Host / Origin / sec-fetch-site 判定
|
|
138
|
+
lib/tools.js session_path、session_handoff
|
|
139
|
+
lib/client.js 浏览器半:header ⧉ 按钮 + 侧栏会话行菜单项
|
|
140
|
+
test/selftest.mjs 23 项自检(含真实多帧日志)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## 开发循环(本机实测的坑)
|
|
144
|
+
|
|
145
|
+
插件的**服务端半边要 import 宿主包**(`@deepseek-ai/dsh-tools`、`@deepseek-ai/schemastery`),
|
|
146
|
+
这决定了它不能用 `link:` 目录依赖 —— `link:` 之后包的真实路径在 profile 之外,
|
|
147
|
+
Node 从真实路径往上找 `node_modules` 找不到宿主包,插件会 `ERR_MODULE_NOT_FOUND`:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
$ node -e "import('@deepseek-ai/dsh-tools')" # 在插件目录里
|
|
151
|
+
dsh-tools FAIL ERR_MODULE_NOT_FOUND
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
本机其它 `link:` 插件(dsh-piano / dsh-scroll-piano)之所以没事,是因为它们服务端**不 import 任何宿主包**。
|
|
155
|
+
|
|
156
|
+
所以改代码后要**打包 + 重装**:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
NODE=~/.dsh/dsh-runtimes/dsh-primary-runtime/dependencies/node/bin/node
|
|
160
|
+
PNPM="/Applications/DeepSeek Harness.app/Contents/Resources/runtime/pnpm/bin/pnpm.mjs"
|
|
161
|
+
cd <本目录> && "$NODE" "$PNPM" pack --pack-destination dist
|
|
162
|
+
cd ~/.dsh/profiles/desktop && "$NODE" "$PNPM" install
|
|
163
|
+
# 再 touch 一下 cordis.patch.yml 触发热载入;客户端半需要刷新页面
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
> pnpm 对**同路径同版本**的 `file:` 包会跳过重装:改了版本号或 `remove` + `add` 才能确保装进去的是新代码。
|
|
167
|
+
|
|
168
|
+
## 边界
|
|
169
|
+
|
|
170
|
+
- 只读:`session_path` 与 HTTP 路由都不写盘;只有 `session_handoff` 写文件,且只写进
|
|
171
|
+
`handoffDir`(默认 `$DSH_HOME/session-handoff`)。
|
|
172
|
+
- 不解压全文到内存:按 frame 扫描,一次只持有单帧 + 尾部残帧。
|
|
173
|
+
- 路由带信任围栏(仅 loopback / 显式 trusted host,拒绝 cross-site 与跨源 Origin)。
|
|
174
|
+
- 交接文件按字符预算**保留最近部分**:接续场景里尾部上下文才有用。
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# dsh-session-path bundle patch: one host row. `name` MUST equal the package name
|
|
2
|
+
# (the client half resolves `require.resolve('<row-name>/package.json')` to find
|
|
3
|
+
# this package and then its `exports["./client"]` bundle). The row `id` is a
|
|
4
|
+
# purely local identifier and is also the settings-page namespace.
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-session-path
|
|
7
|
+
name: dsh-session-path
|
|
8
|
+
config:
|
|
9
|
+
enabled: true
|
|
10
|
+
# "block" copies a paste-ready handoff block (title / id / cwd / log
|
|
11
|
+
# path); "path" copies the bare log-file path only.
|
|
12
|
+
copyFormat: block
|
|
13
|
+
# Format of the file `session_handoff` writes: "md" (readable
|
|
14
|
+
# transcript) or "jsonl" (plain, decompressed event log).
|
|
15
|
+
handoffFormat: md
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-path — browser half.
|
|
3
|
+
*
|
|
4
|
+
* Two places to copy a session's path:
|
|
5
|
+
*
|
|
6
|
+
* 1. the session header's action row (the currently open session), and
|
|
7
|
+
* 2. the sidebar session row's "..." menu, next to 置顶 / 重命名 / 分叉 / 归档
|
|
8
|
+
* — `sidebar.workspaces.session.menu.item`, a `list` slot the host's own
|
|
9
|
+
* actions use too, so any session can be copied without opening it.
|
|
10
|
+
*
|
|
11
|
+
* Both paths ask the host half for the payload (one source of truth for the
|
|
12
|
+
* path, the workspace and the copy format) and only own the clipboard write and
|
|
13
|
+
* its transient feedback.
|
|
14
|
+
*/
|
|
15
|
+
window.__ModuleLoader__.load({
|
|
16
|
+
id: "dsh-session-path",
|
|
17
|
+
factory: (require) => {
|
|
18
|
+
var module = { exports: {} };
|
|
19
|
+
var exports = module.exports;
|
|
20
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
21
|
+
const React = require("react");
|
|
22
|
+
const h = React.createElement;
|
|
23
|
+
const { useCallback, useEffect, useRef, useState } = React;
|
|
24
|
+
// The Module Loader exposes the UI primitives as a baseline external (see
|
|
25
|
+
// the host's client-plugin docs); a missing copy must not break the slot,
|
|
26
|
+
// so the menu item falls back to a plain role="menuitem" button.
|
|
27
|
+
let primitives = null;
|
|
28
|
+
try {
|
|
29
|
+
primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.warn("[dsh-session-path] ui-primitives unavailable, using a plain menu button:", error);
|
|
32
|
+
}
|
|
33
|
+
const MenuItemButton = primitives?.MenuItemButton ?? null;
|
|
34
|
+
|
|
35
|
+
/** Exact route the host half registers. */
|
|
36
|
+
const ROUTE = "/api/dsh-session-path/resolve";
|
|
37
|
+
/** The session header action row. */
|
|
38
|
+
const SLOT = "conversation.session.header.actions";
|
|
39
|
+
/** The sidebar session row's "..." menu. */
|
|
40
|
+
const MENU_SLOT = "sidebar.workspaces.session.menu.item";
|
|
41
|
+
/** How long the "copied" / "failed" feedback stays. */
|
|
42
|
+
const FEEDBACK_MS = 1800;
|
|
43
|
+
|
|
44
|
+
//#region styles
|
|
45
|
+
const CSS = [
|
|
46
|
+
".dshsp_btn{box-sizing:border-box;width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;padding:0;border:none;border-radius:var(--dsw-radius-sm,4px);background:transparent;color:var(--dsw-alias-label-secondary,#8a8f98);cursor:pointer;transition:background .15s ease,color .15s ease}",
|
|
47
|
+
".dshsp_btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14));color:var(--dsw-alias-label-primary,#e6e8ee)}",
|
|
48
|
+
".dshsp_btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#5b8def));outline-offset:1px}",
|
|
49
|
+
".dshsp_btn[data-phase=busy]{opacity:.55;cursor:progress}",
|
|
50
|
+
".dshsp_btn[data-phase=copied]{color:var(--dsw-alias-state-success-primary,#34a853)}",
|
|
51
|
+
".dshsp_btn[data-phase=failed]{color:var(--dsw-alias-state-warning-primary,#e0a03a)}",
|
|
52
|
+
".dshsp_menuItem{box-sizing:border-box;width:100%;display:flex;align-items:center;gap:8px;padding:6px 10px;border:none;background:transparent;color:var(--dsw-alias-label-primary,#e6e8ee);font:inherit;font-size:13px;line-height:20px;text-align:left;cursor:pointer;border-radius:var(--dsw-radius-sm,4px)}",
|
|
53
|
+
".dshsp_menuItem:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.14))}",
|
|
54
|
+
".dshsp_toast{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:2147483000;padding:8px 14px;border-radius:8px;background:var(--dsw-alias-bg-elevated,#1f2430);color:var(--dsw-alias-label-primary,#e6e8ee);box-shadow:0 6px 22px rgba(0,0,0,.35);font-size:13px;line-height:18px;pointer-events:none}",
|
|
55
|
+
".dshsp_toast[data-ok=false]{color:var(--dsw-alias-state-warning-primary,#e0a03a)}",
|
|
56
|
+
].join("");
|
|
57
|
+
const tagId = "dsh-session-path/header-action.css";
|
|
58
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
59
|
+
const tag = document.createElement("style");
|
|
60
|
+
tag.dataset.plugin = "dsh-session-path";
|
|
61
|
+
tag.dataset.pluginCss = tagId;
|
|
62
|
+
tag.textContent = CSS;
|
|
63
|
+
document.head.appendChild(tag);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
|
|
67
|
+
//#region copy
|
|
68
|
+
/** Put `text` on the clipboard, falling back to the legacy selection path. */
|
|
69
|
+
async function writeClipboard(text) {
|
|
70
|
+
try {
|
|
71
|
+
if (navigator.clipboard !== undefined && typeof navigator.clipboard.writeText === "function") {
|
|
72
|
+
await navigator.clipboard.writeText(text);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
/* fall through to the legacy path */
|
|
77
|
+
}
|
|
78
|
+
const area = document.createElement("textarea");
|
|
79
|
+
area.value = text;
|
|
80
|
+
area.setAttribute("readonly", "");
|
|
81
|
+
area.style.cssText = "position:fixed;top:-1000px;left:-1000px;opacity:0";
|
|
82
|
+
document.body.appendChild(area);
|
|
83
|
+
try {
|
|
84
|
+
area.select();
|
|
85
|
+
return document.execCommand("copy") === true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
} finally {
|
|
89
|
+
area.remove();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ask the host half for one session's payload and copy it.
|
|
95
|
+
* @param sessionId - the session to copy.
|
|
96
|
+
* @returns the copied text.
|
|
97
|
+
*/
|
|
98
|
+
async function copyFor(sessionId) {
|
|
99
|
+
const params = new URLSearchParams({ sessionId });
|
|
100
|
+
const response = await fetch(ROUTE + "?" + params.toString(), { headers: { accept: "application/json" } });
|
|
101
|
+
const body = await response.json().catch(() => null);
|
|
102
|
+
if (!response.ok || body === null || body.ok !== true) {
|
|
103
|
+
throw new Error(body?.error?.message ?? "HTTP " + response.status);
|
|
104
|
+
}
|
|
105
|
+
const text = typeof body.value?.text === "string" && body.value.text !== "" ? body.value.text : body.value?.logPath ?? "";
|
|
106
|
+
if (!(await writeClipboard(text))) throw new Error("clipboard write was refused");
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A short-lived, click-through confirmation banner. */
|
|
111
|
+
function toast(text, ok) {
|
|
112
|
+
try {
|
|
113
|
+
const node = document.createElement("div");
|
|
114
|
+
node.className = "dshsp_toast";
|
|
115
|
+
node.dataset.ok = ok ? "true" : "false";
|
|
116
|
+
node.setAttribute("role", "status");
|
|
117
|
+
node.textContent = text;
|
|
118
|
+
document.body.appendChild(node);
|
|
119
|
+
window.setTimeout(() => node.remove(), FEEDBACK_MS);
|
|
120
|
+
} catch {
|
|
121
|
+
/* feedback is best-effort */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
//#endregion
|
|
125
|
+
|
|
126
|
+
//#region icons
|
|
127
|
+
/** Lucide-style outline copy glyph, currentColor, 16px. */
|
|
128
|
+
function CopyIcon() {
|
|
129
|
+
return h(
|
|
130
|
+
"svg",
|
|
131
|
+
{ width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
132
|
+
h("rect", { x: 9, y: 9, width: 12, height: 12, rx: 2 }),
|
|
133
|
+
h("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Check glyph for the copied state. */
|
|
138
|
+
function CheckIcon() {
|
|
139
|
+
return h(
|
|
140
|
+
"svg",
|
|
141
|
+
{ width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
142
|
+
h("path", { d: "M20 6 9 17l-5-5" }),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Warning glyph for the failed state. */
|
|
147
|
+
function WarnIcon() {
|
|
148
|
+
return h(
|
|
149
|
+
"svg",
|
|
150
|
+
{ width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
|
|
151
|
+
h("path", { d: "M12 9v4" }),
|
|
152
|
+
h("path", { d: "M12 17h.01" }),
|
|
153
|
+
h("path", { d: "M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" }),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
//#endregion
|
|
157
|
+
|
|
158
|
+
//#region labels
|
|
159
|
+
/** Bilingual labels, picked from the document language. */
|
|
160
|
+
function labels() {
|
|
161
|
+
const lang = (typeof document !== "undefined" ? document.documentElement.lang : "") || (typeof navigator !== "undefined" ? navigator.language : "") || "";
|
|
162
|
+
if (/^zh/i.test(lang)) {
|
|
163
|
+
return {
|
|
164
|
+
idle: "复制会话路径(供其他会话读取并接续)",
|
|
165
|
+
busy: "正在解析会话路径…",
|
|
166
|
+
copied: "已复制会话路径",
|
|
167
|
+
failed: "复制失败:点开控制台看 [dsh-session-path]",
|
|
168
|
+
menu: "复制会话路径",
|
|
169
|
+
copiedToast: "会话路径已复制",
|
|
170
|
+
failedToast: "复制失败,控制台里搜 [dsh-session-path]",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
idle: "Copy session path (for another session to read and continue)",
|
|
175
|
+
busy: "Resolving the session path…",
|
|
176
|
+
copied: "Session path copied",
|
|
177
|
+
failed: "Copy failed — see [dsh-session-path] in the console",
|
|
178
|
+
menu: "Copy session path",
|
|
179
|
+
copiedToast: "Session path copied",
|
|
180
|
+
failedToast: "Copy failed — search [dsh-session-path] in the console",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//#endregion
|
|
184
|
+
|
|
185
|
+
//#region header button
|
|
186
|
+
/**
|
|
187
|
+
* The header button for the open session.
|
|
188
|
+
* @param props - slot props; `sessionId` identifies the open session.
|
|
189
|
+
*/
|
|
190
|
+
function CopySessionPathButton(props) {
|
|
191
|
+
const sessionId = typeof props.sessionId === "string" ? props.sessionId : "";
|
|
192
|
+
const [phase, setPhase] = useState("idle");
|
|
193
|
+
const timer = useRef(null);
|
|
194
|
+
const t = labels();
|
|
195
|
+
|
|
196
|
+
useEffect(
|
|
197
|
+
() => () => {
|
|
198
|
+
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
199
|
+
},
|
|
200
|
+
[],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const flash = useCallback((next) => {
|
|
204
|
+
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
205
|
+
setPhase(next);
|
|
206
|
+
timer.current = window.setTimeout(() => {
|
|
207
|
+
timer.current = null;
|
|
208
|
+
setPhase("idle");
|
|
209
|
+
}, FEEDBACK_MS);
|
|
210
|
+
}, []);
|
|
211
|
+
|
|
212
|
+
const copy = useCallback(async () => {
|
|
213
|
+
if (sessionId === "" || phase === "busy") return;
|
|
214
|
+
setPhase("busy");
|
|
215
|
+
try {
|
|
216
|
+
await copyFor(sessionId);
|
|
217
|
+
flash("copied");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.warn("[dsh-session-path] copy failed:", error);
|
|
220
|
+
flash("failed");
|
|
221
|
+
}
|
|
222
|
+
}, [sessionId, phase, flash]);
|
|
223
|
+
|
|
224
|
+
if (sessionId === "") return null;
|
|
225
|
+
const title = phase === "copied" ? t.copied : phase === "failed" ? t.failed : phase === "busy" ? t.busy : t.idle;
|
|
226
|
+
return h(
|
|
227
|
+
"button",
|
|
228
|
+
{
|
|
229
|
+
type: "button",
|
|
230
|
+
className: "dshsp_btn",
|
|
231
|
+
"data-phase": phase,
|
|
232
|
+
"data-dsh-session-path": sessionId,
|
|
233
|
+
title,
|
|
234
|
+
"aria-label": title,
|
|
235
|
+
"aria-live": "polite",
|
|
236
|
+
disabled: phase === "busy",
|
|
237
|
+
onClick: copy,
|
|
238
|
+
},
|
|
239
|
+
phase === "copied" ? h(CheckIcon) : phase === "failed" ? h(WarnIcon) : h(CopyIcon),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
|
|
244
|
+
//#region sidebar row menu
|
|
245
|
+
/**
|
|
246
|
+
* One row of a sidebar session's "..." menu.
|
|
247
|
+
* @param props - slot props; `sessionId` / `displayTitle` describe the row,
|
|
248
|
+
* `useMenuOpenState` is the slot-level hook that dismisses the menu.
|
|
249
|
+
*/
|
|
250
|
+
function CopyPathMenuItem(props) {
|
|
251
|
+
const sessionId = typeof props.sessionId === "string" ? props.sessionId : "";
|
|
252
|
+
const t = labels();
|
|
253
|
+
const onSelect = useCallback(() => {
|
|
254
|
+
try {
|
|
255
|
+
// Dismiss first: a menu left open while the fetch settles would
|
|
256
|
+
// swallow the toast behind it.
|
|
257
|
+
if (typeof props.useMenuOpenState === "function") props.useMenuOpenState()[1](false);
|
|
258
|
+
} catch {
|
|
259
|
+
/* a missing hook must not block the copy */
|
|
260
|
+
}
|
|
261
|
+
if (sessionId === "") return;
|
|
262
|
+
void copyFor(sessionId)
|
|
263
|
+
.then(() => toast(t.copiedToast, true))
|
|
264
|
+
.catch((error) => {
|
|
265
|
+
console.warn("[dsh-session-path] copy failed:", error);
|
|
266
|
+
toast(t.failedToast, false);
|
|
267
|
+
});
|
|
268
|
+
}, [props.useMenuOpenState, sessionId, t]);
|
|
269
|
+
|
|
270
|
+
if (sessionId === "") return null;
|
|
271
|
+
if (MenuItemButton !== null) {
|
|
272
|
+
return h(MenuItemButton, { separatorBefore: true, onSelect, "data-dsh-session-path-menu": sessionId }, t.menu);
|
|
273
|
+
}
|
|
274
|
+
return h(
|
|
275
|
+
"button",
|
|
276
|
+
{ type: "button", role: "menuitem", className: "dshsp_menuItem", "data-dsh-session-path-menu": sessionId, onClick: onSelect },
|
|
277
|
+
t.menu,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
|
|
282
|
+
//#region apply
|
|
283
|
+
const inject = ["slots"];
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Register both surfaces.
|
|
287
|
+
* @param ctx - client root context.
|
|
288
|
+
*/
|
|
289
|
+
function apply(ctx) {
|
|
290
|
+
ctx.slots.inject(SLOT, () =>
|
|
291
|
+
ctx.slots.register(
|
|
292
|
+
{
|
|
293
|
+
name: SLOT,
|
|
294
|
+
id: "dsh-session-path:copy",
|
|
295
|
+
order: 20,
|
|
296
|
+
inject: () => ({}),
|
|
297
|
+
},
|
|
298
|
+
CopySessionPathButton,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
// Order 500 lands after the shipped Archive (400); `separatorBefore` on
|
|
302
|
+
// the item opens our own group.
|
|
303
|
+
ctx.slots.inject(MENU_SLOT, () =>
|
|
304
|
+
ctx.slots.register(
|
|
305
|
+
{
|
|
306
|
+
name: MENU_SLOT,
|
|
307
|
+
id: "dsh-session-path:copy-session",
|
|
308
|
+
order: 500,
|
|
309
|
+
inject: () => ({}),
|
|
310
|
+
},
|
|
311
|
+
CopyPathMenuItem,
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
|
|
317
|
+
exports.apply = apply;
|
|
318
|
+
exports.inject = inject;
|
|
319
|
+
/** Test seam: the pure pieces `test/selftest.mjs` can exercise. */
|
|
320
|
+
exports.__test__ = { ROUTE, SLOT, MENU_SLOT, labels, writeClipboard };
|
|
321
|
+
|
|
322
|
+
return module.exports;
|
|
323
|
+
},
|
|
324
|
+
});
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration: the schema the host validates against, the code-side
|
|
3
|
+
* defaults (so a direct `apply()` call works without the loader), and the
|
|
4
|
+
* normalisation of every string/enum knob.
|
|
5
|
+
*
|
|
6
|
+
* The Cordis loader applies `Config` defaults, but `apply` is also reachable
|
|
7
|
+
* through direct construction (tests, tooling), so this module resolves its own
|
|
8
|
+
* defaults instead of trusting the caller.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-session-path/config
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import z from "@deepseek-ai/schemastery";
|
|
14
|
+
|
|
15
|
+
/** Code-side defaults, mirrored by the `Config` schema below. */
|
|
16
|
+
export const DEFAULTS = Object.freeze({
|
|
17
|
+
enabled: true,
|
|
18
|
+
copyFormat: "block",
|
|
19
|
+
handoffFormat: "md",
|
|
20
|
+
handoffDir: "",
|
|
21
|
+
maxHandoffChars: 200_000,
|
|
22
|
+
titleScanBytes: 2_097_152,
|
|
23
|
+
promptEnabled: true,
|
|
24
|
+
promptOrder: 64,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const Config = z.object({
|
|
28
|
+
/** Master switch; `false` registers no tools and no HTTP route. */
|
|
29
|
+
enabled: z.boolean().default(DEFAULTS.enabled),
|
|
30
|
+
/** Clipboard payload shape for the header button: `block` or `path`. */
|
|
31
|
+
copyFormat: z.string().default(DEFAULTS.copyFormat),
|
|
32
|
+
/** `session_handoff` output format: `md` (transcript) or `jsonl` (events). */
|
|
33
|
+
handoffFormat: z.string().default(DEFAULTS.handoffFormat),
|
|
34
|
+
/** Directory for handoff files; empty means `$DSH_HOME/session-handoff`. */
|
|
35
|
+
handoffDir: z.string().default(DEFAULTS.handoffDir),
|
|
36
|
+
/** Character budget for one handoff file (the tail of the session wins). */
|
|
37
|
+
maxHandoffChars: z.natural().default(DEFAULTS.maxHandoffChars),
|
|
38
|
+
/** Compressed bytes scanned when the log title must be read from disk. */
|
|
39
|
+
titleScanBytes: z.natural().default(DEFAULTS.titleScanBytes),
|
|
40
|
+
/** Whether to contribute the guide section to the system prompt. */
|
|
41
|
+
promptEnabled: z.boolean().default(DEFAULTS.promptEnabled),
|
|
42
|
+
/** Ordering of that system-prompt section. */
|
|
43
|
+
promptOrder: z.natural().default(DEFAULTS.promptOrder),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* One of `allowed`, or `fallback` when the value is absent/unknown.
|
|
48
|
+
*
|
|
49
|
+
* @param value - raw configured value.
|
|
50
|
+
* @param allowed - accepted strings.
|
|
51
|
+
* @param fallback - value used otherwise.
|
|
52
|
+
* @returns the resolved string.
|
|
53
|
+
*/
|
|
54
|
+
function pick(value, allowed, fallback) {
|
|
55
|
+
return typeof value === "string" && allowed.includes(value) ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A positive integer, or `fallback`.
|
|
60
|
+
*
|
|
61
|
+
* @param value - raw configured value.
|
|
62
|
+
* @param fallback - value used otherwise.
|
|
63
|
+
* @returns the resolved integer.
|
|
64
|
+
*/
|
|
65
|
+
function positiveInteger(value, fallback) {
|
|
66
|
+
const number = Number(value);
|
|
67
|
+
return Number.isSafeInteger(number) && number > 0 ? number : fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve user configuration into the plain settings object the plugin uses.
|
|
72
|
+
*
|
|
73
|
+
* @param raw - the loader-supplied config (possibly undefined).
|
|
74
|
+
* @returns resolved settings.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveConfig(raw) {
|
|
77
|
+
const config = raw === null || typeof raw !== "object" ? {} : raw;
|
|
78
|
+
return {
|
|
79
|
+
enabled: config.enabled !== false,
|
|
80
|
+
copyFormat: pick(config.copyFormat, ["block", "path"], DEFAULTS.copyFormat),
|
|
81
|
+
handoffFormat: pick(config.handoffFormat, ["md", "jsonl"], DEFAULTS.handoffFormat),
|
|
82
|
+
handoffDir: typeof config.handoffDir === "string" ? config.handoffDir.trim() : "",
|
|
83
|
+
maxHandoffChars: positiveInteger(config.maxHandoffChars, DEFAULTS.maxHandoffChars),
|
|
84
|
+
titleScanBytes: positiveInteger(config.titleScanBytes, DEFAULTS.titleScanBytes),
|
|
85
|
+
promptEnabled: config.promptEnabled !== false,
|
|
86
|
+
promptOrder: Number.isSafeInteger(Number(config.promptOrder)) ? Number(config.promptOrder) : DEFAULTS.promptOrder,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/lib/copy-text.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose the clipboard payload for one session.
|
|
3
|
+
*
|
|
4
|
+
* `path` is the bare log-file path; `block` is a paste-ready handoff a user can
|
|
5
|
+
* drop into any other session, and it names the tool that turns the compressed
|
|
6
|
+
* log into something the other agent can simply read.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-session-path/copy-text
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the copied text for one resolved session.
|
|
13
|
+
*
|
|
14
|
+
* @param info - result of `resolveSessionInfo`.
|
|
15
|
+
* @param format - `path` or `block`.
|
|
16
|
+
* @returns the clipboard string.
|
|
17
|
+
*/
|
|
18
|
+
export function composeCopyText(info, format) {
|
|
19
|
+
if (format === "path") return info.logPath;
|
|
20
|
+
const lines = [];
|
|
21
|
+
lines.push(`【接续会话】${info.title === "" ? "(无标题)" : info.title}`);
|
|
22
|
+
lines.push(`会话 ID:${info.sessionId}`);
|
|
23
|
+
if (info.cwd !== "") lines.push(`工作目录:${info.cwd}`);
|
|
24
|
+
lines.push(`会话日志:${info.logPath === "" ? "(未找到日志文件)" : info.logPath}`);
|
|
25
|
+
lines.push("");
|
|
26
|
+
lines.push(`提示:日志是 zstd 多帧压缩的 JSONL。在任意 DSH 会话里调用工具 session_handoff({ sessionId: "${info.sessionId}" }) 可直接得到解压后的可读记录路径,再 read 它即可接续。`);
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|