dsh-memory-github 0.0.0-stage → 0.3.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/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/cordis.patch.yml +30 -0
- package/lib/index.js +979 -0
- package/package.json +50 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# 更新日志
|
|
2
|
+
|
|
3
|
+
## 0.3.0 — 2026-10-25
|
|
4
|
+
|
|
5
|
+
参考同类插件(dsh-prime-memory、meow-memory、@try-works/dsh-memory、@modusensus/dsh-mneme 等
|
|
6
|
+
百余个生态作品)后补齐的「记忆全生命周期」能力。仍然零依赖、纯 Markdown + git。
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- **`memory_update`**:改已有归档的 title / tags / content,刷新 `updated` 并自动提交推送。
|
|
11
|
+
- **`memory_forget`**:`archive`(移到 `notes/archive/`,可回滚)或 `delete`(直接删文件)。
|
|
12
|
+
- **`memory_stats`**:条数、总大小、标签分布、核心记忆长度、后端信息、最近更新时间。
|
|
13
|
+
- **`memory_history`**:GitHub 后端的提交历史,可追溯某条记忆何时被哪个工具写进来。
|
|
14
|
+
- **`recentLimit`**:每轮在系统提示末尾注入最近 N 条归档的 id / 标题 / 标签(默认关)。
|
|
15
|
+
竞品普遍靠「自动召回」提升命中率;这个是最便宜的近似做法,不引入数据库。
|
|
16
|
+
- **查重提示**:`memory_write` 写入前与已有归档做词袋 Jaccard 比较(≥45%),
|
|
17
|
+
命中就在返回里提示「考虑用 `memory_update`」,减少同一件事写两遍。可用 `dedupeHint: false` 关闭。
|
|
18
|
+
|
|
19
|
+
### 改进
|
|
20
|
+
|
|
21
|
+
- `findNote` 现在支持三种定位方式:id 精确 / id 前缀 / **标题** / id 含该 slug,
|
|
22
|
+
并且多处命中时明确列出候选,不再直接报"找不到"。
|
|
23
|
+
- 只读模式(`role: child`)下新增的两个读工具 `memory_stats`、`memory_history` 也可用。
|
|
24
|
+
|
|
25
|
+
### 测试
|
|
26
|
+
|
|
27
|
+
- 冒烟测试从 42 条扩到 **63 条**(覆盖 update / forget / stats / history / recentLimit / 查重)。
|
|
28
|
+
|
|
29
|
+
## 0.2.0 — 2026-10-25
|
|
30
|
+
|
|
31
|
+
### 新增
|
|
32
|
+
|
|
33
|
+
- **角色区分 `role`**:`primary`(整理端,可写核心与归档)与 `child`(子端,只读,
|
|
34
|
+
写内容走 `inbox/`)。`child` 会自动只读,`memory_sync push` 也会被拒。
|
|
35
|
+
- **`remindEvery`**(默认 6):连续 N 条用户消息没动过记忆库(`MEMORY.md` / `notes/` /
|
|
36
|
+
`index.json` / `inbox/` 的 mtime 都没变)就在系统提示里注入提醒;写过记忆后计数自动归零。
|
|
37
|
+
提醒文案按角色区分:整理端提示用 `memory_write`,子端提示写 `inbox/`。
|
|
38
|
+
- **`staleMinutes`**(默认关闭):距上次成功 pull/push 超过 N 分钟,提醒先
|
|
39
|
+
`memory_sync pull` 再动手,避免多设备同时改同一份记忆。
|
|
40
|
+
- **`pushOnTurnEnd`**(默认关闭):每轮结束若有未提交改动就自动提交推送。
|
|
41
|
+
- 实现依赖宿主事件 `session/event`(`user/message` 计数、`turn/end` 触发推送)。
|
|
42
|
+
|
|
43
|
+
### 修复
|
|
44
|
+
|
|
45
|
+
- **工具 `output` 必须是 `{ schema, render }` 对象**。此前写成函数,DSH 加载时会抛
|
|
46
|
+
`tool "..." must declare output { schema, render, presentationMeta? }`,导致整个插件挂载失败。
|
|
47
|
+
单元测试覆盖了新形状。
|
|
48
|
+
- **`git add` 只加实际存在的路径**。以前固定写 `add -A -- MEMORY.md notes index.json`,
|
|
49
|
+
只要 `MEMORY.md` 还没生成,git 会整体失败、笔记永远提交不上去。
|
|
50
|
+
- **启动拉取不再阻塞启动**:`git pull` 挪到下一轮事件循环执行。
|
|
51
|
+
- **pull 失败自动 `git rebase --abort`**,不再把工作副本卡在 rebase 中间态。
|
|
52
|
+
- 目录已有记忆文件但还不是 git 仓库时,不再贸然 `clone` 覆盖,只告警并跳过。
|
|
53
|
+
- `memory_sync status` 增加「最近提交」一行。
|
|
54
|
+
|
|
55
|
+
## 0.1.0 — 2026-10-25
|
|
56
|
+
|
|
57
|
+
首个版本。
|
|
58
|
+
|
|
59
|
+
- 两段式记忆:`MEMORY.md`(每轮注入,`coreLimit` 默认 3000 字符)+ `notes/*.md` + `index.json`
|
|
60
|
+
- 六个工具:`memory_write` / `memory_core` / `memory_search` / `memory_read` /
|
|
61
|
+
`memory_list` / `memory_sync`
|
|
62
|
+
- 两种后端:`local`(默认)与 `github`(同一目录套 git,启动 pull、写入 commit+push)
|
|
63
|
+
- `readOnly` 模式:只注册读工具
|
|
64
|
+
- 索引自愈:`index.json` 丢失后从 `notes/` 重建
|
|
65
|
+
- 自带冒烟测试(`test/smoke.mjs`、`test/git-backend.mjs`)与 GitHub Actions
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-memory-github 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,203 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-memory-github
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/Administrator016/dsh-memory-github/actions/workflows/test.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 用的**跨会话记忆插件**。
|
|
7
|
+
|
|
8
|
+
默认把记忆存在本地目录;想要换机器也能接着用,就把它接成一个 **GitHub 私有仓库**——
|
|
9
|
+
插件会在每次写入后自动 commit + push,启动时自动 pull。
|
|
10
|
+
|
|
11
|
+
> 记忆分两层:**核心记忆**(`MEMORY.md`,每轮注入系统提示,硬上限可配)和**归档记忆**
|
|
12
|
+
> (`notes/*.md` + `index.json`,按关键词检索)。模型自己决定什么时候写、写什么。
|
|
13
|
+
|
|
14
|
+
## 特性
|
|
15
|
+
|
|
16
|
+
- 🧠 **两段式记忆**:核心记忆常驻注入,归档记忆按需检索(`memory_search` / `memory_read`)
|
|
17
|
+
- 💾 **默认本地**:一个目录就是记忆库(Markdown + JSON,随便备份、随便看)
|
|
18
|
+
- ☁️ **可选 GitHub**:同一个目录套上 git,写入自动推送、启动自动拉取
|
|
19
|
+
- 🔒 **只读模式**:`readOnly: true` 只注册读工具,适合"手机写入、电脑只读"这类多设备分工
|
|
20
|
+
- 🩹 **索引自愈**:`index.json` 丢了会从 `notes/` 重建,不会因为一个文件损坏就丢记忆
|
|
21
|
+
- 🧪 **自带冒烟测试**:`node test/smoke.mjs`,30 条断言不依赖 DSH 运行时
|
|
22
|
+
|
|
23
|
+
## 两种后端
|
|
24
|
+
|
|
25
|
+
| | `local`(默认) | `github`(进阶) |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| 存储 | `$DSH_HOME/memory/` | 同一个目录,同时是 git 工作副本 |
|
|
28
|
+
| 换机 | 手动拷贝目录 | `git clone` 即恢复 |
|
|
29
|
+
| 多设备 | 各存各的 | 共享一份,自动 pull / push |
|
|
30
|
+
| 依赖 | 无 | `git` 可执行文件 |
|
|
31
|
+
|
|
32
|
+
## 安装
|
|
33
|
+
|
|
34
|
+
**方式一:npm(推荐)**
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
dsh plugin --profile desktop add dsh-memory-github
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**方式二:直接从 GitHub 装**
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
# pnpm 语法(dsh plugin 内部就是 pnpm)
|
|
44
|
+
dsh plugin --profile desktop add github:Administrator016/dsh-memory-github
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**方式三:本地目录**
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
dsh plugin --profile desktop add link:/path/to/dsh-memory-github
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**方式四:手动接线**
|
|
54
|
+
|
|
55
|
+
把包放进 `<profile>/node_modules/`,然后在 `<profile>/package.json` 的
|
|
56
|
+
`dsh.profile.bundles` 里加上 `"dsh-memory-github"`。
|
|
57
|
+
|
|
58
|
+
装完重启 DSH 生效。
|
|
59
|
+
|
|
60
|
+
> ⚠️ **和 `dsh-core-memory` 只能二选一。** 两者注册的工具同名
|
|
61
|
+
> (`memory_write` / `memory_core` / `memory_search` / `memory_read` / `memory_list`),
|
|
62
|
+
> 同时装会因重复注册而挂掉一个。换装时先把旧的从 `dsh.profile.bundles` 里去掉。
|
|
63
|
+
|
|
64
|
+
## 配置
|
|
65
|
+
|
|
66
|
+
配置写在 `<profile>/cordis.patch.yml`(插件自带一份默认值,直接改它或在自己的 patch 层覆盖):
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
- id: dsh-memory-github
|
|
70
|
+
name: 'dsh-memory-github'
|
|
71
|
+
config:
|
|
72
|
+
backend: local # local | github
|
|
73
|
+
dir: '' # 空 = $DSH_HOME/memory
|
|
74
|
+
role: primary # primary(整理端,可写)| child(子端,只读 + 写 inbox)
|
|
75
|
+
readOnly: false # true 强制只读;role=child 时自动为 true
|
|
76
|
+
coreLimit: 3000 # 核心记忆注入上限(字符)
|
|
77
|
+
remote: '' # github 后端:git@github.com:you/dsh-memory.git
|
|
78
|
+
branch: main
|
|
79
|
+
autoPull: true # 启动时 pull
|
|
80
|
+
autoPush: true # 写入后 commit + push
|
|
81
|
+
pushOnTurnEnd: false # 每轮结束若有未提交改动就推送
|
|
82
|
+
remindEvery: 6 # 每 N 条用户消息没动记忆 → 注入提醒(0=关)
|
|
83
|
+
staleMinutes: 0 # 距上次同步超过 N 分钟 → 提示先 pull(0=关)
|
|
84
|
+
commitMessage: 'memory: {tool}' # {tool} 会替换成触发的工具名
|
|
85
|
+
gitBin: git # git 可执行文件(不在 PATH 就写绝对路径)
|
|
86
|
+
authorName: dsh-memory
|
|
87
|
+
authorEmail: dsh-memory@localhost
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 多端分工与同步提醒
|
|
91
|
+
|
|
92
|
+
一台机器当"整理端",其余当"子端":
|
|
93
|
+
|
|
94
|
+
| | `role: primary`(整理端) | `role: child`(子端) |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| 写核心 / 归档 | ✅ `memory_write` / `memory_core` | ❌ 不注册写工具 |
|
|
97
|
+
| 写 `inbox/` | 可选 | ✅ 推荐做法 |
|
|
98
|
+
| 自动推送 | `autoPush` / `pushOnTurnEnd` | ❌(`memory_sync push` 也会被拒) |
|
|
99
|
+
| 启动拉取 | `autoPull` | `autoPull`(必须开,否则看不到别人的更新) |
|
|
100
|
+
| 提醒文案 | 用 `memory_write` 存、`memory_sync push` 推 | 写 `inbox/` 再 `git pull/commit/push` |
|
|
101
|
+
|
|
102
|
+
提醒怎么触发:
|
|
103
|
+
|
|
104
|
+
- **`remindEvery`(默认 6)**:连续 N 条用户消息没动过记忆库(核心/notes/index/inbox 的 mtime 都没变),
|
|
105
|
+
就在系统提示里追一段提醒;一旦写了记忆,计数自动归零。
|
|
106
|
+
- **`staleMinutes`(默认关)**:距上次成功 pull/push 超过 N 分钟,提示先 `memory_sync pull`
|
|
107
|
+
再动手,避免两台设备同时改同一份记忆。
|
|
108
|
+
- **`pushOnTurnEnd`**:整理端可以打开,每轮结束自动把改动推上去(相当于"自动存盘")。
|
|
109
|
+
|
|
110
|
+
`local` 后端下这些提醒同样有效(只是不涉及 git 同步)。
|
|
111
|
+
|
|
112
|
+
## 工具
|
|
113
|
+
|
|
114
|
+
| 工具 | 作用 |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `memory_write` | 保存一条归档记忆(title / content / tags),立刻进索引;写入前会做**相似度查重** |
|
|
117
|
+
| `memory_update` | 改已有归档的 title / tags / content,刷新 `updated` |
|
|
118
|
+
| `memory_forget` | `archive`(移到 `notes/archive/` 可回滚)或 `delete` |
|
|
119
|
+
| `memory_core` | 编辑 `MEMORY.md`:`append` / `replace` / `remove` 某个分节 |
|
|
120
|
+
| `memory_search` | 按关键词检索(标题权重 6、标签 3、正文按出现次数),返回摘要 |
|
|
121
|
+
| `memory_read` | 按 id 读全文,支持唯一前缀 |
|
|
122
|
+
| `memory_list` | 列出归档(新→旧),可按标签过滤 |
|
|
123
|
+
| `memory_stats` | 条数、总大小、标签分布、核心记忆长度、后端与最近更新 |
|
|
124
|
+
| `memory_history` | 提交历史(GitHub 后端),追溯某条记忆是何时被哪个工具写进来的 |
|
|
125
|
+
| `memory_sync` | `status` / `pull` / `push`,GitHub 后端专用 |
|
|
126
|
+
|
|
127
|
+
**查重**:`memory_write` 会把新内容与已有归档做词袋相似度比较(Jaccard ≥ 45% 视为高度相似),
|
|
128
|
+
命中时在返回里提示「考虑改用 `memory_update`」。关掉:`dedupeHint: false`。
|
|
129
|
+
|
|
130
|
+
**最近归档注入**:`recentLimit: 5` 会在每轮系统提示末尾附上最近 5 条归档的 id/标题/标签,
|
|
131
|
+
让模型"看得见目录",召回率比纯靠 `memory_search` 高。默认关闭(`0`)。
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
模型侧的使用约定由插件注入的系统提示承载:核心记忆里写"永远要记住的",
|
|
135
|
+
归档里写"以后可能用得上、但不用每轮都占上下文的"。
|
|
136
|
+
|
|
137
|
+
## 开启 GitHub 后端
|
|
138
|
+
|
|
139
|
+
1. **建一个私有仓库**(比如 `you/dsh-memory`),初始化一个 `README.md` 即可,不要勾选自动生成太多文件。
|
|
140
|
+
|
|
141
|
+
2. **准备认证**,两种都行:
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
# SSH(推荐,无 token 泄露风险)
|
|
145
|
+
git clone git@github.com:you/dsh-memory.git ~/.dsh/memory
|
|
146
|
+
|
|
147
|
+
# 或者 HTTPS + PAT(token 只放本地 remote,别提交进仓库)
|
|
148
|
+
git clone https://<token>@github.com/you/dsh-memory.git ~/.dsh/memory
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
3. **改配置**:
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
backend: github
|
|
155
|
+
remote: git@github.com:you/dsh-memory.git
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
4. **重启 DSH**。之后每次 `memory_write` / `memory_core` 都会自动提交推送;
|
|
159
|
+
启动时会先 `pull --rebase`。手动同步随时可以 `memory_sync`。
|
|
160
|
+
|
|
161
|
+
> 多设备同时写入会有冲突。默认策略是 `pull --rebase`;冲突时插件会在工具返回里提示,
|
|
162
|
+
> 建议只让一台设备负责写入,其余设备用 `readOnly: true`。
|
|
163
|
+
|
|
164
|
+
## 数据结构
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
$DSH_HOME/memory/
|
|
168
|
+
├─ MEMORY.md # 核心记忆,每轮注入(超出 coreLimit 会截断)
|
|
169
|
+
├─ index.json # { version, notes: [{ id, title, tags, created, updated, size }] }
|
|
170
|
+
└─ notes/
|
|
171
|
+
└─ 20261025-104912-windows-自启.md # 带 front-matter 的 Markdown
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
笔记文件:
|
|
175
|
+
|
|
176
|
+
```markdown
|
|
177
|
+
---
|
|
178
|
+
id: 20261025-104912-windows-自启
|
|
179
|
+
title: Windows 端开机自启
|
|
180
|
+
tags: windows, dsh
|
|
181
|
+
created: 2026-10-25T02:49:12.000Z
|
|
182
|
+
updated: 2026-10-25T02:49:12.000Z
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
正文……
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## 安全提示
|
|
189
|
+
|
|
190
|
+
- GitHub 仓库请设为 **私有**:记忆里难免出现环境细节。
|
|
191
|
+
- 不要把 PAT 写进 `cordis.patch.yml`、更不要提交进仓库;用 SSH key 或本地 remote。
|
|
192
|
+
- `readOnly: true` 下不会注册任何写工具,适合给不信任的机器用。
|
|
193
|
+
|
|
194
|
+
## 开发
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
node test/smoke.mjs # 30 条断言:工具注册 / 写入 / 检索 / 核心编辑 / 只读 / 索引自愈
|
|
198
|
+
node test/git-backend.mjs # 用本地裸仓库验证自动 push / pull(需要 git)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# dsh-memory-github profile patch layer
|
|
2
|
+
#
|
|
3
|
+
# 默认值:本地记忆库($DSH_HOME/memory),不开 git,行为等同"整理端"。
|
|
4
|
+
# 要用 GitHub 后端 / 子端,把下面的 config 改成注释里那份即可。
|
|
5
|
+
- insert:
|
|
6
|
+
- id: dsh-memory-github
|
|
7
|
+
name: 'dsh-memory-github'
|
|
8
|
+
config:
|
|
9
|
+
backend: local # local | github
|
|
10
|
+
dir: '' # 空 = $DSH_HOME/memory
|
|
11
|
+
role: primary # primary(整理端)| child(子端,只读 + 写 inbox)
|
|
12
|
+
readOnly: false # true 强制只读;role=child 时自动为 true
|
|
13
|
+
coreLimit: 3000 # 核心记忆注入上限(字符)
|
|
14
|
+
remote: '' # github 后端:git@github.com:you/dsh-memory.git
|
|
15
|
+
branch: main
|
|
16
|
+
autoPull: true # 启动时拉取
|
|
17
|
+
autoPush: true # 写入后提交推送
|
|
18
|
+
pushOnTurnEnd: false # 每轮结束若有未提交改动就推送
|
|
19
|
+
remindEvery: 6 # 每 N 条用户消息没动记忆 → 提醒(0=关)
|
|
20
|
+
staleMinutes: 0 # 距上次同步超过 N 分钟 → 提示先 pull(0=关)
|
|
21
|
+
commitMessage: 'memory: {tool}'
|
|
22
|
+
gitBin: git
|
|
23
|
+
authorName: dsh-memory
|
|
24
|
+
authorEmail: dsh-memory@localhost
|
|
25
|
+
# ---- 子端(只读,靠 inbox 交流)示例 ----
|
|
26
|
+
# backend: github
|
|
27
|
+
# remote: git@github.com:you/dsh-memory.git
|
|
28
|
+
# role: child
|
|
29
|
+
# autoPull: true
|
|
30
|
+
# staleMinutes: 30
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-github — 跨会话记忆插件
|
|
3
|
+
*
|
|
4
|
+
* 两种存储后端:
|
|
5
|
+
* local (默认):记忆库就是本地一个目录(MEMORY.md + notes/ + index.json)
|
|
6
|
+
* github (可选):同一个目录是 git 仓库,读写后自动 pull / commit / push
|
|
7
|
+
*
|
|
8
|
+
* 设计要点:
|
|
9
|
+
* - 本地目录是唯一数据源;github 后端只是给它套一层 git 同步
|
|
10
|
+
* - 所有写操作串行(enqueue),避免索引与笔记文件不一致
|
|
11
|
+
* - core(MEMORY.md)每轮注入系统提示,硬上限可配(默认 3000 字符)
|
|
12
|
+
* - readOnly=true 时只注册读工具,适合"手机写入、电脑只读"这类分工
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
export const name = 'dsh-memory-github';
|
|
22
|
+
export const inject = ['systemPrompt', 'tools'];
|
|
23
|
+
|
|
24
|
+
const DEFAULTS = {
|
|
25
|
+
backend: 'local', // local | github
|
|
26
|
+
dir: '', // 空 = $DSH_HOME/memory
|
|
27
|
+
role: 'primary', // primary(整理端,可写)| child(子端,只读 + 写 inbox)
|
|
28
|
+
readOnly: false, // true 强制只读;role=child 时自动为 true
|
|
29
|
+
coreLimit: 3000,
|
|
30
|
+
remote: '', // github 后端:git remote(ssh 或 https)
|
|
31
|
+
branch: 'main',
|
|
32
|
+
autoPull: true, // 启动时 pull
|
|
33
|
+
autoPush: true, // 写入后 commit+push
|
|
34
|
+
pushOnTurnEnd: false, // 每轮结束时若有未提交改动就推送
|
|
35
|
+
remindEvery: 6, // 每 N 条用户消息、且期间没动过记忆 → 提醒(0=关)
|
|
36
|
+
staleMinutes: 0, // 距上次同步超过 N 分钟 → 提醒(0=关)
|
|
37
|
+
recentLimit: 0, // 每轮额外注入最近 N 条归档的标题与标签(0=关)
|
|
38
|
+
dedupeHint: true, // 写入时检查是否与已有归档高度相似
|
|
39
|
+
commitMessage: 'memory: {tool}',
|
|
40
|
+
gitBin: 'git',
|
|
41
|
+
authorName: 'dsh-memory',
|
|
42
|
+
authorEmail: 'dsh-memory@localhost',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------- 路径
|
|
46
|
+
|
|
47
|
+
function dshHome() {
|
|
48
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makePaths(config) {
|
|
52
|
+
const dir = config.dir
|
|
53
|
+
? config.dir.replace(/^~(?=$|[\\/])/, homedir())
|
|
54
|
+
: join(dshHome(), 'memory');
|
|
55
|
+
return {
|
|
56
|
+
dir,
|
|
57
|
+
core: join(dir, 'MEMORY.md'),
|
|
58
|
+
notes: join(dir, 'notes'),
|
|
59
|
+
index: join(dir, 'index.json'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureDirs(p) {
|
|
64
|
+
mkdirSync(p.notes, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------- 基础 IO
|
|
68
|
+
|
|
69
|
+
function atomicWrite(file, text) {
|
|
70
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
71
|
+
writeFileSync(tmp, text, 'utf8');
|
|
72
|
+
renameSync(tmp, file);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readTextSafe(file, fallback = '') {
|
|
76
|
+
try {
|
|
77
|
+
return readFileSync(file, 'utf8');
|
|
78
|
+
} catch {
|
|
79
|
+
return fallback;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function sanitizeSlug(text) {
|
|
84
|
+
return String(text)
|
|
85
|
+
.trim()
|
|
86
|
+
.replace(/[\\/:*?"<>|\r\n\t]+/g, '-')
|
|
87
|
+
.replace(/\s+/g, '-')
|
|
88
|
+
.replace(/-+/g, '-')
|
|
89
|
+
.replace(/^-|-$/g, '')
|
|
90
|
+
.slice(0, 40) || 'note';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function makeId(title, existingIds) {
|
|
94
|
+
const d = new Date();
|
|
95
|
+
const stamp = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
|
|
96
|
+
+ `-${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}${String(d.getSeconds()).padStart(2, '0')}`;
|
|
97
|
+
let id = `${stamp}-${sanitizeSlug(title)}`;
|
|
98
|
+
let n = 2;
|
|
99
|
+
while (existingIds.has(id)) id = `${stamp}-${sanitizeSlug(title)}-${n++}`;
|
|
100
|
+
return id;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------- 笔记格式
|
|
104
|
+
|
|
105
|
+
function noteFileText(meta, content) {
|
|
106
|
+
const tags = (meta.tags ?? []).join(', ');
|
|
107
|
+
return [
|
|
108
|
+
'---',
|
|
109
|
+
`id: ${meta.id}`,
|
|
110
|
+
`title: ${meta.title}`,
|
|
111
|
+
`tags: ${tags}`,
|
|
112
|
+
`created: ${meta.created}`,
|
|
113
|
+
`updated: ${meta.updated}`,
|
|
114
|
+
'---',
|
|
115
|
+
'',
|
|
116
|
+
content.trim(),
|
|
117
|
+
'',
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseNoteFile(text) {
|
|
122
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
|
|
123
|
+
if (!m) return { meta: null, content: text };
|
|
124
|
+
const meta = {};
|
|
125
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
126
|
+
const i = line.indexOf(':');
|
|
127
|
+
if (i < 0) continue;
|
|
128
|
+
const k = line.slice(0, i).trim();
|
|
129
|
+
const v = line.slice(i + 1).trim();
|
|
130
|
+
if (k) meta[k] = v;
|
|
131
|
+
}
|
|
132
|
+
meta.tags = meta.tags ? String(meta.tags).split(',').map((t) => t.trim()).filter(Boolean) : [];
|
|
133
|
+
return { meta, content: m[2].trim() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function noteSort(a, b) {
|
|
137
|
+
return String(b.created ?? '').localeCompare(String(a.created ?? ''));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------- 索引
|
|
141
|
+
|
|
142
|
+
function loadIndex(p) {
|
|
143
|
+
const raw = readTextSafe(p.index, '');
|
|
144
|
+
if (raw.trim()) {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(raw);
|
|
147
|
+
if (Array.isArray(parsed.notes)) return parsed.notes;
|
|
148
|
+
} catch { /* 落到重建 */ }
|
|
149
|
+
}
|
|
150
|
+
return rebuildIndex(p);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rebuildIndex(p) {
|
|
154
|
+
const notes = [];
|
|
155
|
+
try {
|
|
156
|
+
for (const f of readdirSync(p.notes)) {
|
|
157
|
+
if (!f.endsWith('.md')) continue;
|
|
158
|
+
const file = join(p.notes, f);
|
|
159
|
+
const parsed = parseNoteFile(readTextSafe(file));
|
|
160
|
+
const st = statSync(file);
|
|
161
|
+
notes.push({
|
|
162
|
+
id: parsed.meta?.id || f.replace(/\.md$/, ''),
|
|
163
|
+
title: parsed.meta?.title || f.replace(/\.md$/, ''),
|
|
164
|
+
tags: parsed.meta?.tags ?? [],
|
|
165
|
+
created: parsed.meta?.created || st.mtime.toISOString(),
|
|
166
|
+
updated: parsed.meta?.updated || st.mtime.toISOString(),
|
|
167
|
+
size: st.size,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch { /* 目录还没建 */ }
|
|
171
|
+
notes.sort(noteSort);
|
|
172
|
+
try {
|
|
173
|
+
atomicWrite(p.index, JSON.stringify({ version: 1, notes }, null, 2));
|
|
174
|
+
} catch { /* 只读目录就算了 */ }
|
|
175
|
+
return notes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function saveIndex(p, notes) {
|
|
179
|
+
const sorted = [...notes].sort(noteSort);
|
|
180
|
+
atomicWrite(p.index, JSON.stringify({ version: 1, notes: sorted }, null, 2));
|
|
181
|
+
return sorted;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 按 id(或唯一前缀)找到一条归档。 */
|
|
185
|
+
function findNote(p, want) {
|
|
186
|
+
const id = String(want ?? '').replace(/\.md$/, '').trim();
|
|
187
|
+
if (!id) return { error: 'id 不能为空。' };
|
|
188
|
+
const notes = loadIndex(p);
|
|
189
|
+
const exact = notes.find((n) => n.id === id);
|
|
190
|
+
if (exact) return { note: exact, notes };
|
|
191
|
+
|
|
192
|
+
// 依次尝试:id 前缀 / 标题(忽略大小写)/ id 后缀(用户常只给 slug)
|
|
193
|
+
const byPrefix = notes.filter((n) => n.id.startsWith(id));
|
|
194
|
+
if (byPrefix.length === 1) return { note: byPrefix[0], notes };
|
|
195
|
+
const lower = id.toLowerCase();
|
|
196
|
+
const byTitle = notes.filter((n) => String(n.title ?? '').toLowerCase() === lower);
|
|
197
|
+
if (byTitle.length === 1) return { note: byTitle[0], notes };
|
|
198
|
+
const bySuffix = notes.filter((n) => n.id.includes(`-${id}`));
|
|
199
|
+
if (bySuffix.length === 1) return { note: bySuffix[0], notes };
|
|
200
|
+
|
|
201
|
+
const candidates = [...byPrefix, ...byTitle, ...bySuffix].filter((n, i, arr) => arr.findIndex((x) => x.id === n.id) === i);
|
|
202
|
+
if (candidates.length > 1) {
|
|
203
|
+
return {
|
|
204
|
+
error: `「${id}」匹配到 ${candidates.length} 条,请给更长前缀或完整 id:\n`
|
|
205
|
+
+ candidates.map((n) => `- ${n.id} ${n.title}`).join('\n'),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return { error: `没有找到 id 为「${id}」的记忆。` };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 词袋:用于相似度判断(标题 / 标签 / 正文前 600 字)。 */
|
|
212
|
+
function tokenSet(text) {
|
|
213
|
+
const set = new Set();
|
|
214
|
+
for (const tok of String(text ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
215
|
+
if (tok && tok.length > 1) set.add(tok);
|
|
216
|
+
}
|
|
217
|
+
return set;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function jaccard(a, b) {
|
|
221
|
+
if (!a.size || !b.size) return 0;
|
|
222
|
+
let inter = 0;
|
|
223
|
+
for (const x of a) if (b.has(x)) inter += 1;
|
|
224
|
+
return inter / (a.size + b.size - inter);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 写入前查重:返回最多 3 条高相似(Jaccard ≥ 0.45)的归档。 */
|
|
228
|
+
function similarNotes(p, title, content) {
|
|
229
|
+
const mine = tokenSet(`${title} ${String(content ?? '').slice(0, 600)}`);
|
|
230
|
+
const hits = [];
|
|
231
|
+
for (const n of loadIndex(p)) {
|
|
232
|
+
// 只比正文,不要把 frontmatter 里的 id/时间戳算进相似度,否则会把相似度稀释掉
|
|
233
|
+
const parsed = parseNoteFile(readTextSafe(join(p.notes, `${n.id}.md`), ''));
|
|
234
|
+
const body = parsed.content ?? '';
|
|
235
|
+
const theirs = tokenSet(`${n.title} ${(n.tags ?? []).join(' ')} ${body.slice(0, 600)}`);
|
|
236
|
+
const score = jaccard(mine, theirs);
|
|
237
|
+
if (score >= 0.45) hits.push({ id: n.id, title: n.title, score });
|
|
238
|
+
}
|
|
239
|
+
hits.sort((a, b) => b.score - a.score);
|
|
240
|
+
return hits.slice(0, 3);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 把归档移到 notes/archive/(保留内容,可回滚)。 */
|
|
244
|
+
function archiveNoteFile(p, id) {
|
|
245
|
+
const dir = join(p.notes, 'archive');
|
|
246
|
+
mkdirSync(dir, { recursive: true });
|
|
247
|
+
const from = join(p.notes, `${id}.md`);
|
|
248
|
+
const to = join(dir, `${id}.md`);
|
|
249
|
+
renameSync(from, to);
|
|
250
|
+
return to;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 最近 N 条归档的标题清单,用于注入系统提示。 */
|
|
254
|
+
function recentSection(p, limit) {
|
|
255
|
+
const n = Number(limit);
|
|
256
|
+
if (!Number.isFinite(n) || n <= 0) return '';
|
|
257
|
+
const notes = loadIndex(p).slice(0, Math.floor(n));
|
|
258
|
+
if (!notes.length) return '';
|
|
259
|
+
const lines = notes.map((x) => `- \`${x.id}\` ${x.title}${(x.tags ?? []).length ? ' #' + x.tags.join(' #') : ''}`);
|
|
260
|
+
return ['', `### 最近归档(${notes.length} 条,细节用 memory_search)`, ...lines].join('\n');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------- core 记忆
|
|
264
|
+
|
|
265
|
+
function coreLimit(config) {
|
|
266
|
+
const n = Number(config.coreLimit);
|
|
267
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 3000;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------- 提醒
|
|
271
|
+
|
|
272
|
+
/** 记忆库最近一次"被写过"的时间戳:核心 / 索引 / notes / inbox 里最新的 mtime。 */
|
|
273
|
+
function activityStamp(p) {
|
|
274
|
+
let latest = 0;
|
|
275
|
+
const touch = (file) => {
|
|
276
|
+
try {
|
|
277
|
+
const st = statSync(file);
|
|
278
|
+
if (st.mtimeMs > latest) latest = st.mtimeMs;
|
|
279
|
+
} catch { /* 不存在就算了 */ }
|
|
280
|
+
};
|
|
281
|
+
touch(p.core);
|
|
282
|
+
touch(p.index);
|
|
283
|
+
for (const sub of ['notes', 'inbox']) {
|
|
284
|
+
let names = [];
|
|
285
|
+
try { names = readdirSync(join(p.dir, sub)); } catch { continue; }
|
|
286
|
+
for (const name of names) {
|
|
287
|
+
if (name.startsWith('.')) continue;
|
|
288
|
+
touch(join(p.dir, sub, name));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return latest;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function writeReminder(config, count) {
|
|
295
|
+
if (config.role === 'child') {
|
|
296
|
+
return [
|
|
297
|
+
`## 同步提醒(dsh-memory-github · 子端)`,
|
|
298
|
+
``,
|
|
299
|
+
`已经连续 ${count} 条用户消息没有往记忆库写东西了。**本机是子端(只读)**,不能改 MEMORY.md / notes/,做法是:`,
|
|
300
|
+
``,
|
|
301
|
+
`1. 在 \`${config.dir || '$DSH_HOME/memory'}\\inbox\\\` 新建一个 \`pc-<日期>-<主题>.md\`(一文件一事,写清:发生了什么、影响什么、以后该记住什么);`,
|
|
302
|
+
`2. \`git pull --rebase\` → \`git add inbox\` → \`git commit -m "pc inbox: 主题"\` → \`git push\`;`,
|
|
303
|
+
`3. 整理端处理后会把它挪进 inbox/done/ 并归档。`,
|
|
304
|
+
``,
|
|
305
|
+
`如果这轮确实没有值得长期记住的内容,可以忽略。`,
|
|
306
|
+
].join('\n');
|
|
307
|
+
}
|
|
308
|
+
return [
|
|
309
|
+
`## 记忆提醒(dsh-memory-github · 整理端)`,
|
|
310
|
+
``,
|
|
311
|
+
`已经连续 ${count} 条用户消息没有动过记忆库了。如果这轮出现了值得长期保留的内容:`,
|
|
312
|
+
``,
|
|
313
|
+
`- 用 \`memory_write\` 存归档(一文件一事、自包含),或 \`memory_core\` 更新 MEMORY.md 的分节;`,
|
|
314
|
+
`- 写完用 \`memory_sync push\` 推到远端,让子端能拉到。`,
|
|
315
|
+
``,
|
|
316
|
+
`没有就忽略,计数会在下次写入后自动归零。`,
|
|
317
|
+
].join('\n');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function syncReminder(config, minutes) {
|
|
321
|
+
return [
|
|
322
|
+
`## 同步提醒(dsh-memory-github)`,
|
|
323
|
+
``,
|
|
324
|
+
`距上次成功同步已经 ${Math.round(minutes)} 分钟(阈值 ${config.staleMinutes} 分钟)。如果刚接手别人的改动,先 \`memory_sync pull\` 再动手,避免冲突。`,
|
|
325
|
+
].join('\n');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function readCore(p) {
|
|
329
|
+
return readTextSafe(p.core, '');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function coreSection(text, limit) {
|
|
333
|
+
const body = (text ?? '').trim();
|
|
334
|
+
if (!body) {
|
|
335
|
+
return '## 跨会话记忆(dsh-memory-github)\n\n> 记忆库还是空的。用 memory_write 存归档、memory_core 写核心记忆。';
|
|
336
|
+
}
|
|
337
|
+
const cut = body.length > limit;
|
|
338
|
+
const shown = cut ? body.slice(0, limit) : body;
|
|
339
|
+
return shown
|
|
340
|
+
+ (cut ? `\n\n> 核心记忆已截断(硬上限 ${limit} 字符),用 memory_search / memory_read 取细节。` : '');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function splitSections(md) {
|
|
344
|
+
const lines = String(md).split(/\r?\n/);
|
|
345
|
+
const head = [];
|
|
346
|
+
const sections = [];
|
|
347
|
+
let cur = null;
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
if (/^##\s+/.test(line)) {
|
|
350
|
+
if (cur) sections.push(cur);
|
|
351
|
+
cur = { title: line.replace(/^##\s+/, '').trim(), body: [] };
|
|
352
|
+
} else if (cur) {
|
|
353
|
+
cur.body.push(line);
|
|
354
|
+
} else {
|
|
355
|
+
head.push(line);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (cur) sections.push(cur);
|
|
359
|
+
return { head, sections };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function joinSections(head, sections) {
|
|
363
|
+
const out = [...head];
|
|
364
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
365
|
+
for (const s of sections) {
|
|
366
|
+
if (out.length) out.push('');
|
|
367
|
+
out.push(`## ${s.title}`);
|
|
368
|
+
const body = s.body.join('\n').replace(/^\n+|\n+$/g, '');
|
|
369
|
+
if (body) out.push('', body);
|
|
370
|
+
}
|
|
371
|
+
return out.join('\n').trimEnd() + '\n';
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function editCore(p, config, action, section, text) {
|
|
375
|
+
const cur = readCore(p) || '# 记忆\n';
|
|
376
|
+
const { head, sections } = splitSections(cur);
|
|
377
|
+
const idx = sections.findIndex((s) => s.title === section);
|
|
378
|
+
const body = (text ?? '').trim();
|
|
379
|
+
|
|
380
|
+
if (action === 'append') {
|
|
381
|
+
if (idx < 0) sections.push({ title: section, body: body ? ['', body] : [] });
|
|
382
|
+
else sections[idx].body.push(...(body ? ['', body] : []));
|
|
383
|
+
} else if (action === 'replace') {
|
|
384
|
+
if (idx < 0) sections.push({ title: section, body: body ? ['', body] : [] });
|
|
385
|
+
else sections[idx].body = body ? ['', body] : [];
|
|
386
|
+
} else if (action === 'remove') {
|
|
387
|
+
if (idx < 0) return { ok: false, message: `没有分节「${section}」` };
|
|
388
|
+
sections.splice(idx, 1);
|
|
389
|
+
}
|
|
390
|
+
const next = joinSections(head, sections);
|
|
391
|
+
atomicWrite(p.core, next);
|
|
392
|
+
return { ok: true, message: `核心记忆已更新(${action} ${section}),现在 ${next.length} 字符。` };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------- 检索
|
|
396
|
+
|
|
397
|
+
function tokenize(q) {
|
|
398
|
+
return String(q ?? '')
|
|
399
|
+
.toLowerCase()
|
|
400
|
+
.split(/[\s,,、;;]+/)
|
|
401
|
+
.map((t) => t.trim())
|
|
402
|
+
.filter(Boolean);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function searchNotes(p, query, limit = 8) {
|
|
406
|
+
const tokens = tokenize(query);
|
|
407
|
+
const notes = loadIndex(p);
|
|
408
|
+
const hits = [];
|
|
409
|
+
for (const n of notes) {
|
|
410
|
+
const file = join(p.notes, `${n.id}.md`);
|
|
411
|
+
const raw = readTextSafe(file);
|
|
412
|
+
const { content } = parseNoteFile(raw);
|
|
413
|
+
const hay = {
|
|
414
|
+
title: String(n.title ?? '').toLowerCase(),
|
|
415
|
+
tags: (n.tags ?? []).join(' ').toLowerCase(),
|
|
416
|
+
body: content.toLowerCase(),
|
|
417
|
+
};
|
|
418
|
+
let score = 0;
|
|
419
|
+
for (const t of tokens) {
|
|
420
|
+
if (hay.title.includes(t)) score += 6;
|
|
421
|
+
if (hay.tags.includes(t)) score += 3;
|
|
422
|
+
const bodyHits = hay.body.split(t).length - 1;
|
|
423
|
+
score += Math.min(bodyHits, 5);
|
|
424
|
+
}
|
|
425
|
+
if (tokens.length === 0) score = 1;
|
|
426
|
+
if (score <= 0) continue;
|
|
427
|
+
const first = tokens.find((t) => hay.body.includes(t));
|
|
428
|
+
let snippet = content.slice(0, 160).replace(/\s+/g, ' ');
|
|
429
|
+
if (first) {
|
|
430
|
+
const at = Math.max(0, hay.body.indexOf(first) - 40);
|
|
431
|
+
snippet = content.slice(at, at + 180).replace(/\s+/g, ' ');
|
|
432
|
+
}
|
|
433
|
+
hits.push({ note: n, score, snippet });
|
|
434
|
+
}
|
|
435
|
+
hits.sort((a, b) => b.score - a.score || noteSort(a.note, b.note));
|
|
436
|
+
return hits.slice(0, Math.max(1, Math.min(Number(limit) || 8, 50)));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------- git
|
|
440
|
+
|
|
441
|
+
function gitAvailable(config) {
|
|
442
|
+
const r = spawnSync(config.gitBin, ['--version'], { encoding: 'utf8' });
|
|
443
|
+
return r.status === 0;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function git(p, config, args, { allowFail = false } = {}) {
|
|
447
|
+
const r = spawnSync(config.gitBin, ['-C', p.dir, ...args], {
|
|
448
|
+
encoding: 'utf8',
|
|
449
|
+
env: {
|
|
450
|
+
...process.env,
|
|
451
|
+
GIT_AUTHOR_NAME: config.authorName,
|
|
452
|
+
GIT_AUTHOR_EMAIL: config.authorEmail,
|
|
453
|
+
GIT_COMMITTER_NAME: config.authorName,
|
|
454
|
+
GIT_COMMITTER_EMAIL: config.authorEmail,
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
if (r.status !== 0 && !allowFail) {
|
|
458
|
+
throw new Error(`git ${args.join(' ')} 失败:${(r.stderr || r.stdout || '').trim() || `exit ${r.status}`}`);
|
|
459
|
+
}
|
|
460
|
+
return r;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function isRepo(p) {
|
|
464
|
+
return existsSync(join(p.dir, '.git'));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function gitPull(p, config) {
|
|
468
|
+
if (!isRepo(p)) return { ok: false, message: '目录还不是 git 仓库' };
|
|
469
|
+
const r = git(p, config, ['pull', '--rebase', 'origin', config.branch], { allowFail: true });
|
|
470
|
+
if (r.status === 0) {
|
|
471
|
+
return { ok: true, message: (r.stdout || '').trim() || '已是最新' };
|
|
472
|
+
}
|
|
473
|
+
// 失败(常见:conflict)时把 rebase 状态清干净,别把工作副本卡在中间态
|
|
474
|
+
git(p, config, ['rebase', '--abort'], { allowFail: true });
|
|
475
|
+
return { ok: false, message: (r.stderr || r.stdout || '').trim() || `exit ${r.status}` };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function gitPush(p, config, message) {
|
|
479
|
+
if (!isRepo(p)) return { ok: false, message: '目录还不是 git 仓库' };
|
|
480
|
+
// 只提交记忆相关文件:目录里顺手放的其他东西不该被推上去
|
|
481
|
+
// 注意 pathspec 里只要有一个不存在的路径,git add 会整体失败,所以要过滤
|
|
482
|
+
const paths = ['MEMORY.md', 'notes', 'index.json'].filter((f) => existsSync(join(p.dir, f)));
|
|
483
|
+
if (paths.length === 0) return { ok: true, message: '没有可提交的记忆文件' };
|
|
484
|
+
const added = git(p, config, ['add', '-A', '--', ...paths], { allowFail: true });
|
|
485
|
+
if (added.status !== 0) return { ok: false, message: `git add 失败:${(added.stderr || '').trim().split('\n')[0]}` };
|
|
486
|
+
const status = git(p, config, ['status', '--porcelain'], { allowFail: true });
|
|
487
|
+
if (!status.stdout.trim()) return { ok: true, message: '没有改动,跳过提交' };
|
|
488
|
+
git(p, config, ['commit', '-m', message]);
|
|
489
|
+
const push = git(p, config, ['push', 'origin', config.branch], { allowFail: true });
|
|
490
|
+
if (push.status !== 0) {
|
|
491
|
+
const why = (push.stderr || '').trim().split('\n').filter(Boolean)[0] || `exit ${push.status}`;
|
|
492
|
+
return { ok: false, message: `已本地提交,但推送失败(远端可能有了新提交,先 memory_sync pull):${why}` };
|
|
493
|
+
}
|
|
494
|
+
return { ok: true, message: `已提交并推送:${message}` };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------- 串行队列
|
|
498
|
+
|
|
499
|
+
let chain = Promise.resolve();
|
|
500
|
+
function enqueue(fn) {
|
|
501
|
+
const run = chain.then(fn, fn);
|
|
502
|
+
chain = run.catch(() => {});
|
|
503
|
+
return run;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------- 工具输出
|
|
507
|
+
|
|
508
|
+
// DSH 的工具 API:output 必须是 { schema, render } 对象,不是函数
|
|
509
|
+
const outputText = {
|
|
510
|
+
schema: { type: 'string' },
|
|
511
|
+
render: (_args, value) => [{ type: 'text', text: String(value ?? '') }],
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
function errText(e) {
|
|
515
|
+
return `操作失败:${e instanceof Error ? e.message : String(e)}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------- 插件入口
|
|
519
|
+
|
|
520
|
+
export function apply(ctx, config0) {
|
|
521
|
+
const config = { ...DEFAULTS, ...(config0 ?? {}) };
|
|
522
|
+
const p = makePaths(config);
|
|
523
|
+
try { ensureDirs(p); } catch { /* 目录建不了就在工具里报 */ }
|
|
524
|
+
|
|
525
|
+
const githubMode = config.backend === 'github';
|
|
526
|
+
// role=child 时强制只读:子端只写 inbox,不改核心与归档
|
|
527
|
+
const readOnly = Boolean(config.readOnly) || config.role === 'child';
|
|
528
|
+
|
|
529
|
+
// 提醒状态:消息计数 / 最近一次记忆活动 / 最近一次成功同步
|
|
530
|
+
const syncState = { count: 0, lastActivity: activityStamp(p), lastSyncAt: Date.now() };
|
|
531
|
+
|
|
532
|
+
const doPull = () => {
|
|
533
|
+
const r = gitPull(p, config);
|
|
534
|
+
if (r.ok) syncState.lastSyncAt = Date.now();
|
|
535
|
+
return r;
|
|
536
|
+
};
|
|
537
|
+
const doPush = (message) => {
|
|
538
|
+
const r = gitPush(p, config, message);
|
|
539
|
+
if (r.ok) syncState.lastSyncAt = Date.now();
|
|
540
|
+
return r;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// 启动时拉一次:放到下一轮事件循环,别让 git 的网络往返拖慢 DSH 启动
|
|
544
|
+
if (githubMode && config.autoPull) {
|
|
545
|
+
setTimeout(() => {
|
|
546
|
+
try {
|
|
547
|
+
if (!gitAvailable(config)) {
|
|
548
|
+
console.warn('[dsh-memory-github] 找不到 git,跳过自动拉取');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (!isRepo(p) && config.remote) {
|
|
552
|
+
const leftover = ['MEMORY.md', 'notes', 'index.json'].filter((f) => existsSync(join(p.dir, f)));
|
|
553
|
+
if (leftover.length) {
|
|
554
|
+
// 已有本地记忆但还不是仓库:不覆盖,交给用户决定
|
|
555
|
+
console.warn(`[dsh-memory-github] ${p.dir} 已有记忆文件(${leftover.join(', ')})但不是 git 仓库,跳过 clone。想接远端请先手动 git init + remote add + pull。`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const r = spawnSync(config.gitBin, ['clone', config.remote, p.dir], { encoding: 'utf8' });
|
|
559
|
+
console.log(r.status === 0
|
|
560
|
+
? `[dsh-memory-github] 已 clone ${config.remote}`
|
|
561
|
+
: `[dsh-memory-github] clone 失败:${(r.stderr || '').trim()}`);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (isRepo(p)) {
|
|
565
|
+
const r = doPull();
|
|
566
|
+
console.log(`[dsh-memory-github] 启动拉取:${r.ok ? '成功' : '失败'} ${r.message.split('\n')[0]}`);
|
|
567
|
+
}
|
|
568
|
+
} catch (e) {
|
|
569
|
+
console.warn(`[dsh-memory-github] 启动拉取异常:${e instanceof Error ? e.message : e}`);
|
|
570
|
+
}
|
|
571
|
+
}, 0);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 每轮注入核心记忆 + 到点提醒
|
|
575
|
+
ctx.systemPrompt.section({
|
|
576
|
+
name: 'dsh-memory-github:core',
|
|
577
|
+
order: 60,
|
|
578
|
+
text: () => {
|
|
579
|
+
const base = coreSection(readCore(p), coreLimit(config)) + recentSection(p, config.recentLimit);
|
|
580
|
+
const stamp = activityStamp(p);
|
|
581
|
+
if (stamp > syncState.lastActivity) {
|
|
582
|
+
// 记忆被写过(本进程写的,或别的进程/设备同步下来的),计数归零
|
|
583
|
+
syncState.lastActivity = stamp;
|
|
584
|
+
syncState.count = 0;
|
|
585
|
+
}
|
|
586
|
+
const extra = [];
|
|
587
|
+
const every = Number(config.remindEvery);
|
|
588
|
+
if (Number.isFinite(every) && every > 0 && syncState.count >= every) {
|
|
589
|
+
extra.push(writeReminder({ ...config, dir: p.dir }, syncState.count));
|
|
590
|
+
}
|
|
591
|
+
const stale = Number(config.staleMinutes);
|
|
592
|
+
if (githubMode && Number.isFinite(stale) && stale > 0) {
|
|
593
|
+
const minutes = (Date.now() - syncState.lastSyncAt) / 60000;
|
|
594
|
+
if (minutes >= stale) extra.push(syncReminder(config, minutes));
|
|
595
|
+
}
|
|
596
|
+
return extra.length ? `${base}\n\n${extra.join('\n\n')}` : base;
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
const afterWrite = (tool) => {
|
|
601
|
+
if (!githubMode || !config.autoPush) return '';
|
|
602
|
+
const r = doPush(String(config.commitMessage).replace('{tool}', tool));
|
|
603
|
+
return r.ok ? '' : `\n(GitHub 同步失败:${r.message.split('\n')[0]})`;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// 计数用户消息;turn/end 时可选自动推送
|
|
607
|
+
if (typeof ctx.on === 'function') {
|
|
608
|
+
ctx.on('session/event', (_session, event) => {
|
|
609
|
+
if (!event || typeof event.type !== 'string') return;
|
|
610
|
+
if (event.type === 'user/message') {
|
|
611
|
+
syncState.count += 1;
|
|
612
|
+
} else if (event.type === 'turn/end' && githubMode && config.pushOnTurnEnd && !readOnly) {
|
|
613
|
+
enqueue(() => {
|
|
614
|
+
try {
|
|
615
|
+
const r = doPush(String(config.commitMessage).replace('{tool}', 'turn/end'));
|
|
616
|
+
if (!r.ok) console.warn(`[dsh-memory-github] turn/end 推送失败:${r.message.split('\n')[0]}`);
|
|
617
|
+
} catch (e) {
|
|
618
|
+
console.warn(`[dsh-memory-github] turn/end 推送异常:${e instanceof Error ? e.message : e}`);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const defs = [
|
|
626
|
+
{
|
|
627
|
+
name: 'memory_write',
|
|
628
|
+
description: '保存一条归档记忆,跨所有会话持久。内容要自包含(未来会话看不到当前对话)。保存后立刻进入索引,可用 memory_search 检索;GitHub 后端会自动提交推送。',
|
|
629
|
+
parameters: {
|
|
630
|
+
type: 'object',
|
|
631
|
+
additionalProperties: false,
|
|
632
|
+
properties: {
|
|
633
|
+
title: { type: 'string', description: '简短标题(用于文件名与检索)' },
|
|
634
|
+
content: { type: 'string', description: '记忆正文,自包含,可多行 Markdown' },
|
|
635
|
+
tags: { type: 'array', items: { type: 'string' }, description: '可选标签' },
|
|
636
|
+
},
|
|
637
|
+
required: ['title', 'content'],
|
|
638
|
+
},
|
|
639
|
+
output: outputText,
|
|
640
|
+
async execute(args) {
|
|
641
|
+
const title = typeof args.title === 'string' ? args.title.replace(/\s+/g, ' ').trim() : '';
|
|
642
|
+
const content = typeof args.content === 'string' ? args.content : '';
|
|
643
|
+
if (!title || !content.trim()) return '保存失败:title 和 content 均不能为空。';
|
|
644
|
+
const tags = Array.isArray(args.tags)
|
|
645
|
+
? args.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
|
|
646
|
+
: [];
|
|
647
|
+
try {
|
|
648
|
+
const dupes = config.dedupeHint === false
|
|
649
|
+
? []
|
|
650
|
+
: similarNotes(p, title, content);
|
|
651
|
+
return await enqueue(() => {
|
|
652
|
+
ensureDirs(p);
|
|
653
|
+
const notes = loadIndex(p);
|
|
654
|
+
const ids = new Set(notes.map((n) => n.id));
|
|
655
|
+
const id = makeId(title, ids);
|
|
656
|
+
const now = new Date().toISOString();
|
|
657
|
+
const meta = { id, title, tags, created: now, updated: now, size: 0 };
|
|
658
|
+
const text = noteFileText(meta, content);
|
|
659
|
+
meta.size = Buffer.byteLength(text);
|
|
660
|
+
atomicWrite(join(p.notes, `${id}.md`), text);
|
|
661
|
+
saveIndex(p, [meta, ...notes]);
|
|
662
|
+
const sync = afterWrite('memory_write');
|
|
663
|
+
const hint = dupes.length
|
|
664
|
+
? '\n注意:和已有归档高度相似,考虑改用 memory_update:\n'
|
|
665
|
+
+ dupes.map((d) => `- ${d.id} ${d.title}(相似度 ${(d.score * 100).toFixed(0)}%)`).join('\n')
|
|
666
|
+
: '';
|
|
667
|
+
return `已保存 [${id}] ${title}${tags.length ? ' #' + tags.join(' #') : ''}。${sync}${hint}`;
|
|
668
|
+
});
|
|
669
|
+
} catch (e) {
|
|
670
|
+
return errText(e);
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
name: 'memory_core',
|
|
676
|
+
description: '编辑核心记忆 MEMORY.md——它在每个会话的系统提示里常驻注入,务必保持精炼。action=append 追加到分节末尾;replace 整体替换分节;remove 删除分节。',
|
|
677
|
+
parameters: {
|
|
678
|
+
type: 'object',
|
|
679
|
+
additionalProperties: false,
|
|
680
|
+
properties: {
|
|
681
|
+
action: { type: 'string', enum: ['append', 'replace', 'remove'] },
|
|
682
|
+
section: { type: 'string', description: '分节名(MEMORY.md 里的 ## 标题),如:用户 / 环境 / 项目' },
|
|
683
|
+
text: { type: 'string', description: '要写入的内容(append/replace 时必填)' },
|
|
684
|
+
},
|
|
685
|
+
required: ['action', 'section'],
|
|
686
|
+
},
|
|
687
|
+
output: outputText,
|
|
688
|
+
async execute(args) {
|
|
689
|
+
const action = args.action;
|
|
690
|
+
if (!['append', 'replace', 'remove'].includes(action)) return 'action 必须是 append / replace / remove。';
|
|
691
|
+
const section = typeof args.section === 'string' ? args.section.trim() : '';
|
|
692
|
+
if (!section) return 'section 不能为空。';
|
|
693
|
+
const text = typeof args.text === 'string' ? args.text : '';
|
|
694
|
+
if (action !== 'remove' && !text.trim()) return `${action} 需要提供 text。`;
|
|
695
|
+
try {
|
|
696
|
+
return await enqueue(() => {
|
|
697
|
+
ensureDirs(p);
|
|
698
|
+
const r = editCore(p, config, action, section, text);
|
|
699
|
+
if (!r.ok) return r.message;
|
|
700
|
+
return r.message + afterWrite('memory_core');
|
|
701
|
+
});
|
|
702
|
+
} catch (e) {
|
|
703
|
+
return errText(e);
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
name: 'memory_search',
|
|
709
|
+
description: '按关键词检索归档记忆(标题/标签/正文),返回相关度排序的摘要。',
|
|
710
|
+
parameters: {
|
|
711
|
+
type: 'object',
|
|
712
|
+
additionalProperties: false,
|
|
713
|
+
properties: {
|
|
714
|
+
query: { type: 'string', description: '检索关键词,可用空格分隔多个' },
|
|
715
|
+
limit: { type: 'integer', description: '最多返回条数,默认 8' },
|
|
716
|
+
},
|
|
717
|
+
required: ['query'],
|
|
718
|
+
},
|
|
719
|
+
output: outputText,
|
|
720
|
+
async execute(args) {
|
|
721
|
+
try {
|
|
722
|
+
const hits = searchNotes(p, args.query, args.limit);
|
|
723
|
+
if (!hits.length) return `没有找到与「${args.query}」相关的记忆。`;
|
|
724
|
+
return hits
|
|
725
|
+
.map((h) => `- [${h.note.id}] ${h.note.title}${(h.note.tags ?? []).length ? ' #' + h.note.tags.join(' #') : ''}\n ${h.snippet}`)
|
|
726
|
+
.join('\n');
|
|
727
|
+
} catch (e) {
|
|
728
|
+
return errText(e);
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
name: 'memory_read',
|
|
734
|
+
description: '按 id 读取一条归档记忆的完整内容。',
|
|
735
|
+
parameters: {
|
|
736
|
+
type: 'object',
|
|
737
|
+
additionalProperties: false,
|
|
738
|
+
properties: { id: { type: 'string', description: '记忆 id(可带 .md 后缀,支持唯一前缀)' } },
|
|
739
|
+
required: ['id'],
|
|
740
|
+
},
|
|
741
|
+
output: outputText,
|
|
742
|
+
async execute(args) {
|
|
743
|
+
try {
|
|
744
|
+
const want = String(args.id ?? '').replace(/\.md$/, '').trim();
|
|
745
|
+
if (!want) return 'id 不能为空。';
|
|
746
|
+
const notes = loadIndex(p);
|
|
747
|
+
const exact = notes.find((n) => n.id === want);
|
|
748
|
+
const prefixed = exact ? [] : notes.filter((n) => n.id.startsWith(want));
|
|
749
|
+
const target = exact || (prefixed.length === 1 ? prefixed[0] : null);
|
|
750
|
+
if (!target) {
|
|
751
|
+
if (prefixed.length > 1) {
|
|
752
|
+
return `「${want}」匹配到 ${prefixed.length} 条,请给更长的前缀:\n` + prefixed.map((n) => `- ${n.id} ${n.title}`).join('\n');
|
|
753
|
+
}
|
|
754
|
+
return `没有找到 id 为「${want}」的记忆。`;
|
|
755
|
+
}
|
|
756
|
+
return readTextSafe(join(p.notes, `${target.id}.md`), '(文件缺失)');
|
|
757
|
+
} catch (e) {
|
|
758
|
+
return errText(e);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
{
|
|
763
|
+
name: 'memory_list',
|
|
764
|
+
description: '列出归档记忆(新→旧),可按标签过滤。',
|
|
765
|
+
parameters: {
|
|
766
|
+
type: 'object',
|
|
767
|
+
additionalProperties: false,
|
|
768
|
+
properties: {
|
|
769
|
+
limit: { type: 'integer', description: '最多返回条数,默认 20' },
|
|
770
|
+
tag: { type: 'string', description: '可选:只列带此标签的记忆' },
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
output: outputText,
|
|
774
|
+
async execute(args) {
|
|
775
|
+
try {
|
|
776
|
+
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 200));
|
|
777
|
+
const tag = typeof args.tag === 'string' ? args.tag.trim() : '';
|
|
778
|
+
let notes = loadIndex(p);
|
|
779
|
+
if (tag) notes = notes.filter((n) => (n.tags ?? []).includes(tag));
|
|
780
|
+
if (!notes.length) return tag ? `没有带标签「${tag}」的记忆。` : '记忆库还没有归档记忆。';
|
|
781
|
+
const shown = notes.slice(0, limit);
|
|
782
|
+
const lines = shown.map((n) => `- [${n.id}] ${n.title}${(n.tags ?? []).length ? ' #' + n.tags.join(' #') : ''}`);
|
|
783
|
+
const head = `归档记忆共 ${notes.length} 条${tag ? `(标签 ${tag})` : ''}${notes.length > limit ? `,显示前 ${limit} 条` : ''}:`;
|
|
784
|
+
return [head, ...lines].join('\n');
|
|
785
|
+
} catch (e) {
|
|
786
|
+
return errText(e);
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: 'memory_update',
|
|
792
|
+
description: '修改一条已有归档记忆(title / tags / content 三选一或多选)。id 支持唯一前缀。修改后 updated 时间会刷新,GitHub 后端会自动提交推送。',
|
|
793
|
+
parameters: {
|
|
794
|
+
type: 'object',
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
properties: {
|
|
797
|
+
id: { type: 'string', description: '记忆 id,支持唯一前缀' },
|
|
798
|
+
title: { type: 'string', description: '新标题(可选)' },
|
|
799
|
+
content: { type: 'string', description: '新正文(可选,整体替换)' },
|
|
800
|
+
tags: { type: 'array', items: { type: 'string' }, description: '新标签列表(可选,整体替换)' },
|
|
801
|
+
},
|
|
802
|
+
required: ['id'],
|
|
803
|
+
},
|
|
804
|
+
output: outputText,
|
|
805
|
+
async execute(args) {
|
|
806
|
+
try {
|
|
807
|
+
return await enqueue(() => {
|
|
808
|
+
const found = findNote(p, args.id);
|
|
809
|
+
if (found.error) return found.error;
|
|
810
|
+
const note = found.note;
|
|
811
|
+
const file = join(p.notes, `${note.id}.md`);
|
|
812
|
+
const parsed = parseNoteFile(readTextSafe(file));
|
|
813
|
+
const oldContent = parsed.content ?? '';
|
|
814
|
+
const nextMeta = {
|
|
815
|
+
id: note.id,
|
|
816
|
+
title: typeof args.title === 'string' && args.title.trim() ? args.title.replace(/\s+/g, ' ').trim() : note.title,
|
|
817
|
+
tags: Array.isArray(args.tags)
|
|
818
|
+
? args.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
|
|
819
|
+
: (note.tags ?? []),
|
|
820
|
+
created: note.created,
|
|
821
|
+
updated: new Date().toISOString(),
|
|
822
|
+
};
|
|
823
|
+
const nextContent = typeof args.content === 'string' ? args.content : oldContent;
|
|
824
|
+
if (!nextContent.trim()) return '更新失败:content 不能为空。';
|
|
825
|
+
const text = noteFileText(nextMeta, nextContent);
|
|
826
|
+
atomicWrite(file, text);
|
|
827
|
+
const notes = found.notes.map((n) => (n.id === note.id
|
|
828
|
+
? { ...n, title: nextMeta.title, tags: nextMeta.tags, updated: nextMeta.updated, size: Buffer.byteLength(text) }
|
|
829
|
+
: n));
|
|
830
|
+
saveIndex(p, notes);
|
|
831
|
+
const sync = afterWrite('memory_update');
|
|
832
|
+
return `已更新 [${note.id}] ${nextMeta.title}。${sync}`;
|
|
833
|
+
});
|
|
834
|
+
} catch (e) {
|
|
835
|
+
return errText(e);
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
name: 'memory_forget',
|
|
841
|
+
description: '处理一条不再需要的归档记忆。mode=archive(默认)移到 notes/archive/ 保留可回滚;mode=delete 直接从 notes/ 删除文件。',
|
|
842
|
+
parameters: {
|
|
843
|
+
type: 'object',
|
|
844
|
+
additionalProperties: false,
|
|
845
|
+
properties: {
|
|
846
|
+
id: { type: 'string', description: '记忆 id,支持唯一前缀' },
|
|
847
|
+
mode: { type: 'string', enum: ['archive', 'delete'], description: '默认 archive' },
|
|
848
|
+
},
|
|
849
|
+
required: ['id'],
|
|
850
|
+
},
|
|
851
|
+
output: outputText,
|
|
852
|
+
async execute(args) {
|
|
853
|
+
try {
|
|
854
|
+
return await enqueue(() => {
|
|
855
|
+
const found = findNote(p, args.id);
|
|
856
|
+
if (found.error) return found.error;
|
|
857
|
+
const note = found.note;
|
|
858
|
+
const mode = args.mode === 'delete' ? 'delete' : 'archive';
|
|
859
|
+
const file = join(p.notes, `${note.id}.md`);
|
|
860
|
+
if (mode === 'delete') {
|
|
861
|
+
if (existsSync(file)) unlinkSync(file);
|
|
862
|
+
} else {
|
|
863
|
+
archiveNoteFile(p, note.id);
|
|
864
|
+
}
|
|
865
|
+
saveIndex(p, found.notes.filter((n) => n.id !== note.id));
|
|
866
|
+
const sync = afterWrite(`memory_forget:${mode}`);
|
|
867
|
+
return `已${mode === 'delete' ? '删除' : '归档'} [${note.id}] ${note.title}。${sync}`;
|
|
868
|
+
});
|
|
869
|
+
} catch (e) {
|
|
870
|
+
return errText(e);
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
name: 'memory_stats',
|
|
876
|
+
description: '记忆库概况:归档条数、总大小、标签分布、核心记忆长度、后端与最近更新时间。',
|
|
877
|
+
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
|
878
|
+
output: outputText,
|
|
879
|
+
async execute() {
|
|
880
|
+
try {
|
|
881
|
+
const notes = loadIndex(p);
|
|
882
|
+
const tags = new Map();
|
|
883
|
+
let bytes = 0;
|
|
884
|
+
for (const n of notes) {
|
|
885
|
+
bytes += n.size ?? 0;
|
|
886
|
+
for (const t of n.tags ?? []) tags.set(t, (tags.get(t) ?? 0) + 1);
|
|
887
|
+
}
|
|
888
|
+
const core = readCore(p);
|
|
889
|
+
const lines = [
|
|
890
|
+
`目录:${p.dir}`,
|
|
891
|
+
`归档:${notes.length} 条,共 ${(bytes / 1024).toFixed(1)} KB`,
|
|
892
|
+
`核心记忆:${core.trim() ? `${core.length} 字符` : '空'}`,
|
|
893
|
+
`后端:${config.backend}${githubMode ? `(${config.remote || '未设 remote'} · ${config.branch})` : ''}`,
|
|
894
|
+
`最近更新:${notes[0]?.updated ?? '—'}`,
|
|
895
|
+
];
|
|
896
|
+
if (tags.size) {
|
|
897
|
+
lines.push(`标签:${[...tags.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `${t}(${c})`).join(' ')}`);
|
|
898
|
+
}
|
|
899
|
+
return lines.join('\n');
|
|
900
|
+
} catch (e) {
|
|
901
|
+
return errText(e);
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
name: 'memory_history',
|
|
907
|
+
description: '看记忆库的提交历史(仅 GitHub 后端)。返回最近若干次提交的 hash 与信息,用来追溯某条记忆是什么时候、被哪个工具写进来的。',
|
|
908
|
+
parameters: {
|
|
909
|
+
type: 'object',
|
|
910
|
+
additionalProperties: false,
|
|
911
|
+
properties: { limit: { type: 'integer', description: '条数,默认 10' } },
|
|
912
|
+
},
|
|
913
|
+
output: outputText,
|
|
914
|
+
async execute(args) {
|
|
915
|
+
if (!githubMode) return '当前是 local 后端,没有提交历史。';
|
|
916
|
+
try {
|
|
917
|
+
const n = Math.max(1, Math.min(Number(args.limit) || 10, 100));
|
|
918
|
+
const r = git(p, config, ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short']);
|
|
919
|
+
if (r.status !== 0) return `读取历史失败:${(r.stderr || r.stdout || '').trim() || `exit ${r.status}`}`;
|
|
920
|
+
return (r.stdout || '').trim() || '还没有提交记录。';
|
|
921
|
+
} catch (e) {
|
|
922
|
+
return errText(e);
|
|
923
|
+
}
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
name: 'memory_sync',
|
|
928
|
+
description: 'GitHub 后端专用:action=status 看仓库状态,pull 拉取远端,push 提交并推送本地改动。local 后端调用会提示未启用。',
|
|
929
|
+
parameters: {
|
|
930
|
+
type: 'object',
|
|
931
|
+
additionalProperties: false,
|
|
932
|
+
properties: {
|
|
933
|
+
action: { type: 'string', enum: ['status', 'pull', 'push'], description: '默认 status' },
|
|
934
|
+
message: { type: 'string', description: 'push 时的提交信息,可选' },
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
output: outputText,
|
|
938
|
+
async execute(args) {
|
|
939
|
+
const action = args.action || 'status';
|
|
940
|
+
if (!githubMode) return '当前是 local 后端(config.backend=local),没有 git 同步。';
|
|
941
|
+
try {
|
|
942
|
+
if (!gitAvailable(config)) return '找不到 git 可执行文件(config.gitBin)。';
|
|
943
|
+
if (!isRepo(p)) return `目录还不是 git 仓库:${p.dir}\n先 git clone 或在配置里给 remote。`;
|
|
944
|
+
if (action === 'pull') {
|
|
945
|
+
const r = doPull();
|
|
946
|
+
return `拉取${r.ok ? '成功' : '失败'}:${r.message}`;
|
|
947
|
+
}
|
|
948
|
+
if (action === 'push') {
|
|
949
|
+
if (config.readOnly) return '只读模式(readOnly: true)不允许 push。';
|
|
950
|
+
const r = doPush(String(args.message || config.commitMessage).replace('{tool}', 'memory_sync'));
|
|
951
|
+
return r.message;
|
|
952
|
+
}
|
|
953
|
+
const st = git(p, config, ['status', '--porcelain'], { allowFail: true });
|
|
954
|
+
const counts = git(p, config, ['rev-list', '--left-right', '--count', `origin/${config.branch}...HEAD`], { allowFail: true });
|
|
955
|
+
const last = git(p, config, ['log', '-1', '--pretty=%h %s'], { allowFail: true });
|
|
956
|
+
return [
|
|
957
|
+
`目录:${p.dir}`,
|
|
958
|
+
`远端:${(git(p, config, ['remote', 'get-url', 'origin'], { allowFail: true }).stdout || '').trim() || '(未设置)'}`,
|
|
959
|
+
`分支:${config.branch}`,
|
|
960
|
+
`最近提交:${(last.stdout || '').trim() || '(还没有提交)'}`,
|
|
961
|
+
`未提交改动:${st.stdout.trim() ? st.stdout.trim().split('\n').length + ' 个文件' : '无'}`,
|
|
962
|
+
`与远端差异(左=远端独有 右=本地独有):${counts.stdout.trim() || '未知'}`,
|
|
963
|
+
].join('\n');
|
|
964
|
+
} catch (e) {
|
|
965
|
+
return errText(e);
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
];
|
|
970
|
+
|
|
971
|
+
const WRITE_TOOLS = new Set(['memory_write', 'memory_core', 'memory_update', 'memory_forget']);
|
|
972
|
+
const registered = [];
|
|
973
|
+
for (const def of defs) {
|
|
974
|
+
if (readOnly && WRITE_TOOLS.has(def.name)) continue;
|
|
975
|
+
ctx.tools.register(def);
|
|
976
|
+
registered.push(def.name);
|
|
977
|
+
}
|
|
978
|
+
console.log(`[dsh-memory-github] 已加载:backend=${config.backend} dir=${p.dir} 工具=${registered.join(',')}`);
|
|
979
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-memory-github",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Cross-session memory for DeepSeek Harness. Local memory store by default, optional GitHub-backed sync (git), with primary/child roles for multi-device setups.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./package.json": "./package.json",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CHANGELOG.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"dsh-plugin",
|
|
22
|
+
"deepseek-harness",
|
|
23
|
+
"deepseek",
|
|
24
|
+
"cordis",
|
|
25
|
+
"memory",
|
|
26
|
+
"cross-session",
|
|
27
|
+
"git",
|
|
28
|
+
"github",
|
|
29
|
+
"sync"
|
|
30
|
+
],
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "",
|
|
33
|
+
"homepage": "https://github.com/Administrator016/dsh-memory-github#readme",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Administrator016/dsh-memory-github.git"
|
|
37
|
+
},
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/Administrator016/dsh-memory-github/issues"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"dsh": ">=0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|