dsh-session-conductor 1.3.17 → 1.3.19
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 +86 -266
- package/lib/apply.js +21 -2
- package/lib/features/member-model/index.js +5 -1
- package/lib/features/member-model/override.js +50 -0
- package/lib/features/member-model/tools.js +10 -0
- package/lib/sessions/events.js +6 -17
- package/lib/sessions/log-stream.js +68 -0
- package/lib/sessions/member-trace.js +338 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,289 +1,129 @@
|
|
|
1
1
|
# dsh-session-conductor
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH 会话管理增强插件:面板管理会话、批量删除与回收站、损坏修复、自动续跑、模板注入,以及成员(子代理)的模型/权限/换名管理。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 功能
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
## 功能总览
|
|
10
|
-
|
|
11
|
-
| 功能 | 说明 | 入口 |
|
|
7
|
+
| 功能 | 做什么 | 入口 |
|
|
12
8
|
|---|---|---|
|
|
13
|
-
| 会话管理面板 |
|
|
14
|
-
| 回收站 |
|
|
15
|
-
| 自动重命名 |
|
|
16
|
-
| 自动续跑 |
|
|
17
|
-
| 会话分组 |
|
|
18
|
-
| 撤回最后一条消息 |
|
|
19
|
-
| 损坏修复 |
|
|
20
|
-
| 价值分析 | LLM
|
|
21
|
-
| 全文搜索 |
|
|
22
|
-
|
|
|
23
|
-
| 模板注入 |
|
|
24
|
-
| 自动重命名模型 |
|
|
25
|
-
| 成员模型切换 |
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
会话管理面板(侧边栏入口):
|
|
37
|
-
|
|
38
|
-

|
|
39
|
-
|
|
40
|
-
设置页 · 模板注入(设置 → 会话管理):
|
|
41
|
-
|
|
42
|
-

