dsh-channel-memory 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PERMISSIONS.md +46 -0
- package/README.md +114 -2
- package/cordis.patch.yml +22 -0
- package/lib/index.js +344 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yuncan050115
|
|
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/PERMISSIONS.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# PERMISSIONS — dsh-channel-memory
|
|
2
|
+
|
|
3
|
+
本文件声明插件运行时实际使用的权限面。
|
|
4
|
+
|
|
5
|
+
## 总览(运行时行为)
|
|
6
|
+
|
|
7
|
+
- **文件系统**:只在两个目录下读写,路径由配置决定,默认都在 DSH home 内:
|
|
8
|
+
- `~/.dsh/channel-memory/`:`memory.json`(共享记忆)、`digest.json`(自动流水)。
|
|
9
|
+
写入一律「临时文件 + `rename` 原子替换」,并带 sha256 校验和。
|
|
10
|
+
- `~/.dsh/channel-workspace/`:专属工作区目录,仅在不存在时 `mkdir`。
|
|
11
|
+
插件**不读写**该目录内的任何业务文件,它只是宿主用来归属会话的工作根。
|
|
12
|
+
- **宿主服务(只读调用 + 显式挂载)**:通过 `ctx.inject(['workspaceRegistry','sessions'])`
|
|
13
|
+
与 `ctx.inject(['systemPrompt'])` 获取,不使用未声明的服务。
|
|
14
|
+
- `workspaceRegistry.resolveByPath / create / get`
|
|
15
|
+
- `Workspace.attachSession(sessionId)` —— 只在宿主校验通过时成功;
|
|
16
|
+
插件不绕过、不重试、不强改归属关系。
|
|
17
|
+
- `systemPrompt.section(...)` —— 追加一个自有分节,不替换他人分节、不使用
|
|
18
|
+
`complete: true`。
|
|
19
|
+
- **事件监听**:`session/created`、`session/event`(只取 `turn/end` 一类记录)。
|
|
20
|
+
回调全程 `try/catch`,任何异常都不影响会话本身。
|
|
21
|
+
- **网络**:❌ 不发起任何网络请求。
|
|
22
|
+
- **进程/子进程**:❌ 不 spawn 任何子进程。
|
|
23
|
+
- **凭据**:❌ 不读取 `.credentials.yaml`、不读取任何 `*_API_KEY` / `*_ACCOUNT_*`,
|
|
24
|
+
不接触 DSH 凭据服务。
|
|
25
|
+
|
|
26
|
+
## 明确不做
|
|
27
|
+
|
|
28
|
+
- ❌ 不读取用户业务文件、项目文件、会话正文(只记 `turn/end` 的元信息与条数)。
|
|
29
|
+
- ❌ 不写入工作区以外的路径;配置里给了越界路径时由操作者自己负责,插件不做二次扩散。
|
|
30
|
+
- ❌ 不修改其它插件的配置或提示词分节。
|
|
31
|
+
- ❌ 无 npm 生命周期脚本(`install` / `postinstall` 等一律没有)。
|
|
32
|
+
- ❌ 不上传、不同步、不外发任何记忆内容;共享仅发生在同一台机器的同一份 JSON 上。
|
|
33
|
+
|
|
34
|
+
## 数据可见性提示
|
|
35
|
+
|
|
36
|
+
`memory.json` 是**明文**存放在用户目录下的。任何能读该目录的进程都能读到其中的内容,
|
|
37
|
+
因此不要把密码、令牌、私钥等写进记忆——工具描述里也明确只用于事实/偏好/任务口径。
|
|
38
|
+
|
|
39
|
+
## 依赖
|
|
40
|
+
|
|
41
|
+
| 依赖 | 用途 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `@deepseek-ai/dsh-tools`(宿主提供) | `defineTool` 注册模型工具 |
|
|
44
|
+
| `@deepseek-ai/cordis`(peer) | 插件框架类型 |
|
|
45
|
+
|
|
46
|
+
无第三方运行时依赖,仅使用 `node:fs` / `node:os` / `node:path` / `node:crypto`。
|
package/README.md
CHANGED
|
@@ -1,3 +1,115 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-channel-memory
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 插件:**手机通道专属工作区** + **跨会话互通记忆**。
|
|
4
|
+
|
|
5
|
+
零运行时依赖(只用 node 内置模块 + 宿主自带的 `@deepseek-ai/dsh-tools`),
|
|
6
|
+
兼容 DSH `0.2.1-rc.5` / cordis `4.0.5`。
|
|
7
|
+
|
|
8
|
+
## 解决什么
|
|
9
|
+
|
|
10
|
+
用 wxbridge 这类手机通道时,实测到两个问题:
|
|
11
|
+
|
|
12
|
+
1. **会话散落**:每从微信发一句话,桥就可能新起一条 DSH 会话,全落在默认工作区里。
|
|
13
|
+
桌面侧栏被"手机会话"淹没,也没有一个地方能把它们归档、回看。
|
|
14
|
+
2. **记忆不通**:换一条会话(或桥重启)就不知道之前交代过什么。用户只能反复重述
|
|
15
|
+
身份资料、求职口径、偏好——而这些本该是一次交代、长期复用。
|
|
16
|
+
|
|
17
|
+
## 做法
|
|
18
|
+
|
|
19
|
+
- **专属工作区**:`~/.dsh/channel-workspace`(可配)。用宿主的
|
|
20
|
+
`workspaceRegistry.resolveByPath()` 先查,缺失才 `create()`。
|
|
21
|
+
- **归属判定不自己猜**:不同宿主版本暴露会话 cwd 的方式不一样,猜错就会静默跳过一切。
|
|
22
|
+
这里改用宿主自己的不变量 —— `Workspace.attachSession()` 会校验会话 header 的 cwd
|
|
23
|
+
是否等于工作区路径,不等就抛。所以「挂载成功」本身就是权威的归属证据,
|
|
24
|
+
普通桌面会话会在 `catch` 里被安静跳过,插件绝不强行挂载绕过校验。
|
|
25
|
+
- **共享记忆**:`~/.dsh/channel-memory/memory.json`,原子 rename + sha256 校验和。
|
|
26
|
+
校验和不符(文件被截断/外部改坏)时退回空库,而不是拿脏数据当记忆用。
|
|
27
|
+
- **自动流水**:监听 `session/event` 的 `turn/end`,只给已确认归属的通道会话记一条,
|
|
28
|
+
用于「手机侧这段时间聊过什么」的回溯。
|
|
29
|
+
- **提示词注入**:`systemPrompt.section({ name, order, text })`,`text` 传**函数**
|
|
30
|
+
(宿主按 `typeof section.text === 'function' ? section.text(context)` 求值),
|
|
31
|
+
所以写入记忆后下一轮组装自动生效,不需要手动 refresh。只追加、不替换人格。
|
|
32
|
+
|
|
33
|
+
## 模型工具
|
|
34
|
+
|
|
35
|
+
| 工具 | 用途 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `memory_write` | 记一条跨会话共享记忆(`kind`/`tags`/`pinned`) |
|
|
38
|
+
| `memory_search` | 子串检索正文/标签/类别 |
|
|
39
|
+
| `memory_list` | 列出最近 20 条,带 id 便于删除 |
|
|
40
|
+
| `memory_forget` | 按 id 删除 |
|
|
41
|
+
| `channel_workspace_info` | 工作区路径/id/已归属会话数/记忆与流水条数 |
|
|
42
|
+
|
|
43
|
+
`pinned: true` 的记忆常驻提示词索引,其余按 `indexLimit`(默认 12)取最近若干条。
|
|
44
|
+
|
|
45
|
+
## 安装
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd %USERPROFILE%\.dsh\profiles\desktop
|
|
49
|
+
node <DSH>\resources\runtime\..\..\resources\app.asar\dsh\... # 见下
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
推荐用 DSH 插件管理器,或直接:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pnpm add dsh-channel-memory
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
然后把 bundle 名加进 `package.json` 的 `dsh.profile.bundles`:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
"dsh": { "profile": { "bundles": [ ..., "dsh-channel-memory" ] } }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
重启 DSH 生效。校验(不启动 GUI,只做组合干跑):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
set ELECTRON_RUN_AS_NODE=1
|
|
68
|
+
"DeepSeek Harness.exe" --expose-internals <profile>\..\..\..\app.asar\dsh\node_modules\@deepseek-ai\dsh\lib\bin.js ^
|
|
69
|
+
--profile desktop --dump-config
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 配置
|
|
73
|
+
|
|
74
|
+
`cordis.patch.yml` 里 `channel-memory` 行的 `config` 全部可选,注释掉了默认值:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
- insert:
|
|
78
|
+
- id: channel-memory
|
|
79
|
+
name: 'dsh-channel-memory'
|
|
80
|
+
config:
|
|
81
|
+
workspacePath: 'C:/Users/you/.dsh/channel-workspace'
|
|
82
|
+
workspaceTitle: '手机通道'
|
|
83
|
+
dataDir: 'C:/Users/you/.dsh/channel-memory'
|
|
84
|
+
injectIndex: true
|
|
85
|
+
indexLimit: 12
|
|
86
|
+
autoDigest: true
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 与手机通道配合
|
|
90
|
+
|
|
91
|
+
要让微信发起的会话真的落进这个工作区,需要**桥侧的会话 cwd 指向通道工作区**。
|
|
92
|
+
wxbridge 支持在 `~/.dsh/wxbridge/config.json` 里配 `cwd`(宿主半 `pick('cwd')` →
|
|
93
|
+
keeper → 桥的 `--cwd`),例如:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{ "cwd": "C:/Users/wujid/.dsh/channel-workspace" }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
配完重启桥即可。不配也不会出错——插件只是没有会话可归属,记忆与工具照常工作。
|
|
100
|
+
|
|
101
|
+
## 验证
|
|
102
|
+
|
|
103
|
+
`plugin/contract_test.mjs` 用**宿主自己的 `defineTool`** 跑契约测试(不是 stub):
|
|
104
|
+
value-schema DSL 违规会当场抛。夹具强制宿主不变量(cwd 不符则 `attachSession` 抛),
|
|
105
|
+
因此「外来会话被拒绝挂载」是被真实验证的,而不是被假 object 允许的。
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
RESULT: PASS (5 个工具注册、1 个提示词分节、外来会话未被挂载、post-apply 写入反映进索引)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
权限面见 `PERMISSIONS.md`。
|
|
112
|
+
|
|
113
|
+
## License
|
|
114
|
+
|
|
115
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# dsh-channel-memory — installable DSH profile bundle layer.
|
|
2
|
+
#
|
|
3
|
+
# The loader reads this file as `dsh.bundle.patch`, so it must exist even when
|
|
4
|
+
# the plugin needs no config: an insert entry is what actually mounts the bundle
|
|
5
|
+
# into the composed profile tree. `name` is the full npm package name — the
|
|
6
|
+
# client-modules registry resolves the browser bundle by the loader entry name,
|
|
7
|
+
# and the host resolves the package from the profile's node_modules.
|
|
8
|
+
#
|
|
9
|
+
# Every key below is optional; the plugin supplies its own defaults, so an
|
|
10
|
+
# operator can uncomment only what they want to change. Paths are under the DSH
|
|
11
|
+
# home (~/.dsh) by default and honour $DSH_HOME automatically.
|
|
12
|
+
- insert:
|
|
13
|
+
- id: channel-memory
|
|
14
|
+
name: 'dsh-channel-memory'
|
|
15
|
+
config:
|
|
16
|
+
{}
|
|
17
|
+
# workspacePath: 'C:/Users/you/.dsh/channel-workspace' # 手机通道专属工作区目录
|
|
18
|
+
# workspaceTitle: '手机通道' # 侧栏显示名
|
|
19
|
+
# dataDir: 'C:/Users/you/.dsh/channel-memory' # 记忆与流水的存放目录
|
|
20
|
+
# injectIndex: true # 是否把记忆索引追加进系统提示词(默认 true)
|
|
21
|
+
# indexLimit: 12 # 索引里最多展示多少条最近记忆
|
|
22
|
+
# autoDigest: true # 是否为通道会话的每轮结束记一条流水(默认 true)
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-channel-memory — 手机通道专属工作区 + 跨会话互通记忆
|
|
3
|
+
*
|
|
4
|
+
* 解决两个实测到的问题:
|
|
5
|
+
* 1. 每次从微信发起对话都会新起一条 DSH 会话,散落在默认工作区里,桌面侧栏
|
|
6
|
+
* 被"手机会话"淹没,也无处归档。
|
|
7
|
+
* 2. 手机侧会话之间没有共享记忆:换会话或桥重启后不知道之前交代过什么。
|
|
8
|
+
*
|
|
9
|
+
* 形态说明(都按宿主 0.2.1-rc.5 的实际契约来,不凭直觉):
|
|
10
|
+
* - 服务依赖用 ctx.inject([...names], cb) 声明;根 ctx 上直接取不到
|
|
11
|
+
* workspaceRegistry / systemPrompt,所以全部在注入回调里完成。
|
|
12
|
+
* - 专属工作区:workspaceRegistry.resolveByPath() 先查,缺失才 create()。
|
|
13
|
+
* create 对同一 realpath 幂等,但显式先查可保证"绝不改动既有 title"。
|
|
14
|
+
* - 会话归属:attachSession() 由宿主校验 header.cwd 是否等于工作区路径,
|
|
15
|
+
* 不匹配会抛;因此只在确实属于通道时才挂,失败静默跳过,绝不伪造归属。
|
|
16
|
+
* - 提示词注入:systemPrompt.section({ name, order, text }),text 传函数
|
|
17
|
+
* (宿主按 `typeof section.text === 'function' ? section.text(context)` 求值),
|
|
18
|
+
* 所以记忆变化会随下次组装自动生效,不需要手动 refresh。
|
|
19
|
+
* - 自动流水:监听 session/event 的 turn/end(payload 明确为 (session, event)),
|
|
20
|
+
* 不用 agent/turn-stopping —— 后者是 scope-filtered serial 事件,回调里
|
|
21
|
+
* 拿不到稳定的 agent 标识。
|
|
22
|
+
* - 存储:~/.dsh/channel-memory/*.json,原子 rename + sha256 校验和;校验和
|
|
23
|
+
* 不符时退回空库而不是把脏数据当记忆用。
|
|
24
|
+
*
|
|
25
|
+
* 零运行时依赖:node 内置模块 + 宿主提供的 @deepseek-ai/dsh-tools。
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
31
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
32
|
+
|
|
33
|
+
export const name = 'channel-memory'
|
|
34
|
+
// `tools` 是每 agent 的调度服务,根 ctx 上直接读取会抛
|
|
35
|
+
// "cannot get property tools without inject"。声明式 inject 才能拿到可用的
|
|
36
|
+
// 服务句柄,且注册落在本插件的 fiber 上(与 modlens 等可用插件同一形态)。
|
|
37
|
+
export const inject = ['tools']
|
|
38
|
+
|
|
39
|
+
const NL = String.fromCharCode(10)
|
|
40
|
+
const MAX_MEMORIES = 400
|
|
41
|
+
const MAX_DIGESTS = 300
|
|
42
|
+
const MAX_TEXT = 4000
|
|
43
|
+
const SECTION_ORDER = 3200
|
|
44
|
+
|
|
45
|
+
const dshHome = () => process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
46
|
+
const clamp = (s, n) => String(s == null ? '' : s).replace(/[\r\n]+/g, ' ').trim().slice(0, n)
|
|
47
|
+
|
|
48
|
+
/** 原子写 + 校验和:坏文件绝不覆盖好文件。 */
|
|
49
|
+
function writeJson(file, value) {
|
|
50
|
+
const { checksum, ...rest } = value
|
|
51
|
+
const body = JSON.stringify(rest, null, 1)
|
|
52
|
+
const tmp = file + '.tmp'
|
|
53
|
+
writeFileSync(tmp, JSON.stringify({ ...rest, checksum: createHash('sha256').update(body).digest('hex') }, null, 1) + NL, 'utf8')
|
|
54
|
+
renameSync(tmp, file)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readJson(file, initial) {
|
|
58
|
+
if (!existsSync(file)) return structuredClone(initial)
|
|
59
|
+
let doc
|
|
60
|
+
try { doc = JSON.parse(readFileSync(file, 'utf8')) } catch { return structuredClone(initial) }
|
|
61
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return structuredClone(initial)
|
|
62
|
+
const { checksum, ...rest } = doc
|
|
63
|
+
if (checksum) {
|
|
64
|
+
const expect = createHash('sha256').update(JSON.stringify(rest, null, 1)).digest('hex')
|
|
65
|
+
if (expect !== checksum) return structuredClone(initial) // 截断/外部改坏:退回空库
|
|
66
|
+
}
|
|
67
|
+
return { ...structuredClone(initial), ...rest }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MEM0 = { version: 1, items: [] }
|
|
71
|
+
const DIG0 = { version: 1, items: [] }
|
|
72
|
+
|
|
73
|
+
export function apply(ctx, config) {
|
|
74
|
+
const cfg = {
|
|
75
|
+
workspacePath: config.workspacePath || join(dshHome(), 'channel-workspace'),
|
|
76
|
+
dataDir: config.dataDir || join(dshHome(), 'channel-memory'),
|
|
77
|
+
workspaceTitle: config.workspaceTitle || '手机通道',
|
|
78
|
+
injectIndex: config.injectIndex !== false,
|
|
79
|
+
indexLimit: Number.isInteger(config.indexLimit) && config.indexLimit > 0 ? config.indexLimit : 12,
|
|
80
|
+
autoDigest: config.autoDigest !== false,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try { mkdirSync(cfg.dataDir, { recursive: true }) } catch { /* 只读环境下仍可运行 */ }
|
|
84
|
+
const MEM_FILE = join(cfg.dataDir, 'memory.json')
|
|
85
|
+
const DIG_FILE = join(cfg.dataDir, 'digest.json')
|
|
86
|
+
const mem = readJson(MEM_FILE, MEM0)
|
|
87
|
+
const dig = readJson(DIG_FILE, DIG0)
|
|
88
|
+
if (!Array.isArray(mem.items)) mem.items = []
|
|
89
|
+
if (!Array.isArray(dig.items)) dig.items = []
|
|
90
|
+
|
|
91
|
+
const saveMem = () => { try { writeJson(MEM_FILE, { version: 1, items: mem.items.slice(-MAX_MEMORIES) }) } catch (e) { ctx.logger?.warn?.('channel-memory: save failed ' + String(e?.message).slice(0, 80)) } }
|
|
92
|
+
const saveDig = () => { try { writeJson(DIG_FILE, { version: 1, items: dig.items.slice(-MAX_DIGESTS) }) } catch { } }
|
|
93
|
+
|
|
94
|
+
const addMemory = (input) => {
|
|
95
|
+
const text = clamp(input.text, MAX_TEXT)
|
|
96
|
+
if (!text) return { ok: false, reason: 'text 为空' }
|
|
97
|
+
const item = {
|
|
98
|
+
id: randomUUID().slice(0, 8),
|
|
99
|
+
text,
|
|
100
|
+
kind: clamp(input.kind, 24) || 'note',
|
|
101
|
+
tags: (Array.isArray(input.tags) ? input.tags : []).map((t) => clamp(t, 40)).filter(Boolean).slice(0, 8),
|
|
102
|
+
pinned: input.pinned === true,
|
|
103
|
+
source: clamp(input.source, 40) || 'agent',
|
|
104
|
+
at: new Date().toISOString(),
|
|
105
|
+
}
|
|
106
|
+
mem.items.push(item)
|
|
107
|
+
saveMem()
|
|
108
|
+
return { ok: true, item, total: mem.items.length }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const search = (q, limit) => {
|
|
112
|
+
const needle = clamp(q, 200).toLowerCase()
|
|
113
|
+
const pool = mem.items.slice().reverse()
|
|
114
|
+
const hit = needle
|
|
115
|
+
? pool.filter((m) => (m.text + ' ' + (m.tags || []).join(' ') + ' ' + m.kind).toLowerCase().includes(needle))
|
|
116
|
+
: pool
|
|
117
|
+
return hit.slice(0, Math.max(1, Math.min(Number(limit) || cfg.indexLimit, 100)))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const buildIndex = () => {
|
|
121
|
+
const pinned = mem.items.filter((m) => m.pinned)
|
|
122
|
+
const recent = mem.items.slice(-cfg.indexLimit)
|
|
123
|
+
const seen = new Set()
|
|
124
|
+
const lines = [...pinned, ...recent].filter((m) => !seen.has(m.id) && (seen.add(m.id), true))
|
|
125
|
+
if (!lines.length) return ''
|
|
126
|
+
return [
|
|
127
|
+
'<channel_memory scope="shared-across-sessions">',
|
|
128
|
+
'「手机通道」共享记忆,所有会话可读(memory_write 记录 / memory_search 检索 / memory_forget 删除):',
|
|
129
|
+
...lines.map((m) => `- [${m.id}]${m.pinned ? '[置顶]' : ''} (${m.kind}) ${m.text.slice(0, 220)}`),
|
|
130
|
+
'</channel_memory>',
|
|
131
|
+
].join(NL)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- 宿主服务:workspaceRegistry / sessions ----------------------------
|
|
135
|
+
// 归属判定不自己猜会话的 cwd 字段形状(不同宿主版本暴露方式不同,猜错就会
|
|
136
|
+
// 静默跳过一切)。改用宿主自己的不变量:attachSession() 会校验会话 header 的
|
|
137
|
+
// cwd 是否等于工作区路径,不等就抛。所以"尝试挂载成功"本身就是权威的归属证据。
|
|
138
|
+
const state = { workspaceId: '', workspacePath: '', reg: null }
|
|
139
|
+
const channelSessions = new Set()
|
|
140
|
+
|
|
141
|
+
ctx.inject(['workspaceRegistry', 'sessions'], (host) => {
|
|
142
|
+
const reg = host.workspaceRegistry
|
|
143
|
+
if (!reg) { ctx.logger?.warn?.('channel-memory: workspaceRegistry 不可用'); return }
|
|
144
|
+
state.reg = reg
|
|
145
|
+
|
|
146
|
+
const ensureWorkspace = async () => {
|
|
147
|
+
try {
|
|
148
|
+
if (!existsSync(cfg.workspacePath)) mkdirSync(cfg.workspacePath, { recursive: true })
|
|
149
|
+
const found = await reg.resolveByPath(cfg.workspacePath)
|
|
150
|
+
const ws = found || await reg.create(cfg.workspacePath, cfg.workspaceTitle)
|
|
151
|
+
state.workspaceId = ws.id; state.workspacePath = ws.path
|
|
152
|
+
for (const id of ws.sessionIds || []) channelSessions.add(id) // 重启后续用
|
|
153
|
+
if (!found) ctx.logger?.info?.(`channel-memory: workspace ${ws.id} -> ${ws.path}`)
|
|
154
|
+
return ws
|
|
155
|
+
} catch (e) {
|
|
156
|
+
ctx.logger?.warn?.('channel-memory: workspace ensure failed: ' + String(e?.message ?? e).slice(0, 160))
|
|
157
|
+
return undefined
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const attach = async (session) => {
|
|
162
|
+
const id = session?.id
|
|
163
|
+
if (!state.workspaceId || !id || channelSessions.has(id)) return
|
|
164
|
+
let ws
|
|
165
|
+
try { ws = reg.get(state.workspaceId) } catch { return }
|
|
166
|
+
if (!ws) return
|
|
167
|
+
try {
|
|
168
|
+
await ws.attachSession(id) // host validates cwd === workspace path
|
|
169
|
+
channelSessions.add(id)
|
|
170
|
+
ctx.logger?.info?.(`channel-memory: attached ${String(id).slice(0, 12)}`)
|
|
171
|
+
} catch {
|
|
172
|
+
// 普通桌面会话会在这里被宿主拒绝——这是预期路径,不是错误,
|
|
173
|
+
// 所以保持静默,绝不强行挂载绕过宿主校验。
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
ctx.effect(async () => { await ensureWorkspace() })
|
|
178
|
+
|
|
179
|
+
ctx.on('session/created', (session) => { Promise.resolve(attach(session)).catch(() => { }) })
|
|
180
|
+
|
|
181
|
+
// 自动流水:只记已确认归属的通道会话的 turn/end。
|
|
182
|
+
if (cfg.autoDigest) {
|
|
183
|
+
ctx.on('session/event', (session, event) => {
|
|
184
|
+
try {
|
|
185
|
+
if (!event || event.type !== 'turn/end') return
|
|
186
|
+
if (!channelSessions.has(session?.id)) return
|
|
187
|
+
dig.items.push({
|
|
188
|
+
id: randomUUID().slice(0, 8),
|
|
189
|
+
session: clamp(session?.id, 60),
|
|
190
|
+
reason: clamp(event?.data?.reason?.kind ?? event?.data?.reason, 24),
|
|
191
|
+
at: new Date().toISOString(),
|
|
192
|
+
})
|
|
193
|
+
saveDig()
|
|
194
|
+
} catch { /* 流水失败绝不影响会话本身 */ }
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ---- 提示词注入 --------------------------------------------------------
|
|
200
|
+
if (cfg.injectIndex) {
|
|
201
|
+
ctx.inject(['systemPrompt'], (host) => {
|
|
202
|
+
const sp = host.systemPrompt
|
|
203
|
+
if (!sp || typeof sp.section !== 'function') { ctx.logger?.warn?.('channel-memory: systemPrompt 不可用'); return }
|
|
204
|
+
try {
|
|
205
|
+
sp.section({ name: 'channel-memory:index', order: SECTION_ORDER, text: buildIndex })
|
|
206
|
+
} catch (e) {
|
|
207
|
+
ctx.logger?.warn?.('channel-memory: section failed: ' + String(e?.message ?? e).slice(0, 140))
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- 模型工具 ----------------------------------------------------------
|
|
213
|
+
// output.schema 是"值节点":只写 type/properties/additionalProperties,
|
|
214
|
+
// 不写 required(该 DSL 里 required 只在参数槽位合法,写进 output.schema 会被拒)。
|
|
215
|
+
const OUT = (extra = {}) => ({
|
|
216
|
+
schema: {
|
|
217
|
+
type: 'object',
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
properties: { ok: { type: 'boolean' }, result: { type: 'string' }, ...extra },
|
|
220
|
+
},
|
|
221
|
+
render: (_args, value) => [{ type: 'text', text: String(value?.result ?? '') }],
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const tools = [
|
|
225
|
+
{
|
|
226
|
+
name: 'memory_write',
|
|
227
|
+
description:
|
|
228
|
+
'写入一条跨会话共享记忆(手机通道与桌面端都能读到)。用于用户明确交代、以后还要用的事实:' +
|
|
229
|
+
'身份资料、求职口径、偏好、正在推进的事项与结论。不要记一次性闲聊或能从文件读到的内容。' +
|
|
230
|
+
'pinned=true 会常驻提示词索引。',
|
|
231
|
+
parameters: {
|
|
232
|
+
text: { type: 'string', required: true, description: '记忆正文,一条只说一件事,≤4000 字。' },
|
|
233
|
+
kind: { type: 'string', description: '类别:fact / pref / task / decision / contact。默认 note。' },
|
|
234
|
+
tags: { type: 'array', items: { type: 'string' }, description: '可选标签,最多 8 个。' },
|
|
235
|
+
pinned: { type: 'boolean', description: '是否置顶进提示词索引。' },
|
|
236
|
+
},
|
|
237
|
+
execute: async (args) => {
|
|
238
|
+
const r = addMemory({ ...args, source: 'tool' })
|
|
239
|
+
return { ok: r.ok, result: r.ok ? `已记录 [${r.item.id}](共 ${r.total} 条)` : `✗ ${r.reason}` }
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'memory_search',
|
|
244
|
+
description: '在共享记忆里检索(子串匹配正文/标签/类别)。不传 query 返回最近若干条。',
|
|
245
|
+
parameters: {
|
|
246
|
+
query: { type: 'string', description: '检索词,中文可用。' },
|
|
247
|
+
limit: { type: 'integer', description: '最多返回几条,默认 12。' },
|
|
248
|
+
},
|
|
249
|
+
execute: async (args) => {
|
|
250
|
+
const hits = search(args.query, args.limit)
|
|
251
|
+
if (!hits.length) return { ok: true, result: '(无匹配记忆)' }
|
|
252
|
+
return { ok: true, result: hits.map((m) => `[${m.id}] (${m.kind})${m.pinned ? '[置顶]' : ''} ${m.text}`).join(NL) }
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: 'memory_list',
|
|
257
|
+
description: '列出共享记忆(最近 20 条,带 id 便于删除)。',
|
|
258
|
+
parameters: {
|
|
259
|
+
include_digests: { type: 'boolean', description: '是否一并给出自动流水条数(默认否)。' },
|
|
260
|
+
},
|
|
261
|
+
execute: async (args) => {
|
|
262
|
+
const lines = mem.items.slice(-20).map((m) => `[${m.id}] (${m.kind})${m.pinned ? '[置顶]' : ''} ${m.text.slice(0, 160)}`)
|
|
263
|
+
let out = `共享记忆 ${mem.items.length} 条(显示最近 ${lines.length}):` + NL + (lines.join(NL) || '(空)')
|
|
264
|
+
if (args.include_digests) out += NL + `自动流水 ${dig.items.length} 条。`
|
|
265
|
+
return { ok: true, result: out }
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'memory_forget',
|
|
270
|
+
description: '按 id 删除一条共享记忆。id 来自 memory_search / memory_list。',
|
|
271
|
+
parameters: { id: { type: 'string', required: true, description: '记忆 id,如 a1b2c3d4。' } },
|
|
272
|
+
execute: async (args) => {
|
|
273
|
+
const id = clamp(args.id, 20)
|
|
274
|
+
const before = mem.items.length
|
|
275
|
+
mem.items = mem.items.filter((m) => m.id !== id)
|
|
276
|
+
if (mem.items.length === before) return { ok: false, result: `✗ 未找到 [${id}]` }
|
|
277
|
+
saveMem()
|
|
278
|
+
return { ok: true, result: `已删除 [${id}],剩余 ${mem.items.length} 条` }
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'channel_workspace_info',
|
|
283
|
+
description: '查看「手机通道」专属工作区状态:路径、id、已归属会话数、记忆与流水条数。',
|
|
284
|
+
parameters: {},
|
|
285
|
+
execute: async () => {
|
|
286
|
+
let count = 0
|
|
287
|
+
try {
|
|
288
|
+
const ws = state.reg?.get?.(state.workspaceId)
|
|
289
|
+
count = (ws?.sessionIds || []).length
|
|
290
|
+
} catch { }
|
|
291
|
+
return {
|
|
292
|
+
ok: Boolean(state.workspaceId),
|
|
293
|
+
result: [
|
|
294
|
+
`工作区 id : ${state.workspaceId || '(未就绪)'}`,
|
|
295
|
+
`路径 : ${state.workspacePath || cfg.workspacePath}`,
|
|
296
|
+
`已归属会话: ${count}`,
|
|
297
|
+
`共享记忆 : ${mem.items.length} 条(置顶 ${mem.items.filter((m) => m.pinned).length})`,
|
|
298
|
+
`自动流水 : ${dig.items.length} 条`,
|
|
299
|
+
`数据目录 : ${cfg.dataDir}`,
|
|
300
|
+
].join(NL),
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
const diag = { toolsService: 'ok', defineTool: typeof defineTool, errors: [], registered: [] }
|
|
307
|
+
const registerAll = (toolsSvc) => {
|
|
308
|
+
if (!toolsSvc || typeof toolsSvc.register !== 'function') { diag.toolsService = 'unavailable'; return }
|
|
309
|
+
for (const t of tools) {
|
|
310
|
+
try {
|
|
311
|
+
toolsSvc.register(defineTool({ ...t, output: OUT() }))
|
|
312
|
+
diag.registered.push(t.name)
|
|
313
|
+
} catch (e) {
|
|
314
|
+
const msg = `${t.name}: ` + String(e?.message ?? e).slice(0, 160)
|
|
315
|
+
diag.errors.push(msg)
|
|
316
|
+
ctx.logger?.warn?.(`channel-memory: tool ${msg}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// inject 声明保证了 ctx.tools 可用;注册同步完成后才落快照,
|
|
322
|
+
// 不会再出现 "toolsService absent / registered 0" 的时序假象。
|
|
323
|
+
registerAll(ctx.tools)
|
|
324
|
+
|
|
325
|
+
// Observability: without this, a silent registration failure is indistinguishable
|
|
326
|
+
// from "plugin not installed" when looking at the profile from the outside.
|
|
327
|
+
// Tool registration is synchronous above, but the workspace is created by an
|
|
328
|
+
// async effect, so a single snapshot here always shows workspace:null. Re-write
|
|
329
|
+
// after the effect has had time to land, otherwise the file contradicts
|
|
330
|
+
// channel_workspace_info and looks like a mount failure.
|
|
331
|
+
const writeStatus = () => {
|
|
332
|
+
try {
|
|
333
|
+
writeJson(join(cfg.dataDir, 'status.json'), {
|
|
334
|
+
version: 1, at: new Date().toISOString(), registered: diag.registered.length, total: tools.length,
|
|
335
|
+
diag, workspace: state.workspaceId || null, memories: mem.items.length,
|
|
336
|
+
})
|
|
337
|
+
} catch { }
|
|
338
|
+
}
|
|
339
|
+
writeStatus()
|
|
340
|
+
setTimeout(writeStatus, 2000)
|
|
341
|
+
setTimeout(writeStatus, 10000)
|
|
342
|
+
|
|
343
|
+
ctx.logger?.info?.(`channel-memory ready: tools=${diag.registered.length}/${tools.length} memories=${mem.items.length} ws=${cfg.workspacePath}`)
|
|
344
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-channel-memory",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "微信通道专属工作区 + 跨会话互通记忆:把手机发起的 DSH 会话归到一个独立工作区,并让所有会话共享一份可检索的长期记忆。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.js",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Yuncan050115/dsh-channel-memory.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Yuncan050115/dsh-channel-memory/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/Yuncan050115/dsh-channel-memory#readme",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./package.json": "./package.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"README.md",
|
|
24
|
+
"PERMISSIONS.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"package.json"
|
|
27
|
+
],
|
|
28
|
+
"dsh": {
|
|
29
|
+
"bundle": {
|
|
30
|
+
"patch": "./cordis.patch.yml"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
35
|
+
"@deepseek-ai/dsh-tools": ">=0.2.1-rc.5"
|
|
36
|
+
},
|
|
37
|
+
"peerDependenciesMeta": {
|
|
38
|
+
"@deepseek-ai/cordis": {
|
|
39
|
+
"optional": true
|
|
40
|
+
},
|
|
41
|
+
"@deepseek-ai/dsh-tools": {
|
|
42
|
+
"optional": true
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"deepseek-harness",
|
|
50
|
+
"dsh-plugin",
|
|
51
|
+
"wechat",
|
|
52
|
+
"memory",
|
|
53
|
+
"workspace"
|
|
54
|
+
]
|
|
55
|
+
}
|