dsh-memory-github 0.2.0 → 0.4.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 CHANGED
@@ -1,5 +1,56 @@
1
1
  # 更新日志
2
2
 
3
+ ## 0.4.0 — 2026-10-25
4
+
5
+ 定位从「又一个跨会话记忆插件」改成「**多端共享记忆**」——本地记忆库的同类插件已有上百个,
6
+ 多设备共用一份记忆才是这个项目的差异点。
7
+
8
+ ### 新增
9
+
10
+ - **`autoRecall` 自动召回(默认开)**:每轮从当前用户消息抽检索词(英文整词、中文双字滑窗),
11
+ 给归档打分(标题 ×3 / 标签 ×2 / 正文 ×1),把最相关的几条直接注入系统提示。
12
+ 不需要模型"想起来去 `memory_search`"——这是竞品最影响体感的能力,用纯词袋实现,
13
+ 不发 LLM 请求、不引入数据库。
14
+ - `autoRecall: false` 关闭
15
+ - `autoRecallLimit: 5` 最多注入几条
16
+ - `autoRecallMinScore: 4` 分数门槛(调高更保守,避免无关记忆干扰)
17
+ - 用户消息文本从 `user/message` 事件的 `content` 取(兼容字符串与 block 数组两种形态)。
18
+
19
+ ### 文档
20
+
21
+ - README 重写开头:说明「为什么不跟本地库插件比功能」、适合与不适合的场景
22
+ - 特性列表、配置示例补上 `autoRecall` 三项
23
+
24
+ ### 测试
25
+
26
+ - 冒烟测试 63 → **68 条**(覆盖:命中相关归档、不注入无关归档、无相关时不硬塞、关闭开关)
27
+
28
+ ## 0.3.0 — 2026-10-25
29
+
30
+ 参考同类插件(dsh-prime-memory、meow-memory、@try-works/dsh-memory、@modusensus/dsh-mneme 等
31
+ 百余个生态作品)后补齐的「记忆全生命周期」能力。仍然零依赖、纯 Markdown + git。
32
+
33
+ ### 新增
34
+
35
+ - **`memory_update`**:改已有归档的 title / tags / content,刷新 `updated` 并自动提交推送。
36
+ - **`memory_forget`**:`archive`(移到 `notes/archive/`,可回滚)或 `delete`(直接删文件)。
37
+ - **`memory_stats`**:条数、总大小、标签分布、核心记忆长度、后端信息、最近更新时间。
38
+ - **`memory_history`**:GitHub 后端的提交历史,可追溯某条记忆何时被哪个工具写进来。
39
+ - **`recentLimit`**:每轮在系统提示末尾注入最近 N 条归档的 id / 标题 / 标签(默认关)。
40
+ 竞品普遍靠「自动召回」提升命中率;这个是最便宜的近似做法,不引入数据库。
41
+ - **查重提示**:`memory_write` 写入前与已有归档做词袋 Jaccard 比较(≥45%),
42
+ 命中就在返回里提示「考虑用 `memory_update`」,减少同一件事写两遍。可用 `dedupeHint: false` 关闭。
43
+
44
+ ### 改进
45
+
46
+ - `findNote` 现在支持三种定位方式:id 精确 / id 前缀 / **标题** / id 含该 slug,
47
+ 并且多处命中时明确列出候选,不再直接报"找不到"。
48
+ - 只读模式(`role: child`)下新增的两个读工具 `memory_stats`、`memory_history` 也可用。
49
+
50
+ ### 测试
51
+
52
+ - 冒烟测试从 42 条扩到 **63 条**(覆盖 update / forget / stats / history / recentLimit / 查重)。
53
+
3
54
  ## 0.2.0 — 2026-10-25
4
55
 
5
56
  ### 新增