|
|
43
|
-
|
|
44
|
-
## 环境要求(支持的 DSH 版本)
|
|
9
|
+
| 会话管理面板 | 五视图(全部/已归档/活跃/回收站/说明);归档、删除、撤回、置不活跃、释放空闲 | 侧边栏「会话管理」 |
|
|
10
|
+
| 回收站 | 删除只打标记,可单条/多选/按批次还原;到期或一键清空才真删盘 | 面板「回收站」 |
|
|
11
|
+
| 自动重命名 | 回合后分析内容,主题偏离时生成新标题 | 面板逐会话开关 |
|
|
12
|
+
| 自动续跑 | 识别非人为中断并继续原任务,失败 30s 重试 | 面板逐会话开关 |
|
|
13
|
+
| 会话分组 | 按工作区分组显示;分组下新建会话 | 设置 → 插件 → 会话分组 |
|
|
14
|
+
| 撤回最后一条消息 | 删除最后一条用户消息及整轮回复(预览 + 二次确认 + 备份) | 面板按钮 |
|
|
15
|
+
| 损坏修复 | seq-gap / EIO 坏块 / 双格式 / zstd 帧级损坏 四类 | 面板「说明」页 + `/repair` 系列 |
|
|
16
|
+
| 价值分析 | LLM 给会话分类(已完成/未完成/久未活跃/活跃中) | 面板按钮 |
|
|
17
|
+
| 全文搜索 | FTS5 优先,内置 zstd 扫描兜底 | 面板搜索框 |
|
|
18
|
+
| 压缩模型 | 上下文自动压缩使用独立模型 | 设置 → 插件 → 压缩模型 |
|
|
19
|
+
| 模板注入 | 方案/收尾模板;本地文件、在线网址、目录浏览三种导入 | 设置 → 会话管理 |
|
|
20
|
+
| 自动重命名模型 | 指定标题分析用的模型 | 设置 → 会话管理 |
|
|
21
|
+
| 成员模型切换 | 切单个或全部成员的模型,可跟随主代理 | `/model-switch`、`set_member_model` |
|
|
22
|
+
| 成员权限预设 | 查看/切换成员权限预设,支持批量与跟随 | `/perm-switch`、`set_member_permission` |
|
|
23
|
+
| 成员换名 | 新建成员并把原会话内容搬进去 | `clone_member_as` |
|
|
24
|
+
| 创建成员并选模型 | 创建时即指定模型,首轮就用它 | `create_teammate_with_model` |
|
|
25
|
+
| 指定模型新建会话 | 新建会话并直接指定 provider/model | `create_session` |
|
|
26
|
+
| 记忆取回 | 上下文用量骤降时自动回注最近几轮对话 | 自动注入 + `recall_memory` |
|
|
27
|
+
| 上下文用量 | 查自己会话的占用与构成 | `context_usage` |
|
|
28
|
+
| 跨会话消息 | 给另一个会话发消息,空闲时自动唤醒 | `list_sessions`、`send_to_session` |
|
|
29
|
+
| 成员轨迹 | 读子代理的完整执行过程:思考、正文、工具调用与结果 | `read_member_trace` |
|
|
30
|
+
|
|
31
|
+
## 环境要求
|
|
45
32
|
|
|
46
33
|
| 项 | 要求 |
|
|
47
34
|
|---|---|
|
|
48
|
-
|
|
|
49
|
-
| Node |
|
|
35
|
+
| DSH | **0.2.1 及以上**(更早版本功能可用,但列表缓存不生效,会话多时列表构建变慢) |
|
|
36
|
+
| Node | 与宿主自带的 Node 一致 |
|
|
50
37
|
| 平台 | Linux(群晖 DSM / fnOS 等);纯 Node 实现,无原生依赖 |
|
|
51
38
|
|
|
52
|
-
|
|
53
|
-
**`SessionPersistenceSnapshot`**(`{ header, revision, eventCount?, sizeBytes? }`)里的 `revision`
|
|
54
|
-
作为「日志变过没有」的变更令牌。0.2.1 起该 API 稳定提供 `revision`;更早的版本里
|
|
55
|
-
`list()` 只给裸 header(没有 `revision`),插件会**优雅降级**:功能全部正常,
|
|
56
|
-
只是每次列表构建都要重新解析会话日志(会话多、日志大时会明显变慢、内存占用高)。
|
|
57
|
-
|
|
58
|
-
> 早期代码曾探测 `persistence.listSnapshots()` 并期望它返回 `{ header, revision }` ——
|
|
59
|
-
> 该 session 级方法**在任何 DSH 版本里都不存在**(0.1.6 里同名的 `listSnapshots(profileDir)`
|
|
60
|
-
> 是 profile 快照,另一套东西),所以那个探测永远失败、缓存从未生效过。
|
|
61
|
-
> 1.2.0 起统一改用 `list()` 并按快照解析。
|
|
62
|
-
|
|
63
|
-
## 安装
|
|
64
|
-
|
|
65
|
-
装到哪个 profile 由 `--profile` 指定。`dsh plugin` 会把后面的参数**转发给该 profile 目录下的 pnpm**,
|
|
66
|
-
因此第 2 步与「直接在该 profile 目录执行 pnpm」等价(该命令会初始化首次使用的 profile)。
|
|
67
|
-
|
|
68
|
-
1. **把插件源码放进 profile 的真实目录**(目录拷贝,不要软链):
|
|
69
|
-
```
|
|
70
|
-
<DSH_HOME>/profiles/<profile>/local-plugins/dsh-session-conductor/
|
|
71
|
-
```
|
|
72
|
-
2. **用 dsh 在该 profile 里装依赖**(写入 profile 的 `package.json` 与 `node_modules/`):
|
|
73
|
-
```
|
|
74
|
-
dsh plugin --profile <profile> add file:local-plugins/dsh-session-conductor
|
|
75
|
-
dsh plugin --profile <profile> install
|
|
76
|
-
```
|
|
77
|
-
装完 profile `package.json` 的 `dependencies` 里应出现
|
|
78
|
-
`"dsh-session-conductor": "file:local-plugins/dsh-session-conductor"`。
|
|
79
|
-
3. **bundle 形态再补 loader 行**:把插件名加进 profile `package.json` 的 `dsh.profile.bundles`;
|
|
80
|
-
插件自带的 `cordis.patch.yml` 必须用顶层 `- insert:` **新建** loader 行 —— 顶层 `- id:` 是"覆盖已有行",
|
|
81
|
-
基座没有该 id 时市场会报 `patch target not found`。
|
|
82
|
-
4. **装前预演、装后确认**:装前先 dry-run(确认插件入口能被 import、patch 能进组合树、不破坏现有插件组合);
|
|
83
|
-
装完重启宿主,并在实例日志里确认该插件 `loaded`。
|
|
84
|
-
|
|
85
|
-
> 为什么放在 profile 的 `local-plugins/` 而不是全局:插件与宿主版本强绑定,放在 profile 内可随实例
|
|
86
|
-
> 独立升级,也不会让多个 profile 互相污染。
|
|
87
|
-
|
|
88
|
-
### 从 GitHub 安装(可选)
|
|
89
|
-
|
|
90
|
-
本仓库是公开仓库,可直接用 GitHub 源安装。
|
|
91
|
-
|
|
92
|
-
⚠️ **`dsh plugin --profile <名> add …` 当前实测会挂死且零输出**(两次都以 timeout 结束、日志为空)⇒
|
|
93
|
-
安装请在**该 profile 目录里直跑 pnpm**(`dsh plugin` 的设计是把参数转发给 profile 的 pnpm,两者本应等价,
|
|
94
|
-
但转发这条路目前不可用):
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
cd <DSH_HOME>/profiles/<profile>
|
|
98
|
-
pnpm add github:EIGHTfs/dsh-session-conductor#<commit>
|
|
99
|
-
# 等价写法:pnpm add git+https://github.com/EIGHTfs/dsh-session-conductor.git#<commit>
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
**前置条件**:
|
|
103
|
-
|
|
104
|
-
1. **本机要有 `npm`** —— pnpm 构建 git 来源的包时会在临时目录执行 `npm install`;
|
|
105
|
-
缺 npm 时表现为 `ERR_PNPM_PREPARE_PACKAGE` + `spawn ENOENT: npm: command not found`。
|
|
106
|
-
2. **pnpm 11 默认禁止 git 源依赖执行构建脚本** —— 仅**带 `prepare`/`prepack` 的插件**需要放行
|
|
107
|
-
(例:dsh-git-push 的 prepare 构建不入库的 `lib/client.js`)。**本仓库没有 prepare/prepack ⇒ 无需这一步**;
|
|
108
|
-
若安装的是那类插件,先在 profile 的 `pnpm-workspace.yaml` 里加:
|
|
109
|
-
|
|
110
|
-
```yaml
|
|
111
|
-
allowBuilds:
|
|
112
|
-
<包名>@https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>: true
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
⚠️ key 里带 commit 哈希 ⇒ 换版本/换提交要同步改这一行;否则报 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`。
|
|
116
|
-
|
|
117
|
-
- 用 `#<commit>` 固定版本(本仓库远端暂无 tag,写 `#v1.0.x` 会拉不到);公开仓库匿名可装,私有仓库才需凭据。
|
|
118
|
-
- 装完同样要做上面的第 3、4 步(`dsh.profile.bundles` + `cordis.patch.yml` 的 `- insert:` + 重启确认 `loaded`)。
|
|
119
|
-
|
|
120
|
-
## 使用
|
|
121
|
-
|
|
122
|
-
### 会话管理面板
|
|
39
|
+
## 用法
|
|
123
40
|
|
|
124
|
-
|
|
41
|
+
### 面板与设置页
|
|
125
42
|
|
|
126
|
-
-
|
|
127
|
-
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
- **说明**:损坏修复(seq-gap / EIO / 双格式)+ 使用说明
|
|
43
|
+
- **面板**:侧边栏底部「会话管理」展开。五个视图分别是全部、已归档、活跃、回收站、说明。
|
|
44
|
+
行内可归档、删除、撤回最后一条消息、开关自动重命名/自动续跑;顶部可搜索、价值分析、释放全部空闲。
|
|
45
|
+
- **设置页**:左侧栏「会话管理」独立页。模板注入(方案/收尾两个槽位,可上传本地 md、填在线网址、
|
|
46
|
+
浏览 DSH 目录导入,导入后可再编辑)与自动重命名模型选择。
|
|
131
47
|
|
|
132
|
-
|
|
133
|
-
> 所以还原是秒级且零风险。真正删盘只发生在回收站到期(`trashRetentionDays`,默认 7 天)
|
|
134
|
-
> 或「一键清空」,由 `purgeExpiredTrash` 执行。
|
|
48
|
+
### 聊天框指令
|
|
135
49
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
左侧栏「会话管理」独立页:
|
|
139
|
-
|
|
140
|
-
- **模板注入**:方案模板 / 收尾模板两个槽位。每个槽位下拉选导入方式:
|
|
141
|
-
- **本地文件**:从设备选择上传(系统文件选择器)
|
|
142
|
-
- **在线 md 网址**:输入网址,后端下载转存
|
|
143
|
-
- **DSH 目录浏览**:路径选择悬浮窗(目录树 + 路径输入,点 .md 选中后导入)
|
|
144
|
-
导入内容显示在编辑框(动态高度、自动换行),**可再次编辑并保存**;开启后注入系统提示词,AI 一开始就看见(方案/收尾各一条上下文注入)。
|
|
145
|
-
- **自动重命名模型**:选择标题分析用的模型(跟随会话模型或指定便宜/专用模型)
|
|
146
|
-
|
|
147
|
-
### 真实后端预览页
|
|
148
|
-
|
|
149
|
-
改 UI 后免重启查看效果 —— **预览能力已移出本插件**,统一用独立插件 `dsh-useful-tools`(**0.1.11**)的 `preview` / `minihost` 工具。
|
|
150
|
-
|
|
151
|
-
**最短三行(实测可用;本仓库没有 `assets/preview.spec.mjs` 也能出真实界面)**:
|
|
50
|
+
`/model-switch` —— 查看/切换成员模型:
|
|
152
51
|
|
|
153
52
|
```
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
53
|
+
/model-switch # 无参:列出成员与各自模型
|
|
54
|
+
/model-switch writer # 只给成员:查它实际在用的模型
|
|
55
|
+
/model-switch writer sa6400/deepseek-v4.1-flash # 成员 + 模型:切换
|
|
56
|
+
/model-switch * auto # 成员写 * = 全部;模型写 auto = 跟随我
|
|
157
57
|
```
|
|
158
58
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
**要点**:
|
|
162
|
-
|
|
163
|
-
- **`services` 必须传**(0.1.11 新增):本插件 `apply()` 依赖 `storageDomain` 等宿主服务,不注入时 `/templates` 会 500、页面显示「storageDomain 不可用」;注入 `test/api/helpers/fake-services.mjs` 后 `/templates` 返回 200 真实数据(已实测)。
|
|
164
|
-
- **autoMount**(0.1.7 起):无 spec 时 mock 宿主 `__ModuleLoader__` / `require` / `ctx`(slots / locale / effect),调插件 `apply(ctx)`,把 `slots.register(def, component)` 捕获到的组件 `createRoot().render()` 到 `#root`。**限制**:组件若依赖 `useSettings` / `useSession` 这类宿主 hooks 会渲染失败(页面会显示错误与已捕获插槽清单,不白屏);要 100% 还原仍需 spec 提供 mock。locale 取插件自己 `ctx.locale.register` 的字典,键名不一致会回退成 key。
|
|
165
|
-
- `gen` 默认产物是 `<插件根>/assets/preview.html`(会写进仓库)⇒ 不想入库就传 `out`(如 `out: "<临时目录>/preview.html"`),并让 `serve` 的 `file` 指向同一路径。本仓库已把该产物加进 `.gitignore`。
|
|
166
|
-
- `serve` 默认端口 **31188**,显式传 `port` 才用你给的;`backend` 可指向 minihost 的端口(`/api/*` 反代到模拟后端),也可指向**真实 DSH 后端**。
|
|
167
|
-
- 显式传 `spec` 但文件不存在会**直接报错**(不静默降级)—— 故意的,避免指错文件还生成一个看不懂的页面。
|
|
168
|
-
- **截图**(README 配图)走 **browser 插件的 `browser_screenshot`**;「自包含快照」形态不做(理由见 `dsh-useful-tools` 的 `docs/NOT-DONE.md`)。
|
|
169
|
-
- **隔离实例自测**(0.1.8/0.1.9):`test_env { action:"create"|"start"|"stop"|"status", plugin, pluginName }` 起独立 `DSH_HOME` + 自动端口的实例;`sync_plugin { write:true, register:true, dshHome:"<测试环境 DSH_HOME>" }` 把插件装进去(写 profile 的 `dependencies` + `dsh.profile.bundles`,并对齐 `node_modules` 副本)。
|
|
170
|
-
- 停止:`minihost { action: "stop", port: 31177 }` + `preview { action: "stop", port: 31200 }`;状态:两者都用 `action: "status"`。
|
|
171
|
-
- **截图**(README 配图)改走 **browser 插件的 `browser_screenshot`**;「自包含快照」形态不做(理由见 `dsh-useful-tools` 的 `docs/NOT-DONE.md`)。
|
|
172
|
-
|
|
173
|
-
### 聊天框指令:`/model-switch`
|
|
174
|
-
|
|
175
|
-
在**用户聊天框**里直接看/切成员(子代理)的模型,不必求 AI。指令由插件注册到宿主的
|
|
176
|
-
`ctx.commands` 服务,**在插件加载时生效** —— 装好或更新后需重载一次实例,输入框里敲 `/model` 才有补全。
|
|
59
|
+
`/perm-switch` —— 查看/切换成员权限预设:
|
|
177
60
|
|
|
178
|
-
**① 看有哪些成员、各自在用什么**
|
|
179
|
-
|
|
180
|
-
```
|
|
181
|
-
/model-switch
|
|
182
61
|
```
|
|
183
|
-
|
|
62
|
+
/perm-switch # 无参:列出成员与各自预设
|
|
63
|
+
/perm-switch writer # 只给成员:查 preset / sandbox / approval
|
|
64
|
+
/perm-switch writer danger-full-access # 成员 + 预设:切换
|
|
65
|
+
/perm-switch * auto # 成员写 * = 全部;预设写 auto = 跟随我
|
|
184
66
|
```
|
|
185
|
-
成员模型(共 2 个)
|
|
186
|
-
|
|
187
|
-
· model-test(session-aaaa-1111) — 最近请求=wb/cn:deepseek-v4-flash
|
|
188
|
-
· writer(session-bbbb-2222) — 最近请求=n/deepseek-v4.1-flash
|
|
189
|
-
|
|
190
|
-
要查**实际**在用什么(含历史请求,不只本次进程)或切换:
|
|
191
|
-
/model-switch <成员名或 sessionId> ← 查实际模型
|
|
192
|
-
/model-switch <成员名或 sessionId> <provider>/<模型> ← 切换并回显查证
|
|
193
|
-
|
|
194
|
-
和别人对齐 / 批量:
|
|
195
|
-
/model-switch * auto ← 最常用:所有子代理都换成「我自己现在用的」模型
|
|
196
|
-
/model-switch <成员名> auto ← 只让这一个成员换成我自己现在用的模型
|
|
197
|
-
/model-switch * <provider>/<模型> ← 所有子代理换成一个指定的模型
|
|
198
|
-
|
|
199
|
-
* = 全部成员(不含我自己);auto = 「跟我保持一致」,不用去查我用的是哪个模型。
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### 让全部子代理和我用同一个模型
|
|
203
|
-
|
|
204
|
-
三种写法都能做到,按场景挑:
|
|
205
|
-
|
|
206
|
-
| 场景 | 用户指令 | AI 工具调用 |
|
|
207
|
-
|---|---|---|
|
|
208
|
-
| **所有子代理都跟我一致** | `/model-switch * auto` | `set_member_model({})`(三个参数全留空) |
|
|
209
|
-
| 只让某一个跟我一致 | `/model-switch <成员名> auto` | `set_member_model({ target: "<成员名>" })` |
|
|
210
|
-
| 所有子代理统一到指定模型 | `/model-switch * sa6400/deepseek-v4.1-flash` | `set_member_model({ provider: "sa6400", model: "deepseek-v4.1-flash" })` |
|
|
211
|
-
|
|
212
|
-
**参数留空的语义**(指令与工具一致):
|
|
213
|
-
|
|
214
|
-
| 参数 | 留空 / `*` / `auto` 的含义 |
|
|
215
|
-
|---|---|
|
|
216
|
-
| 成员(`target` 或第一个位置参数) | 留空或 `*` = **全部成员**(自动排除我自己,也排除正在创建 / 已失败的) |
|
|
217
|
-
| 模型(`provider`+`model` 或第二个位置参数) | 留空或 `auto` = **跟主代理(Lead)一致**,即我自己当前实际在用的模型 |
|
|
218
|
-
|
|
219
|
-
「我自己在用的模型」取自**请求级权威记录**(会话日志里的 `request/header`,即真实发出请求时用的值),
|
|
220
|
-
读不到就如实报错,不会拿名册里的静态字段冒充。批量执行**串行**、**逐个隔离失败**、**回执带完整名单**。
|
|
221
|
-
|
|
222
|
-
权限预设同样支持这套写法:`/perm-switch * auto` = 所有子代理的权限预设跟我一致;
|
|
223
|
-
`set_member_permission({})` 等价。
|
|
224
|
-
|
|
225
|
-
注意:正在跑回合的成员会被跳过(等 60s 仍未结束则报错并提示先中断)—— 这是有意的,
|
|
226
|
-
运行中切模型不生效,还可能被其内存态覆盖。
|
|
227
67
|
|
|
228
|
-
|
|
68
|
+
两条指令都支持:成员位置可填成员名、唯一子串或 sessionId;`*` 表示全部成员(不含自己);
|
|
69
|
+
`auto` 表示跟随主代理当前值。切换后回执会带上「实际生效」的查证结果。
|
|
229
70
|
|
|
230
|
-
|
|
71
|
+
### AI 工具
|
|
231
72
|
|
|
232
|
-
```
|
|
233
|
-
|
|
73
|
+
```js
|
|
74
|
+
list_models({}) // 列出可用 provider 与模型 id
|
|
75
|
+
set_member_model({ target, provider, model }) // 三者都可空:target 空=全部成员;provider/model 空=跟随我
|
|
76
|
+
set_member_permission({ target, preset }) // 两者都可空:target 空=全部成员;preset 空=跟随我
|
|
77
|
+
list_permission_presets({}) // 列出可选权限预设
|
|
78
|
+
clone_member_as({ source, name, description, withHistory, provider, model, subagentProvider })
|
|
79
|
+
// source/name 必填;withHistory 默认 true(false=只建空成员)
|
|
80
|
+
create_teammate_with_model({ name, description, prompt, provider, model, subagentProvider, context })
|
|
81
|
+
// name/description/prompt/provider/model 必填
|
|
82
|
+
create_session({ cwd, workspaceId, provider, model, text, dryRun, sessionId })
|
|
83
|
+
// 全可空:cwd 缺省=调用方 cwd;dryRun=true 只预览
|
|
84
|
+
list_sessions({ query, limit, offset, fromIndex }) // 全可空:fromIndex=true 直接读索引(秒回)
|
|
85
|
+
send_to_session({ sessionId, text, mode, dryRun }) // sessionId/text 必填;mode=queue|steer
|
|
86
|
+
recall_memory({ rounds, session }) // 都可空:rounds 默认取配置、上限 50;session 空=自己
|
|
87
|
+
context_usage({}) // 无参
|
|
88
|
+
read_member_trace({ session, rounds, detail, include, maxCharsPerItem })
|
|
89
|
+
// session 空=自己;rounds 默认 1(上限 20);detail=summary|full
|
|
234
90
|
```
|
|
235
91
|
|
|
236
|
-
|
|
237
|
-
成员 writer(session-bbbb-2222)
|
|
92
|
+
典型调用:
|
|
238
93
|
|
|
239
|
-
|
|
240
|
-
|
|
94
|
+
```js
|
|
95
|
+
// 全部子代理都换成我现在用的模型
|
|
96
|
+
set_member_model({})
|
|
241
97
|
|
|
242
|
-
|
|
243
|
-
|
|
98
|
+
// 只把 writer 切到指定模型
|
|
99
|
+
set_member_model({ target: "writer", provider: "sa6400", model: "cn:deepseek-v4.1-flash" })
|
|
244
100
|
|
|
245
|
-
|
|
101
|
+
// 把 probe1 克隆成 rename1(换名用)
|
|
102
|
+
clone_member_as({ source: "probe1", name: "rename1" })
|
|
246
103
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
```
|
|
250
|
-
/model-switch writer n/deepseek-v4.1-flash
|
|
104
|
+
// 给另一个会话发消息(dryRun 先预览目标状态与限流余量)
|
|
105
|
+
send_to_session({ sessionId: "session-xxxx", text: "继续刚才的任务", dryRun: true })
|
|
251
106
|
```
|
|
252
107
|
|
|
253
|
-
```
|
|
254
|
-
已把成员 writer(session-bbbb-2222) 切到 n/deepseek-v4.1-flash
|
|
255
|
-
生效方式:model/selection 事件(官方投影接管)…
|
|
256
|
-
最近一次实际请求:wb/cn:deepseek-v4-flash(2026/10/17 05:18:00)
|
|
257
|
-
判定:⏳ 待下一回合生效(最近一次请求是切换前发出的,属正常)
|
|
258
|
-
确认结果会在该成员下次发起请求时注入回发起方(事件驱动,不阻塞)。
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
看到 **⏳ 不代表失败**:切换本就下一回合生效(当前那次请求早已发出)。该成员下次请求时插件按事件判定,并把 `【切换查证】✅ 已确认生效:…`(或 `❌ 未生效:期望 X,实际 Y` —— 不假装成功)注入回发起方。定位可用**成员名**或 **sessionId**(名字支持唯一子串,重名会要求写全);模型必须写成 `provider/model`,写错会直接给正确格式。
|
|
262
|
-
|
|
263
108
|
## API
|
|
264
109
|
|
|
265
110
|
插件以 `/api/session-conductor/*` 前缀注册宿主路由:
|
|
266
111
|
|
|
267
112
|
| 端点 | 方法 | 用途 |
|
|
268
113
|
|---|---|---|
|
|
269
|
-
| `/list` | GET |
|
|
270
|
-
| `/archive`
|
|
114
|
+
| `/list` | GET | 会话列表;`?fromIndex=true` 读落盘索引秒回,`?job=1` 交给后台 job |
|
|
115
|
+
| `/archive` `/restore` | POST | 归档 / 恢复会话 |
|
|
271
116
|
| `/delete` | POST | 删除会话(拒绝运行中) |
|
|
272
117
|
| `/undo` | POST | 撤回最后一条消息 |
|
|
273
|
-
| `/auto-rename` | POST |
|
|
274
|
-
| `/auto-continue` | POST | 自动续跑开关 |
|
|
118
|
+
| `/auto-rename` `/auto-continue` | POST | 两个逐会话开关 |
|
|
275
119
|
| `/auto-continue-gate` | GET/POST | 全局续跑闸门 |
|
|
276
120
|
| `/release` | POST | 释放空闲会话 |
|
|
277
121
|
| `/value-analysis` | POST | 价值分析 |
|
|
278
|
-
| `/repair`
|
|
122
|
+
| `/repair` `/repair-eio` `/repair-dual` | POST | 三类损坏修复 |
|
|
279
123
|
| `/search` | GET | 全文搜索 |
|
|
280
|
-
| `/compaction-model` | GET/POST |
|
|
281
|
-
| `/templates` | GET/POST |
|
|
282
|
-
| `/
|
|
283
|
-
| `/auto-rename-model` | GET | 自动重命名模型(透传官方 modelCatalog) |
|
|
284
|
-
| `/i18n` | GET | 语言包(zh/en 外置 JSON) |
|
|
285
|
-
|
|
286
|
-
示例:
|
|
124
|
+
| `/compaction-model` `/auto-rename-model` | GET/POST | 两个模型设置 |
|
|
125
|
+
| `/templates` `/templates/dir` | GET/POST | 模板注入与目录浏览 |
|
|
126
|
+
| `/i18n` | GET | 语言包(zh/en) |
|
|
287
127
|
|
|
288
128
|
```bash
|
|
289
129
|
curl http://<host>/api/session-conductor/list
|
|
@@ -298,27 +138,7 @@ curl -X POST http://<host>/api/session-conductor/archive \
|
|
|
298
138
|
|
|
299
139
|
## 文档索引
|
|
300
140
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
- [
|
|
304
|
-
- [
|
|
305
|
-
- [函数列表](docs/FUNCTIONS.md) — lib/ 全部导出函数的名称/位置/签名(doc-func 维护)
|
|
306
|
-
|
|
307
|
-
### 聊天框指令:`/perm-switch`
|
|
308
|
-
|
|
309
|
-
在**用户聊天框**里看/切成员(子代理)的**权限预设**。子代理在界面上切不了权限预设,这条指令是唯一的界面内入口。
|
|
310
|
-
|
|
311
|
-
```
|
|
312
|
-
/perm-switch # 列成员与各自预设(auto 悬空会告警)
|
|
313
|
-
/perm-switch <成员名|sessionId> # 查该成员的 preset / sandbox / approval
|
|
314
|
-
/perm-switch <成员名|sessionId> <预设名> # 切单个成员
|
|
315
|
-
/perm-switch * <预设名> # 全部成员切成该预设(回执带完整名单)
|
|
316
|
-
/perm-switch <成员名|*> auto # 跟随主代理(Lead)当前预设
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
**为什么需要它**:子代理会话的 `permission/preset` 一旦指向当前不可用的值(典型:`auto` 在「自动审查」关闭后悬空),
|
|
320
|
-
该成员会永久唤不醒,而宿主只会报一句 `subagent/not-resumable`。典型判据:会话日志最后一条
|
|
321
|
-
`permission/preset` 是 `auto`,且当前可选预设列表里**没有** `auto`。
|
|
322
|
-
|
|
323
|
-
改动写在会话日志里(追加一条 `permission/preset` 事件,带 checksum),成员下次加载/唤醒时按折叠语义生效
|
|
324
|
-
(最后一条 preset 事件为准)。已有事件不动,可删尾部帧回滚。
|
|
141
|
+
- [文件目录结构](docs/FILE-TREE.md) — 目录树与文件作用
|
|
142
|
+
- [版本列表](docs/VERSIONS.md) — 按 git 提交聚合的版本记录
|
|
143
|
+
- [函数列表](docs/FUNCTIONS.md) — lib/ 导出函数的名称/位置/签名
|
|
144
|
+
- [模型与权限切换机制](docs/模型与权限切换机制.MD) — 子代理切换的实现依据与内部机制
|
package/lib/apply.js
CHANGED
|
@@ -21,7 +21,8 @@ import { refreshTitleState } from './features/rename/title.js';
|
|
|
21
21
|
// 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
|
|
22
22
|
// applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
|
|
23
23
|
// 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
|
|
24
|
-
import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes
|
|
24
|
+
import { getMemberModelOverride, applyModelOverride, noteRequestModel, takeSwitchOutcomes,
|
|
25
|
+
adoptOfficialSelection, readOfficialPendingSelection, setMemberModelOverride } from './features/member-model/index.js';
|
|
25
26
|
// 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
|
|
26
27
|
import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
|
|
27
28
|
import { registerMemberModelTools } from './features/member-model/tools.js';
|
|
@@ -317,7 +318,25 @@ export async function apply(ctx, config = {}) {
|
|
|
317
318
|
// override 由 set_member_model 写入(domain 持久 + 内存缓存镜像),每轮都改 → 跨轮持久。
|
|
318
319
|
ctx.on("agent/request", async ({ agent }, next) => {
|
|
319
320
|
const resolved = await next();
|
|
320
|
-
const
|
|
321
|
+
const sessionId = agent?.session?.id;
|
|
322
|
+
let override = await getMemberModelOverride(ctx, sessionId);
|
|
323
|
+
// 官方界面切换同步:override 优先级高于官方链路,若用户在主会话界面用官方选择器改了模型,
|
|
324
|
+
// 不把新值同步进 override 就会出现「界面显示新模型、请求一直走旧模型」且无处可查
|
|
325
|
+
// (实测事故:批量写入的 override 把会话钉在已失效 provider 上,界面切换、改日志、重启全无效)。
|
|
326
|
+
// 做法是同步而不是清除:官方切换会写投影 pending,这里把它写回 override,
|
|
327
|
+
// 既让界面选择立刻生效,又保留 override 的跨轮持久能力(不会回落部署默认)。
|
|
328
|
+
try {
|
|
329
|
+
const pending = readOfficialPendingSelection(ctx, agent);
|
|
330
|
+
const adopt = adoptOfficialSelection(override, pending);
|
|
331
|
+
if (adopt) {
|
|
332
|
+
await setMemberModelOverride(ctx, sessionId, adopt.provider, adopt.model);
|
|
333
|
+
override = adopt;
|
|
334
|
+
log(ctx, `官方界面切换已同步到 override:${sessionId} → ${adopt.provider}/${adopt.model}`);
|
|
335
|
+
}
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// 同步失败绝不打断请求:退回用旧 override(行为与修复前一致)
|
|
338
|
+
log(ctx, `官方切换同步失败(不影响本次请求): ${String(error?.message ?? error)}`);
|
|
339
|
+
}
|
|
321
340
|
const applied = applyModelOverride(resolved, override);
|
|
322
341
|
// 切换查证(事件驱动):记录本次请求**真实使用**的 provider/model;若该会话有待查证项,
|
|
323
342
|
// 就在这里按事件判定结果并入队(切换本身不阻塞、不轮询 —— 只等事件到)。
|
|
@@ -13,7 +13,11 @@ export {
|
|
|
13
13
|
// 批量目标解析(空 / `*` / `all` = 全部成员,排除 Lead)与 Lead 定位
|
|
14
14
|
collectTeamMembers, isAllTarget, resolveTargets, resolveLeader,
|
|
15
15
|
} from "./locate.js";
|
|
16
|
-
export {
|
|
16
|
+
export {
|
|
17
|
+
getMemberModelOverride, setMemberModelOverride, applyModelOverride, resetMemberModelCacheForTest,
|
|
18
|
+
// 官方界面切换的同步:override 优先级高于官方链路,不同步就会出现「界面切了但请求不变」
|
|
19
|
+
adoptOfficialSelection, readOfficialPendingSelection,
|
|
20
|
+
} from "./override.js";
|
|
17
21
|
export { waitForIdle, appendSelectionEventToLog, switchAgentModel } from "./switch.js";
|
|
18
22
|
// 切换查证(事件驱动,注册 job 不阻塞):登记待查证项 → 每次请求记录实际模型 → 按事件判定 → 结果回报
|
|
19
23
|
export {
|
|
@@ -77,6 +77,56 @@ export function applyModelOverride(resolved, override) {
|
|
|
77
77
|
return { ...rest, provider: override.provider, model: override.model };
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* 判断是否要把「官方界面刚选的模型」同步进 override。
|
|
82
|
+
*
|
|
83
|
+
* 【为什么需要它】本插件的 override 是**每轮强制改写请求**的持久状态,优先级高于官方链路。
|
|
84
|
+
* 于是有个致命副作用:一旦写入,用户在主会话界面用官方模型选择器切换**完全无效** ——
|
|
85
|
+
* 界面显示新模型、实际请求走 override 里的旧模型,而且不报错、不提示、无清除入口
|
|
86
|
+
* (实测事故:批量「跟随主代理」把一批会话钉在某个已失效 provider 上,界面怎么切都不动,
|
|
87
|
+
* 改会话日志、重启宿主也都没用,因为 override 存在 domain 里且每轮覆盖)。
|
|
88
|
+
*
|
|
89
|
+
* 【做法:同步,而不是清除】官方切换会往 `modelSelection` 投影写 `pending`;
|
|
90
|
+
* 而插件在 hook 里用 override 改写了请求配置 → 与 pending 不匹配 → `consumeSelection` 不会
|
|
91
|
+
* 清空它,于是 pending 会一直保留。每轮把它与 override 比对,不一致就**把 override 更新成
|
|
92
|
+
* pending 的值**:官方界面选择立刻生效,同时仍享有 override 的「跨轮持久」能力(不会回落)。
|
|
93
|
+
*
|
|
94
|
+
* 只在**已存在 override** 的会话上同步:没有 override 的会话不干预,交给官方链路自己跑。
|
|
95
|
+
*
|
|
96
|
+
* @param {{provider: string, model: string} | null} override 当前生效的 override
|
|
97
|
+
* @param {{provider: string, model: string} | null} pending 官方投影里的待生效选择
|
|
98
|
+
* @returns {{provider: string, model: string} | null} 需要写入的新 override;null = 无需同步
|
|
99
|
+
*/
|
|
100
|
+
export function adoptOfficialSelection(override, pending) {
|
|
101
|
+
if (!override || !pending) return null;
|
|
102
|
+
if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
|
|
103
|
+
if (pending.provider === "" || pending.model === "") return null;
|
|
104
|
+
if (override.provider === pending.provider && override.model === pending.model) return null;
|
|
105
|
+
return { provider: pending.provider, model: pending.model };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 从会话投影里读官方「待生效」的模型选择(官方界面切换会写这里)。
|
|
110
|
+
*
|
|
111
|
+
* 投影不可用、字段缺失、调用抛错一律返回 null(宁可不同步,也不要在 hook 里抛异常打断请求)。
|
|
112
|
+
*
|
|
113
|
+
* @param {object} ctx 插件 context
|
|
114
|
+
* @param {object} agent 当前请求所属 agent
|
|
115
|
+
* @returns {{provider: string, model: string} | null}
|
|
116
|
+
*/
|
|
117
|
+
export function readOfficialPendingSelection(ctx, agent) {
|
|
118
|
+
try {
|
|
119
|
+
const projections = ctx?.sessionProjections ?? ctx?.get?.("sessionProjections");
|
|
120
|
+
const state = projections?.stateOf?.(agent?.session, "modelSelection");
|
|
121
|
+
const pending = state?.pending;
|
|
122
|
+
if (!pending || typeof pending !== "object") return null;
|
|
123
|
+
if (typeof pending.provider !== "string" || typeof pending.model !== "string") return null;
|
|
124
|
+
return { provider: pending.provider, model: pending.model };
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
80
130
|
/** 测试钩子:清空 override 缓存(单测在场景之间调用)。 */
|
|
81
131
|
export function resetMemberModelCacheForTest() {
|
|
82
132
|
memberModelCache.clear();
|
|
@@ -19,6 +19,8 @@ import { applyModelToMany, applyPermissionToMany, renderBatchReport } from "./ba
|
|
|
19
19
|
import { registerModelSwitchCommand } from "./command.js";
|
|
20
20
|
import { registerContextUsageTool } from "../context-usage/index.js";
|
|
21
21
|
import { registerMemoryRecallTool } from "../memory-recall/index.js";
|
|
22
|
+
// 成员轨迹读取(子代理只有最后一条回复会被自动发回主代理)
|
|
23
|
+
import { registerMemberTraceTool } from "../../sessions/member-trace.js";
|
|
22
24
|
import { registerSessionMessagingTools } from "../session-messaging/index.js";
|
|
23
25
|
import { registerPermSwitchCommand } from "./permission-command.js";
|
|
24
26
|
import { cloneMemberAs, renderCloneMemberResult } from "./clone-member.js";
|
|
@@ -312,6 +314,14 @@ async function describeSwitchSnapshot(ctx, sessionId, provider, model) {
|
|
|
312
314
|
} catch (error) {
|
|
313
315
|
log(ctx, `记忆取回工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
314
316
|
}
|
|
317
|
+
try {
|
|
318
|
+
// 成员轨迹:子代理只有最后一条回复会被自动发回主代理,中间的思考与工具调用靠这个工具读
|
|
319
|
+
if (registerMemberTraceTool(ctx, defineTool, tools)) {
|
|
320
|
+
log(ctx, "成员轨迹工具已注册 (read_member_trace)");
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
log(ctx, `成员轨迹工具注册失败(不影响其它工具): ${String(error?.message ?? error)}`);
|
|
324
|
+
}
|
|
315
325
|
log(ctx, "成员模型工具已注册 (list_models / set_member_model)");
|
|
316
326
|
// 把成员「换名」成新成员(新建 + 搬会话内容)。
|
|
317
327
|
// 宿主把成员名钉死为不可变身份字段:`team/member` 投影里 name/provider/context 一变就抛
|
package/lib/sessions/events.js
CHANGED
|
@@ -22,6 +22,8 @@ import { join } from "node:path";
|
|
|
22
22
|
import { StringDecoder } from "node:string_decoder";
|
|
23
23
|
|
|
24
24
|
import { streamDecodedFrames } from "../zstd-frames.js";
|
|
25
|
+
// 流式逐事件读取的公共底座(与成员轨迹共用,避免帧边界/坏行/解码器收尾各写一套)
|
|
26
|
+
import { streamSessionEvents } from "./log-stream.js";
|
|
25
27
|
import { findSessionLog } from "../session-log.js";
|
|
26
28
|
|
|
27
29
|
let interruptedTurnClosers = null;
|
|
@@ -276,15 +278,12 @@ export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
|
|
|
276
278
|
const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 3;
|
|
277
279
|
const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0 ? opts.maxCharsPerItem : DEFAULT_MAX_CHARS_PER_ITEM;
|
|
278
280
|
const buf = await readFile(file);
|
|
279
|
-
const decoder = new StringDecoder("utf8");
|
|
280
|
-
let pending = "";
|
|
281
281
|
const turns = [];
|
|
282
282
|
let current = null;
|
|
283
283
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
try { event = JSON.parse(line); } catch { return; }
|
|
284
|
+
// 流式逐事件读取走公共底座(log-stream.js):帧边界跨行、坏行跳过、解码器收尾这些细节
|
|
285
|
+
// 与「成员轨迹」共用同一份实现,避免两边各写一套后只在某类会话上少读一段。
|
|
286
|
+
const onEvent = (event) => {
|
|
288
287
|
const type = event?.type;
|
|
289
288
|
if (type === "turn/end") {
|
|
290
289
|
// 回合结束:此后到来的用户消息算新的一轮
|
|
@@ -312,17 +311,7 @@ export async function readRecentUserTurns(ctx, sessionId, opts = {}) {
|
|
|
312
311
|
}
|
|
313
312
|
};
|
|
314
313
|
|
|
315
|
-
await
|
|
316
|
-
pending += decoder.write(chunk);
|
|
317
|
-
let idx;
|
|
318
|
-
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
319
|
-
const line = pending.slice(0, idx);
|
|
320
|
-
pending = pending.slice(idx + 1);
|
|
321
|
-
consumeLine(line);
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
pending += decoder.end();
|
|
325
|
-
consumeLine(pending);
|
|
314
|
+
await streamSessionEvents(buf, onEvent);
|
|
326
315
|
|
|
327
316
|
return turns.map((t) => ({ time: t.time, userText: t.userText, agentText: t.agentText }));
|
|
328
317
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// dsh-session-conductor — 会话日志的流式逐事件读取(公共底座)
|
|
2
|
+
//
|
|
3
|
+
// 【为什么单独抽出来】「按 zstd 帧流式解码 + 行缓冲 + 逐行 JSON 解析 + 坏行跳过」这段
|
|
4
|
+
// 在记忆取回(readRecentUserTurns)与成员轨迹(readMemberTrace)里是**逐字重复**的。
|
|
5
|
+
// 两处各写一份的代价不只是重复:帧边界跨行、坏行容错、解码器收尾这些细节只要一边改错,
|
|
6
|
+
// 就会表现为「某类会话读出来少了一段」,而且很难察觉。抽成一处后,两个消费方只保留
|
|
7
|
+
// 「按 type 怎么组织」的差异。
|
|
8
|
+
//
|
|
9
|
+
// 【为什么流式】会话日志可达数十 MB、上万事件。全量 decode 再 split 会一次性占用
|
|
10
|
+
// 数倍于文件大小的内存;流式逐帧 + 边读边丢,内存只与消费方保留的状态有关。
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { StringDecoder } from "node:string_decoder";
|
|
13
|
+
import { streamDecodedFrames } from "../zstd-frames.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 逐事件流式读取一个会话日志。
|
|
17
|
+
*
|
|
18
|
+
* 回调收到的顺序与日志顺序一致。单个坏行(截断、半帧、非 JSON)会被跳过并计数,
|
|
19
|
+
* **不中断**整次读取 —— 损坏会话仍能读出可用前缀,这对修复类功能是刚需。
|
|
20
|
+
*
|
|
21
|
+
* @param {Buffer} buf 已读入内存的日志内容(调用方决定读哪个文件;这样也便于单测直接喂 Buffer)
|
|
22
|
+
* @param {(event: object) => void} onEvent 每解析出一个事件调用一次
|
|
23
|
+
* @returns {Promise<{events: number, bad: number}>} events = 成功解析数;bad = 跳过的坏行数
|
|
24
|
+
*/
|
|
25
|
+
export async function streamSessionEvents(buf, onEvent) {
|
|
26
|
+
const decoder = new StringDecoder("utf8");
|
|
27
|
+
let pending = "";
|
|
28
|
+
let events = 0;
|
|
29
|
+
let bad = 0;
|
|
30
|
+
|
|
31
|
+
const consumeLine = (line) => {
|
|
32
|
+
if (!line || !line.trim()) return;
|
|
33
|
+
let event;
|
|
34
|
+
try {
|
|
35
|
+
event = JSON.parse(line);
|
|
36
|
+
} catch {
|
|
37
|
+
bad += 1;
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
events += 1;
|
|
41
|
+
onEvent(event);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
await streamDecodedFrames(buf, (chunk) => {
|
|
45
|
+
pending += decoder.write(chunk);
|
|
46
|
+
let idx;
|
|
47
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
48
|
+
consumeLine(pending.slice(0, idx));
|
|
49
|
+
pending = pending.slice(idx + 1);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
// 解码器收尾:多字节字符可能跨帧被切开,必须 flush 后再处理残留行
|
|
53
|
+
pending += decoder.end();
|
|
54
|
+
consumeLine(pending);
|
|
55
|
+
|
|
56
|
+
return { events, bad };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 读文件并逐事件回调(对 streamSessionEvents 的薄封装,省掉调用方自己 readFile)。
|
|
61
|
+
*
|
|
62
|
+
* @param {string} file 会话日志绝对路径
|
|
63
|
+
* @param {(event: object) => void} onEvent 事件回调
|
|
64
|
+
* @returns {Promise<{events: number, bad: number}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function streamSessionFile(file, onEvent) {
|
|
67
|
+
return await streamSessionEvents(await readFile(file), onEvent);
|
|
68
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// dsh-session-conductor — 读取成员(子代理)会话的完整执行轨迹
|
|
2
|
+
//
|
|
3
|
+
// 【这个文件解决什么】子代理只有「最后一条回复」会被宿主自动发回主代理,中间的思考与工具调用
|
|
4
|
+
// 主代理完全看不到。一个执行型子代理单轮可以产生上百条 assistant 消息(实测某会话单轮 115 条、
|
|
5
|
+
// 全会话 1382 条、带 reasoning 的 939 条),主代理只拿到一句结论,等于丢了全部过程。
|
|
6
|
+
//
|
|
7
|
+
// 【为什么不复用 recall_memory】那套是「记忆取回」语义,有三条硬限制让它读不了轨迹:
|
|
8
|
+
// ① 每轮只取第一条 assistant 消息(`if (!current || current.agentText) return`);
|
|
9
|
+
// ② reasoning 与 text 二选一(`pick("reasoning") || pick("text")`);
|
|
10
|
+
// ③ 完全不读 tool/call 与 tool/result。
|
|
11
|
+
// 本模块按 turn → step → 事件三层组织,把思考、正文、工具调用与结果一并取出。
|
|
12
|
+
//
|
|
13
|
+
// 【为什么按 zstd 帧流式读】单个会话日志可达数十 MB、上万事件;全量解码会一次性吃掉大块内存。
|
|
14
|
+
// 流式逐帧解析 + 边解析边丢弃旧轮次,内存只与「保留的轮数 × 每轮事件数」有关。
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { findSessionLogById, sessionsRootOf } from "./events.js";
|
|
17
|
+
// 流式逐事件读取的公共底座(与记忆取回共用;本模块只负责「按 turn/step 怎么组织」)
|
|
18
|
+
import { streamSessionEvents } from "./log-stream.js";
|
|
19
|
+
|
|
20
|
+
/** 单条文本默认截断长度(summary 模式下更短,见 SUMMARY_CLIP)。 */
|
|
21
|
+
const DEFAULT_MAX_ITEM = 800;
|
|
22
|
+
/** summary 模式下单条压成一行时的截断长度。 */
|
|
23
|
+
const SUMMARY_CLIP = 160;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 把一段文本压成单行摘要(summary 模式用)。
|
|
27
|
+
* @param {string} text 原始文本
|
|
28
|
+
* @param {number} max 最大长度
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function oneLine(text, max = SUMMARY_CLIP) {
|
|
32
|
+
const flat = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
33
|
+
if (max > 0 && flat.length > max) return `${flat.slice(0, max)}…`;
|
|
34
|
+
return flat;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 从 assistant/message 的 content 里分别取 reasoning 与正文。
|
|
39
|
+
*
|
|
40
|
+
* 与 recall 的 `firstAgentTextOf` 不同:这里**两者都要**(思考与正文是不同信息,
|
|
41
|
+
* 只留一个会让「想了什么」或「说了什么」缺一半)。
|
|
42
|
+
*
|
|
43
|
+
* @param {object} data 事件 data
|
|
44
|
+
* @returns {{reasoning: string, text: string}}
|
|
45
|
+
*/
|
|
46
|
+
function splitAssistantContent(data) {
|
|
47
|
+
const content = data?.message?.content;
|
|
48
|
+
if (!Array.isArray(content)) return { reasoning: "", text: "" };
|
|
49
|
+
const join = (kind) => content
|
|
50
|
+
.filter((p) => p?.type === kind && typeof p.text === "string" && p.text.trim())
|
|
51
|
+
.map((p) => p.text)
|
|
52
|
+
.join("\n");
|
|
53
|
+
return { reasoning: join("reasoning"), text: join("text") };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 把 tool/call 的参数压成一行摘要。
|
|
58
|
+
*
|
|
59
|
+
* 工具参数里常有整段文件内容或长命令;全量输出会把轨迹淹没。这里只保留键名与短值:
|
|
60
|
+
* 字符串值超过 80 字符即截断,对象/数组只报类型与长度。
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} args 工具参数
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function briefArgs(args) {
|
|
66
|
+
if (args === null || args === undefined) return "";
|
|
67
|
+
if (typeof args !== "object") return oneLine(String(args), 80);
|
|
68
|
+
const parts = [];
|
|
69
|
+
for (const [key, value] of Object.entries(args)) {
|
|
70
|
+
if (typeof value === "string") parts.push(`${key}=${value.length > 80 ? `${value.slice(0, 80)}…` : value}`);
|
|
71
|
+
else if (Array.isArray(value)) parts.push(`${key}[${value.length}]`);
|
|
72
|
+
else if (value !== null && typeof value === "object") parts.push(`${key}{…}`);
|
|
73
|
+
else parts.push(`${key}=${String(value)}`);
|
|
74
|
+
if (parts.join(", ").length > 200) break;
|
|
75
|
+
}
|
|
76
|
+
return oneLine(parts.join(", "), 200);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把 tool/result 压成一行摘要:成功/失败 + 内容长度或错误信息。
|
|
81
|
+
* @param {object} data 事件 data
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function briefResult(data) {
|
|
85
|
+
const ok = data?.ok;
|
|
86
|
+
const error = data?.error;
|
|
87
|
+
if (error !== undefined) {
|
|
88
|
+
const msg = typeof error === "string" ? error : JSON.stringify(error);
|
|
89
|
+
return `失败:${oneLine(msg, 120)}`;
|
|
90
|
+
}
|
|
91
|
+
const content = data?.message?.content ?? data?.content;
|
|
92
|
+
let size = 0;
|
|
93
|
+
if (Array.isArray(content)) size = content.reduce((n, p) => n + (typeof p?.text === "string" ? p.text.length : 0), 0);
|
|
94
|
+
else if (typeof content === "string") size = content.length;
|
|
95
|
+
return `${ok === false ? "失败" : "成功"}(${size} 字符)`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 读取一个会话最近 N 轮的完整轨迹。
|
|
100
|
+
*
|
|
101
|
+
* @param {object} ctx 插件 context(保留给调用方传递,本函数不依赖具体服务)
|
|
102
|
+
* @param {string} sessionId 目标会话 id
|
|
103
|
+
* @param {{rounds?: number, maxCharsPerItem?: number, root?: string}} [opts]
|
|
104
|
+
* rounds 取最近几轮(默认 1);maxCharsPerItem 单条截断(默认 800)
|
|
105
|
+
* @returns {Promise<{turns: Array<object>} | {error: string}>}
|
|
106
|
+
*/
|
|
107
|
+
export async function readMemberTrace(ctx, sessionId, opts = {}) {
|
|
108
|
+
const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
|
|
109
|
+
if (!file) return { error: `找不到会话日志(sessionId=${sessionId})` };
|
|
110
|
+
const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 1;
|
|
111
|
+
const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0
|
|
112
|
+
? opts.maxCharsPerItem
|
|
113
|
+
: DEFAULT_MAX_ITEM;
|
|
114
|
+
|
|
115
|
+
const buf = await readFile(file);
|
|
116
|
+
const turns = [];
|
|
117
|
+
let turn = null;
|
|
118
|
+
let step = null;
|
|
119
|
+
|
|
120
|
+
/** 保证当前轮与当前 step 存在(事件可能不按期望顺序出现,缺失时兜底建一个)。 */
|
|
121
|
+
const ensureTurn = () => {
|
|
122
|
+
if (turn === null) {
|
|
123
|
+
turn = { index: 0, task: "", steps: [] };
|
|
124
|
+
turns.push(turn);
|
|
125
|
+
}
|
|
126
|
+
return turn;
|
|
127
|
+
};
|
|
128
|
+
const ensureStep = () => {
|
|
129
|
+
const t = ensureTurn();
|
|
130
|
+
if (step === null) {
|
|
131
|
+
step = { step: 1, items: [] };
|
|
132
|
+
t.steps.push(step);
|
|
133
|
+
}
|
|
134
|
+
return step;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const onEvent = (event) => {
|
|
138
|
+
const type = event?.type;
|
|
139
|
+
|
|
140
|
+
if (type === "turn/start") {
|
|
141
|
+
// 新回合。注意**不要**在这里用 turns.shift() 裁剪:shift 会让下面 turn/step 两个引用
|
|
142
|
+
// 指向已被移出数组的对象,之后的 assistant 消息会挂到那个孤儿对象上(表现为
|
|
143
|
+
// 「某一轮的思考内容跑到了另一轮」)。改成读完之后统一 slice(-rounds)。
|
|
144
|
+
turn = { index: (turns.length > 0 ? turns[turns.length - 1].index + 1 : 1), task: "", steps: [] };
|
|
145
|
+
turns.push(turn);
|
|
146
|
+
step = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (type === "step/start") {
|
|
150
|
+
const t = ensureTurn();
|
|
151
|
+
step = { step: typeof event?.data?.step === "number" ? event.data.step : t.steps.length + 1, items: [] };
|
|
152
|
+
t.steps.push(step);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (type === "user/message") {
|
|
156
|
+
// 用**白名单**而不是黑名单:user/message 的 source.kind 有十余种,除了真实任务以外
|
|
157
|
+
// 全是宿主或插件的注入(实测同一份日志中:skill-catalog×5、runtime-context×7、time-context×62、
|
|
158
|
+
// tool-jobs×17、plugin:dsh-session-conductor×131、plugin:dsh-git-push×61 等)。
|
|
159
|
+
// 黑名单写法只要漏一种,任务文本就会被几百 KB 的注入内容淹没。
|
|
160
|
+
// 真实任务只有两种来源:user(用户直接说)与 agent-message(主代理派给子代理的团队消息)。
|
|
161
|
+
const kind = event?.data?.source?.kind;
|
|
162
|
+
if (kind !== "user" && kind !== "agent-message") return;
|
|
163
|
+
const content = event?.data?.content;
|
|
164
|
+
const text = Array.isArray(content)
|
|
165
|
+
? content.filter((p) => typeof p?.text === "string").map((p) => p.text).join("\n")
|
|
166
|
+
: "";
|
|
167
|
+
if (!text.trim()) return;
|
|
168
|
+
const t = ensureTurn();
|
|
169
|
+
t.task = t.task ? `${t.task}\n\n${oneLine(text, perItem)}` : oneLine(text, perItem);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (type === "assistant/message") {
|
|
173
|
+
const { reasoning, text } = splitAssistantContent(event?.data);
|
|
174
|
+
if (!reasoning && !text) return;
|
|
175
|
+
const s = ensureStep();
|
|
176
|
+
if (reasoning) s.items.push({ kind: "reasoning", text: oneLine(reasoning, perItem) });
|
|
177
|
+
if (text) s.items.push({ kind: "text", text: oneLine(text, perItem) });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (type === "tool/call") {
|
|
181
|
+
const s = ensureStep();
|
|
182
|
+
s.items.push({
|
|
183
|
+
kind: "tool",
|
|
184
|
+
name: String(event?.data?.name ?? "?"),
|
|
185
|
+
args: briefArgs(event?.data?.arguments),
|
|
186
|
+
result: "",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (type === "tool/result") {
|
|
191
|
+
// 结果挂到同一 step 里最近一次尚未填结果的 tool 记录上
|
|
192
|
+
const t = ensureTurn();
|
|
193
|
+
for (let i = t.steps.length - 1; i >= 0; i -= 1) {
|
|
194
|
+
const items = t.steps[i].items;
|
|
195
|
+
for (let j = items.length - 1; j >= 0; j -= 1) {
|
|
196
|
+
if (items[j].kind === "tool" && !items[j].result) {
|
|
197
|
+
items[j].result = briefResult(event?.data);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await streamSessionEvents(buf, onEvent);
|
|
207
|
+
|
|
208
|
+
// 丢掉没有任务的空轮(日志里可能存在只有 turn 边界、没有内容的情况),再取最近 rounds 轮。
|
|
209
|
+
// 顺序很重要:先过滤空轮再裁剪,否则 rounds=2 时可能被一个空轮占掉一个名额。
|
|
210
|
+
const useful = turns.filter((t) => t.task || t.steps.some((s) => s.items.length > 0));
|
|
211
|
+
return { turns: useful.slice(-rounds), file };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 把轨迹格式化成给 AI 读的文本。
|
|
216
|
+
*
|
|
217
|
+
* @param {Array<object>} turns readMemberTrace 的 turns
|
|
218
|
+
* @param {{sessionLabel?: string, detail?: "summary"|"full", include?: string[]}} [opts]
|
|
219
|
+
* detail=summary 时每条压成一行;full 时保留 readMemberTrace 给出的文本长度
|
|
220
|
+
* @param {Set<string>} [kinds] 要包含的内容种类(reasoning / text / tool)
|
|
221
|
+
* @returns {string}
|
|
222
|
+
*/
|
|
223
|
+
export function formatMemberTrace(turns, opts = {}, kinds = new Set(["reasoning", "text", "tool"])) {
|
|
224
|
+
const label = opts.sessionLabel ?? "目标会话";
|
|
225
|
+
const full = opts.detail === "full";
|
|
226
|
+
const lines = [];
|
|
227
|
+
lines.push(`【${label}】最近 ${turns.length} 轮执行轨迹`);
|
|
228
|
+
for (const t of turns) {
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push(`── 第 ${t.index} 轮${t.task ? `|任务:${full ? t.task : oneLine(t.task, 200)}` : ""}`);
|
|
231
|
+
for (const s of t.steps) {
|
|
232
|
+
for (const item of s.items) {
|
|
233
|
+
if (item.kind === "reasoning" && !kinds.has("reasoning")) continue;
|
|
234
|
+
if (item.kind === "text" && !kinds.has("text")) continue;
|
|
235
|
+
if (item.kind === "tool" && !kinds.has("tool")) continue;
|
|
236
|
+
if (item.kind === "reasoning") lines.push(` [step ${s.step}] 思考:${item.text}`);
|
|
237
|
+
else if (item.kind === "text") lines.push(` [step ${s.step}] 正文:${item.text}`);
|
|
238
|
+
else {
|
|
239
|
+
const res = item.result ? ` → ${item.result}` : "";
|
|
240
|
+
lines.push(` [step ${s.step}] 调用 ${item.name}(${item.args})${res}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return lines.join("\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** 工具输出格式:一律纯文本(与其它工具一致,交给宿主渲染成一段 message)。 */
|
|
249
|
+
const TEXT_OUTPUT = {
|
|
250
|
+
schema: { type: "string" },
|
|
251
|
+
render(args, value) {
|
|
252
|
+
return [{ type: "text", text: String(value) }];
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 注册 `read_member_trace` AI 工具。
|
|
258
|
+
*
|
|
259
|
+
* 【解决什么】子代理只有「最后一条回复」会被宿主自动发回主代理;执行型子代理单轮可产生上百条
|
|
260
|
+
* assistant 消息(思考 + 工具调用 + 逐步结论),主代理只看得到最后一句,等于丢失全部过程。
|
|
261
|
+
* 本工具让主代理按需读取指定会话(通常是子代理)的完整轨迹。
|
|
262
|
+
*
|
|
263
|
+
* @param {object} ctx 插件 context
|
|
264
|
+
* @param {Function} defineTool 宿主 defineTool
|
|
265
|
+
* @param {object} tools 宿主 tools 服务
|
|
266
|
+
* @param {{cfg?: object, resolveSession?: Function}} [deps] 测试注入点
|
|
267
|
+
* @returns {boolean} 是否成功注册
|
|
268
|
+
*/
|
|
269
|
+
export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
|
|
270
|
+
if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
|
|
271
|
+
const resolve = deps.resolveSession;
|
|
272
|
+
const maxRounds = 20;
|
|
273
|
+
|
|
274
|
+
tools.register(defineTool({
|
|
275
|
+
name: "read_member_trace",
|
|
276
|
+
description:
|
|
277
|
+
"读取成员(子代理)或其他会话的**完整执行轨迹**:每轮的思考、正文、工具调用与结果摘要。"
|
|
278
|
+
+ "何时用:子代理只把最后一条回复发回来,想看它中间想了什么/调用了什么/为什么这么结论时;"
|
|
279
|
+
+ "或想核对某个成员到底做了哪些操作。"
|
|
280
|
+
+ `参数 rounds = 取最近几轮(默认 1,上限 ${maxRounds};一轮可能上百条,别一次要太多)。`
|
|
281
|
+
+ "detail=summary(默认,每条压成一行)/ full(保留较长文本)。"
|
|
282
|
+
+ "include 可裁掉 reasoning / text / tool 中不需要的部分。只读,不改任何会话。",
|
|
283
|
+
parameters: {
|
|
284
|
+
session: { type: "string", description: "目标会话:成员名 / sessionId / id 前缀 / 标题子串;空 = 自己" },
|
|
285
|
+
rounds: { type: "number", description: `取最近几轮(默认 1,上限 ${maxRounds})` },
|
|
286
|
+
detail: { type: "string", description: "summary(默认,每条一行)或 full(较长文本)" },
|
|
287
|
+
include: { type: "string", description: "只看某些内容:传逗号分隔的 reasoning,text,tool;默认全含" },
|
|
288
|
+
maxCharsPerItem: { type: "number", description: "单条截断字符数(默认 800)" },
|
|
289
|
+
},
|
|
290
|
+
output: TEXT_OUTPUT,
|
|
291
|
+
async execute(args, exec) {
|
|
292
|
+
const selfId = exec?.agent?.session?.id;
|
|
293
|
+
if (!selfId) return "拿不到当前会话 id,无法读取轨迹。";
|
|
294
|
+
|
|
295
|
+
// 目标解析:与记忆取回共用同一套(成员名 → id / 前缀 / 标题;命中多个给候选,不猜)
|
|
296
|
+
const want = typeof args?.session === "string" ? args.session.trim() : "";
|
|
297
|
+
const resolveFn = resolve ?? (await import("../features/memory-recall/index.js")).resolveSessionTarget;
|
|
298
|
+
const rounds = Math.min(
|
|
299
|
+
Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : 1,
|
|
300
|
+
maxRounds,
|
|
301
|
+
);
|
|
302
|
+
const detail = args?.detail === "full" ? "full" : "summary";
|
|
303
|
+
const kinds = new Set(
|
|
304
|
+
(typeof args?.include === "string" && args.include.trim()
|
|
305
|
+
? args.include.split(",").map((s) => s.trim()).filter(Boolean)
|
|
306
|
+
: ["reasoning", "text", "tool"]),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
let targetId = selfId;
|
|
310
|
+
if (want && want !== selfId) {
|
|
311
|
+
const hit = await resolveFn(ctx, want, selfId);
|
|
312
|
+
if (hit?.candidates) {
|
|
313
|
+
return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
|
|
314
|
+
+ hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
|
|
315
|
+
}
|
|
316
|
+
if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
|
|
317
|
+
targetId = hit.id;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const read = await readMemberTrace(ctx, targetId, {
|
|
321
|
+
rounds,
|
|
322
|
+
maxCharsPerItem: Number.isSafeInteger(args?.maxCharsPerItem) && args.maxCharsPerItem > 0
|
|
323
|
+
? args.maxCharsPerItem
|
|
324
|
+
: undefined,
|
|
325
|
+
});
|
|
326
|
+
if (read.error) return `读取轨迹失败:${read.error}`;
|
|
327
|
+
if (!read.turns || read.turns.length === 0) {
|
|
328
|
+
return `该会话最近 ${rounds} 轮没有可读内容(可能还没跑过回合,或日志里只有注入事件)。`;
|
|
329
|
+
}
|
|
330
|
+
const text = formatMemberTrace(read.turns, {
|
|
331
|
+
sessionLabel: targetId === selfId ? "自身" : `目标 ${targetId}`,
|
|
332
|
+
detail,
|
|
333
|
+
}, kinds);
|
|
334
|
+
return text || "没有符合 include 过滤条件的内容。";
|
|
335
|
+
},
|
|
336
|
+
}));
|
|
337
|
+
return true;
|
|
338
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-conductor",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.19",
|
|
4
4
|
"description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|