package/README.md CHANGED
@@ -3,22 +3,39 @@
3
3
  [![test](https://github.com/Administrator016/dsh-memory-github/actions/workflows/test.yml/badge.svg)](https://github.com/Administrator016/dsh-memory-github/actions/workflows/test.yml)
4
4
  [![license](https://img.shields.io/github/license/Administrator016/dsh-memory-github)](LICENSE)
5
5
 
6
- 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 用的**跨会话记忆插件**。
6
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 用的**多端共享记忆插件**。
7
7
 
8
- 默认把记忆存在本地目录;想要换机器也能接着用,就把它接成一个 **GitHub 私有仓库**——
9
- 插件会在每次写入后自动 commit + push,启动时自动 pull。
8
+ 生态里同类插件有上百个(`dsh-prime-memory`、`meow-memory`、`dsh-mnemonic`…),绝大多数是
9
+ **本地库**:SQLite、向量索引、面板 UI,功能很强,但**只在那一台机器上**。
10
+ 这个插件的取舍相反——不引入数据库、不调 embedding,只用一个目录 + 一个 git 仓库,
11
+ 把「手机 / 电脑 / 移动硬盘」这些设备连成**同一份记忆**:
12
+
13
+ - 一台机当**整理端**(`role: primary`)负责写和推
14
+ - 其余机器当**子端**(`role: child`)只读,想留话就写 `inbox/`,整理端处理完归档
15
+ - 换机器:`git clone` 或拷贝目录即恢复;连不联网都能用
10
16
 
11
17
  > 记忆分两层:**核心记忆**(`MEMORY.md`,每轮注入系统提示,硬上限可配)和**归档记忆**
12
- > (`notes/*.md` + `index.json`,按关键词检索)。模型自己决定什么时候写、写什么。
18
+ > (`notes/*.md` + `index.json`,按关键词检索 + **自动召回**)。模型自己决定什么时候写、写什么。
13
19
 
14
20
  ## 特性
15
21
 
16
22
  - 🧠 **两段式记忆**:核心记忆常驻注入,归档记忆按需检索(`memory_search` / `memory_read`)
17
- - 💾 **默认本地**:一个目录就是记忆库(Markdown + JSON,随便备份、随便看)
18
- - ☁️ **可选 GitHub**:同一个目录套上 git,写入自动推送、启动自动拉取
19
- - 🔒 **只读模式**:`readOnly: true` 只注册读工具,适合"手机写入、电脑只读"这类多设备分工
23
+ - 🎯 **自动召回**:每轮按你的消息自动挑几条相关归档注入(无需模型想起来去 search)
24
+ - 🌐 **多端共享**:`primary` / `child` 角色分工,GitHub 做同步总线
25
+ - 💾 **零依赖**:一个目录就是记忆库(Markdown + JSON),可读、可审计、可 `git diff`
26
+ - ☁️ **可选 GitHub**:写入自动 commit + push,启动自动 pull,冲突自动 `rebase --abort`
27
+ - 🔒 **只读模式**:`readOnly: true` 只注册读工具,适合"手机写入、电脑只读"这类分工
20
28
  - 🩹 **索引自愈**:`index.json` 丢了会从 `notes/` 重建,不会因为一个文件损坏就丢记忆
21
- - 🧪 **自带冒烟测试**:`node test/smoke.mjs`,30 条断言不依赖 DSH 运行时
29
+ - 🧪 **自带测试**:`node test/smoke.mjs`(68 条断言)+ `test/git-backend.mjs`(12 条),不依赖 DSH 运行时
30
+
31
+ ## 什么时候适合用它
32
+
33
+ ✅ 你有多台设备(手机 + 电脑),想让它们记住同一批事
34
+ ✅ 你想知道 AI 到底记住了什么(纯 Markdown,直接打开看)
35
+ ✅ 记忆要能带走、能版本管理、能自己审查
36
+
37
+ ❌ 你要的是单机最强检索(那去用带 BM25 / 向量库的插件)
38
+ ❌ 你要的是聊天框里的记忆面板 UI(本项目没有客户端插件)
22
39
 
23
40
  ## 两种后端
24
41
 
@@ -29,6 +46,7 @@
29
46
  | 多设备 | 各存各的 | 共享一份,自动 pull / push |
30
47
  | 依赖 | 无 | `git` 可执行文件 |
31
48
 
49
+
32
50
  ## 安装
33
51
 
34
52
  **方式一:npm(推荐)**
@@ -81,6 +99,11 @@ dsh plugin --profile desktop add link:/path/to/dsh-memory-github
81
99
  pushOnTurnEnd: false # 每轮结束若有未提交改动就推送
82
100
  remindEvery: 6 # 每 N 条用户消息没动记忆 → 注入提醒(0=关)
83
101
  staleMinutes: 0 # 距上次同步超过 N 分钟 → 提示先 pull(0=关)
102
+ autoRecall: true # 按当前用户消息自动召回相关归档(默认开)
103
+ autoRecallLimit: 5 # 自动召回最多注入几条
104
+ autoRecallMinScore: 4 # 自动召回打分门槛,调高更保守
105
+ recentLimit: 0 # 额外注入最近 N 条归档标题(0=关)
106
+ dedupeHint: true # 写入时提示与已有归档高度相似
84
107
  commitMessage: 'memory: {tool}' # {tool} 会替换成触发的工具名
85
108
  gitBin: git # git 可执行文件(不在 PATH 就写绝对路径)
86
109
  authorName: dsh-memory
@@ -113,13 +136,24 @@ dsh plugin --profile desktop add link:/path/to/dsh-memory-github
113
136
 
114
137
  | 工具 | 作用 |
115
138
  |---|---|
116
- | `memory_write` | 保存一条归档记忆(title / content / tags),立刻进索引 |
139
+ | `memory_write` | 保存一条归档记忆(title / content / tags),立刻进索引;写入前会做**相似度查重** |
140
+ | `memory_update` | 改已有归档的 title / tags / content,刷新 `updated` |
141
+ | `memory_forget` | `archive`(移到 `notes/archive/` 可回滚)或 `delete` |
117
142
  | `memory_core` | 编辑 `MEMORY.md`:`append` / `replace` / `remove` 某个分节 |
118
143
  | `memory_search` | 按关键词检索(标题权重 6、标签 3、正文按出现次数),返回摘要 |
119
144
  | `memory_read` | 按 id 读全文,支持唯一前缀 |
120
145
  | `memory_list` | 列出归档(新→旧),可按标签过滤 |
146
+ | `memory_stats` | 条数、总大小、标签分布、核心记忆长度、后端与最近更新 |
147
+ | `memory_history` | 提交历史(GitHub 后端),追溯某条记忆是何时被哪个工具写进来的 |
121
148
  | `memory_sync` | `status` / `pull` / `push`,GitHub 后端专用 |
122
149
 
150
+ **查重**:`memory_write` 会把新内容与已有归档做词袋相似度比较(Jaccard ≥ 45% 视为高度相似),
151
+ 命中时在返回里提示「考虑改用 `memory_update`」。关掉:`dedupeHint: false`。
152
+
153
+ **最近归档注入**:`recentLimit: 5` 会在每轮系统提示末尾附上最近 5 条归档的 id/标题/标签,
154
+ 让模型"看得见目录",召回率比纯靠 `memory_search` 高。默认关闭(`0`)。
155
+
156
+
123
157
  模型侧的使用约定由插件注入的系统提示承载:核心记忆里写"永远要记住的",
124
158
  归档里写"以后可能用得上、但不用每轮都占上下文的"。
125
159
 
package/lib/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * - readOnly=true 时只注册读工具,适合"手机写入、电脑只读"这类分工
13
13
  */
14
14
  import {
15
- existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync,
15
+ existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync,
16
16
  } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { homedir } from 'node:os';
@@ -34,6 +34,11 @@ const DEFAULTS = {
34
34
  pushOnTurnEnd: false, // 每轮结束时若有未提交改动就推送
35
35
  remindEvery: 6, // 每 N 条用户消息、且期间没动过记忆 → 提醒(0=关)
36
36
  staleMinutes: 0, // 距上次同步超过 N 分钟 → 提醒(0=关)
37
+ recentLimit: 0, // 每轮额外注入最近 N 条归档的标题与标签(0=关)
38
+ autoRecall: true, // 按当前用户消息自动召回相关归档并注入(不用模型主动 search)
39
+ autoRecallLimit: 5, // 自动召回最多注入几条
40
+ autoRecallMinScore: 4, // 自动召回打分门槛,越高越保守
41
+ dedupeHint: true, // 写入时检查是否与已有归档高度相似
37
42
  commitMessage: 'memory: {tool}',
38
43
  gitBin: 'git',
39
44
  authorName: 'dsh-memory',
@@ -179,6 +184,148 @@ function saveIndex(p, notes) {
179
184
  return sorted;
180
185
  }
181
186
 
187
+ /** 按 id(或唯一前缀)找到一条归档。 */
188
+ function findNote(p, want) {
189
+ const id = String(want ?? '').replace(/\.md$/, '').trim();
190
+ if (!id) return { error: 'id 不能为空。' };
191
+ const notes = loadIndex(p);
192
+ const exact = notes.find((n) => n.id === id);
193
+ if (exact) return { note: exact, notes };
194
+
195
+ // 依次尝试:id 前缀 / 标题(忽略大小写)/ id 后缀(用户常只给 slug)
196
+ const byPrefix = notes.filter((n) => n.id.startsWith(id));
197
+ if (byPrefix.length === 1) return { note: byPrefix[0], notes };
198
+ const lower = id.toLowerCase();
199
+ const byTitle = notes.filter((n) => String(n.title ?? '').toLowerCase() === lower);
200
+ if (byTitle.length === 1) return { note: byTitle[0], notes };
201
+ const bySuffix = notes.filter((n) => n.id.includes(`-${id}`));
202
+ if (bySuffix.length === 1) return { note: bySuffix[0], notes };
203
+
204
+ const candidates = [...byPrefix, ...byTitle, ...bySuffix].filter((n, i, arr) => arr.findIndex((x) => x.id === n.id) === i);
205
+ if (candidates.length > 1) {
206
+ return {
207
+ error: `「${id}」匹配到 ${candidates.length} 条,请给更长前缀或完整 id:\n`
208
+ + candidates.map((n) => `- ${n.id} ${n.title}`).join('\n'),
209
+ };
210
+ }
211
+ return { error: `没有找到 id 为「${id}」的记忆。` };
212
+ }
213
+
214
+ /** 词袋:用于相似度判断(标题 / 标签 / 正文前 600 字)。 */
215
+ function tokenSet(text) {
216
+ const set = new Set();
217
+ for (const tok of String(text ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
218
+ if (tok && tok.length > 1) set.add(tok);
219
+ }
220
+ return set;
221
+ }
222
+
223
+ function jaccard(a, b) {
224
+ if (!a.size || !b.size) return 0;
225
+ let inter = 0;
226
+ for (const x of a) if (b.has(x)) inter += 1;
227
+ return inter / (a.size + b.size - inter);
228
+ }
229
+
230
+ /** 写入前查重:返回最多 3 条高相似(Jaccard ≥ 0.45)的归档。 */
231
+ function similarNotes(p, title, content) {
232
+ const mine = tokenSet(`${title} ${String(content ?? '').slice(0, 600)}`);
233
+ const hits = [];
234
+ for (const n of loadIndex(p)) {
235
+ // 只比正文,不要把 frontmatter 里的 id/时间戳算进相似度,否则会把相似度稀释掉
236
+ const parsed = parseNoteFile(readTextSafe(join(p.notes, `${n.id}.md`), ''));
237
+ const body = parsed.content ?? '';
238
+ const theirs = tokenSet(`${n.title} ${(n.tags ?? []).join(' ')} ${body.slice(0, 600)}`);
239
+ const score = jaccard(mine, theirs);
240
+ if (score >= 0.45) hits.push({ id: n.id, title: n.title, score });
241
+ }
242
+ hits.sort((a, b) => b.score - a.score);
243
+ return hits.slice(0, 3);
244
+ }
245
+
246
+ /** 把归档移到 notes/archive/(保留内容,可回滚)。 */
247
+ function archiveNoteFile(p, id) {
248
+ const dir = join(p.notes, 'archive');
249
+ mkdirSync(dir, { recursive: true });
250
+ const from = join(p.notes, `${id}.md`);
251
+ const to = join(dir, `${id}.md`);
252
+ renameSync(from, to);
253
+ return to;
254
+ }
255
+
256
+ /** 最近 N 条归档的标题清单,用于注入系统提示。 */
257
+ function recentSection(p, limit) {
258
+ const n = Number(limit);
259
+ if (!Number.isFinite(n) || n <= 0) return '';
260
+ const notes = loadIndex(p).slice(0, Math.floor(n));
261
+ if (!notes.length) return '';
262
+ const lines = notes.map((x) => `- \`${x.id}\` ${x.title}${(x.tags ?? []).length ? ' #' + x.tags.join(' #') : ''}`);
263
+ return ['', `### 最近归档(${notes.length} 条,细节用 memory_search)`, ...lines].join('\n');
264
+ }
265
+
266
+ /**
267
+ * 从用户消息里抽检索词:英文/数字按整词,中文按双字滑窗(中文没有空格,整句当词反而匹配不上)。
268
+ */
269
+ function recallTerms(text) {
270
+ const terms = new Set();
271
+ for (const w of String(text ?? '').toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
272
+ if (!w) continue;
273
+ if (/^[a-z0-9]+$/.test(w)) {
274
+ if (w.length > 1) terms.add(w);
275
+ } else {
276
+ for (let i = 0; i < w.length - 1; i += 1) terms.add(w.slice(i, i + 2));
277
+ }
278
+ }
279
+ return [...terms].slice(0, 150);
280
+ }
281
+
282
+ /**
283
+ * 自动召回:拿当前用户消息的词去给归档打分,注入最相关的几条。
284
+ * 竞品的卖点就是"不用问它就记得",这里用纯词袋实现,不发 LLM 请求、不需要数据库。
285
+ */
286
+ function autoRecallSection(p, config, query) {
287
+ if (config.autoRecall === false) return '';
288
+ const limit = Number(config.autoRecallLimit);
289
+ if (!Number.isFinite(limit) || limit <= 0) return '';
290
+ const terms = recallTerms(query);
291
+ if (!terms.length) return '';
292
+ const minScore = Number.isFinite(Number(config.autoRecallMinScore)) ? Number(config.autoRecallMinScore) : 4;
293
+
294
+ const scored = [];
295
+ for (const n of loadIndex(p)) {
296
+ const parsed = parseNoteFile(readTextSafe(join(p.notes, `${n.id}.md`), ''));
297
+ const title = String(n.title ?? '').toLowerCase();
298
+ const tags = (n.tags ?? []).join(' ').toLowerCase();
299
+ const body = (parsed.content ?? '').toLowerCase();
300
+ let score = 0;
301
+ for (const t of terms) {
302
+ if (title.includes(t)) score += 3;
303
+ if (tags.includes(t)) score += 2;
304
+ if (body.includes(t)) score += 1;
305
+ }
306
+ if (score >= minScore) scored.push({ n, score });
307
+ }
308
+ if (!scored.length) return '';
309
+ scored.sort((a, b) => b.score - a.score);
310
+ const top = scored.slice(0, Math.floor(limit));
311
+ const lines = top.map(({ n }) => `- \`${n.id}\` ${n.title}${(n.tags ?? []).length ? ' #' + n.tags.join(' #') : ''}`);
312
+ return [
313
+ '',
314
+ `### 可能与当前任务相关的归档(自动召回 ${top.length} 条,细节用 memory_read / memory_search)`,
315
+ ...lines,
316
+ ].join('\n');
317
+ }
318
+
319
+ /** 把一条 user/message 事件的 content 取成纯文本。 */
320
+ function messageText(data) {
321
+ const c = data?.content;
322
+ if (typeof c === 'string') return c;
323
+ if (Array.isArray(c)) {
324
+ return c.map((b) => (typeof b === 'string' ? b : (b?.text ?? ''))).join('\n');
325
+ }
326
+ return '';
327
+ }
328
+
182
329
  // ---------------------------------------------------------------- core 记忆
183
330
 
184
331
  function coreLimit(config) {
@@ -446,7 +593,7 @@ export function apply(ctx, config0) {
446
593
  const readOnly = Boolean(config.readOnly) || config.role === 'child';
447
594
 
448
595
  // 提醒状态:消息计数 / 最近一次记忆活动 / 最近一次成功同步
449
- const syncState = { count: 0, lastActivity: activityStamp(p), lastSyncAt: Date.now() };
596
+ const syncState = { count: 0, lastActivity: activityStamp(p), lastSyncAt: Date.now(), lastUserText: '' };
450
597
 
451
598
  const doPull = () => {
452
599
  const r = gitPull(p, config);
@@ -495,7 +642,9 @@ export function apply(ctx, config0) {
495
642
  name: 'dsh-memory-github:core',
496
643
  order: 60,
497
644
  text: () => {
498
- const base = coreSection(readCore(p), coreLimit(config));
645
+ const base = coreSection(readCore(p), coreLimit(config))
646
+ + recentSection(p, config.recentLimit)
647
+ + autoRecallSection(p, config, syncState.lastUserText);
499
648
  const stamp = activityStamp(p);
500
649
  if (stamp > syncState.lastActivity) {
501
650
  // 记忆被写过(本进程写的,或别的进程/设备同步下来的),计数归零
@@ -528,6 +677,7 @@ export function apply(ctx, config0) {
528
677
  if (!event || typeof event.type !== 'string') return;
529
678
  if (event.type === 'user/message') {
530
679
  syncState.count += 1;
680
+ syncState.lastUserText = messageText(event.data);
531
681
  } else if (event.type === 'turn/end' && githubMode && config.pushOnTurnEnd && !readOnly) {
532
682
  enqueue(() => {
533
683
  try {
@@ -564,6 +714,9 @@ export function apply(ctx, config0) {
564
714
  ? args.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
565
715
  : [];
566
716
  try {
717
+ const dupes = config.dedupeHint === false
718
+ ? []
719
+ : similarNotes(p, title, content);
567
720
  return await enqueue(() => {
568
721
  ensureDirs(p);
569
722
  const notes = loadIndex(p);
@@ -576,7 +729,11 @@ export function apply(ctx, config0) {
576
729
  atomicWrite(join(p.notes, `${id}.md`), text);
577
730
  saveIndex(p, [meta, ...notes]);
578
731
  const sync = afterWrite('memory_write');
579
- return `已保存 [${id}] ${title}${tags.length ? ' #' + tags.join(' #') : ''}。${sync}`;
732
+ const hint = dupes.length
733
+ ? '\n注意:和已有归档高度相似,考虑改用 memory_update:\n'
734
+ + dupes.map((d) => `- ${d.id} ${d.title}(相似度 ${(d.score * 100).toFixed(0)}%)`).join('\n')
735
+ : '';
736
+ return `已保存 [${id}] ${title}${tags.length ? ' #' + tags.join(' #') : ''}。${sync}${hint}`;
580
737
  });
581
738
  } catch (e) {
582
739
  return errText(e);
@@ -699,6 +856,142 @@ export function apply(ctx, config0) {
699
856
  }
700
857
  },
701
858
  },
859
+ {
860
+ name: 'memory_update',
861
+ description: '修改一条已有归档记忆(title / tags / content 三选一或多选)。id 支持唯一前缀。修改后 updated 时间会刷新,GitHub 后端会自动提交推送。',
862
+ parameters: {
863
+ type: 'object',
864
+ additionalProperties: false,
865
+ properties: {
866
+ id: { type: 'string', description: '记忆 id,支持唯一前缀' },
867
+ title: { type: 'string', description: '新标题(可选)' },
868
+ content: { type: 'string', description: '新正文(可选,整体替换)' },
869
+ tags: { type: 'array', items: { type: 'string' }, description: '新标签列表(可选,整体替换)' },
870
+ },
871
+ required: ['id'],
872
+ },
873
+ output: outputText,
874
+ async execute(args) {
875
+ try {
876
+ return await enqueue(() => {
877
+ const found = findNote(p, args.id);
878
+ if (found.error) return found.error;
879
+ const note = found.note;
880
+ const file = join(p.notes, `${note.id}.md`);
881
+ const parsed = parseNoteFile(readTextSafe(file));
882
+ const oldContent = parsed.content ?? '';
883
+ const nextMeta = {
884
+ id: note.id,
885
+ title: typeof args.title === 'string' && args.title.trim() ? args.title.replace(/\s+/g, ' ').trim() : note.title,
886
+ tags: Array.isArray(args.tags)
887
+ ? args.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
888
+ : (note.tags ?? []),
889
+ created: note.created,
890
+ updated: new Date().toISOString(),
891
+ };
892
+ const nextContent = typeof args.content === 'string' ? args.content : oldContent;
893
+ if (!nextContent.trim()) return '更新失败:content 不能为空。';
894
+ const text = noteFileText(nextMeta, nextContent);
895
+ atomicWrite(file, text);
896
+ const notes = found.notes.map((n) => (n.id === note.id
897
+ ? { ...n, title: nextMeta.title, tags: nextMeta.tags, updated: nextMeta.updated, size: Buffer.byteLength(text) }
898
+ : n));
899
+ saveIndex(p, notes);
900
+ const sync = afterWrite('memory_update');
901
+ return `已更新 [${note.id}] ${nextMeta.title}。${sync}`;
902
+ });
903
+ } catch (e) {
904
+ return errText(e);
905
+ }
906
+ },
907
+ },
908
+ {
909
+ name: 'memory_forget',
910
+ description: '处理一条不再需要的归档记忆。mode=archive(默认)移到 notes/archive/ 保留可回滚;mode=delete 直接从 notes/ 删除文件。',
911
+ parameters: {
912
+ type: 'object',
913
+ additionalProperties: false,
914
+ properties: {
915
+ id: { type: 'string', description: '记忆 id,支持唯一前缀' },
916
+ mode: { type: 'string', enum: ['archive', 'delete'], description: '默认 archive' },
917
+ },
918
+ required: ['id'],
919
+ },
920
+ output: outputText,
921
+ async execute(args) {
922
+ try {
923
+ return await enqueue(() => {
924
+ const found = findNote(p, args.id);
925
+ if (found.error) return found.error;
926
+ const note = found.note;
927
+ const mode = args.mode === 'delete' ? 'delete' : 'archive';
928
+ const file = join(p.notes, `${note.id}.md`);
929
+ if (mode === 'delete') {
930
+ if (existsSync(file)) unlinkSync(file);
931
+ } else {
932
+ archiveNoteFile(p, note.id);
933
+ }
934
+ saveIndex(p, found.notes.filter((n) => n.id !== note.id));
935
+ const sync = afterWrite(`memory_forget:${mode}`);
936
+ return `已${mode === 'delete' ? '删除' : '归档'} [${note.id}] ${note.title}。${sync}`;
937
+ });
938
+ } catch (e) {
939
+ return errText(e);
940
+ }
941
+ },
942
+ },
943
+ {
944
+ name: 'memory_stats',
945
+ description: '记忆库概况:归档条数、总大小、标签分布、核心记忆长度、后端与最近更新时间。',
946
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
947
+ output: outputText,
948
+ async execute() {
949
+ try {
950
+ const notes = loadIndex(p);
951
+ const tags = new Map();
952
+ let bytes = 0;
953
+ for (const n of notes) {
954
+ bytes += n.size ?? 0;
955
+ for (const t of n.tags ?? []) tags.set(t, (tags.get(t) ?? 0) + 1);
956
+ }
957
+ const core = readCore(p);
958
+ const lines = [
959
+ `目录:${p.dir}`,
960
+ `归档:${notes.length} 条,共 ${(bytes / 1024).toFixed(1)} KB`,
961
+ `核心记忆:${core.trim() ? `${core.length} 字符` : '空'}`,
962
+ `后端:${config.backend}${githubMode ? `(${config.remote || '未设 remote'} · ${config.branch})` : ''}`,
963
+ `最近更新:${notes[0]?.updated ?? '—'}`,
964
+ ];
965
+ if (tags.size) {
966
+ lines.push(`标签:${[...tags.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `${t}(${c})`).join(' ')}`);
967
+ }
968
+ return lines.join('\n');
969
+ } catch (e) {
970
+ return errText(e);
971
+ }
972
+ },
973
+ },
974
+ {
975
+ name: 'memory_history',
976
+ description: '看记忆库的提交历史(仅 GitHub 后端)。返回最近若干次提交的 hash 与信息,用来追溯某条记忆是什么时候、被哪个工具写进来的。',
977
+ parameters: {
978
+ type: 'object',
979
+ additionalProperties: false,
980
+ properties: { limit: { type: 'integer', description: '条数,默认 10' } },
981
+ },
982
+ output: outputText,
983
+ async execute(args) {
984
+ if (!githubMode) return '当前是 local 后端,没有提交历史。';
985
+ try {
986
+ const n = Math.max(1, Math.min(Number(args.limit) || 10, 100));
987
+ const r = git(p, config, ['log', `-${n}`, '--pretty=format:%h %ad %s', '--date=short']);
988
+ if (r.status !== 0) return `读取历史失败:${(r.stderr || r.stdout || '').trim() || `exit ${r.status}`}`;
989
+ return (r.stdout || '').trim() || '还没有提交记录。';
990
+ } catch (e) {
991
+ return errText(e);
992
+ }
993
+ },
994
+ },
702
995
  {
703
996
  name: 'memory_sync',
704
997
  description: 'GitHub 后端专用:action=status 看仓库状态,pull 拉取远端,push 提交并推送本地改动。local 后端调用会提示未启用。',
@@ -744,7 +1037,7 @@ export function apply(ctx, config0) {
744
1037
  },
745
1038
  ];
746
1039
 
747
- const WRITE_TOOLS = new Set(['memory_write', 'memory_core']);
1040
+ const WRITE_TOOLS = new Set(['memory_write', 'memory_core', 'memory_update', 'memory_forget']);
748
1041
  const registered = [];
749
1042
  for (const def of defs) {
750
1043
  if (readOnly && WRITE_TOOLS.has(def.name)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-github",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
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
5
  "type": "module",
6
6
  "main": "lib/index.js",