dsh-memory-github 0.2.0 → 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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # 更新日志
2
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
+
3
29
  ## 0.2.0 — 2026-10-25
4
30
 
5
31
  ### 新增
package/README.md CHANGED
@@ -113,13 +113,24 @@ dsh plugin --profile desktop add link:/path/to/dsh-memory-github
113
113
 
114
114
  | 工具 | 作用 |
115
115
  |---|---|
116
- | `memory_write` | 保存一条归档记忆(title / content / tags),立刻进索引 |
116
+ | `memory_write` | 保存一条归档记忆(title / content / tags),立刻进索引;写入前会做**相似度查重** |
117
+ | `memory_update` | 改已有归档的 title / tags / content,刷新 `updated` |
118
+ | `memory_forget` | `archive`(移到 `notes/archive/` 可回滚)或 `delete` |
117
119
  | `memory_core` | 编辑 `MEMORY.md`:`append` / `replace` / `remove` 某个分节 |
118
120
  | `memory_search` | 按关键词检索(标题权重 6、标签 3、正文按出现次数),返回摘要 |
119
121
  | `memory_read` | 按 id 读全文,支持唯一前缀 |
120
122
  | `memory_list` | 列出归档(新→旧),可按标签过滤 |
123
+ | `memory_stats` | 条数、总大小、标签分布、核心记忆长度、后端与最近更新 |
124
+ | `memory_history` | 提交历史(GitHub 后端),追溯某条记忆是何时被哪个工具写进来的 |
121
125
  | `memory_sync` | `status` / `pull` / `push`,GitHub 后端专用 |
122
126
 
127
+ **查重**:`memory_write` 会把新内容与已有归档做词袋相似度比较(Jaccard ≥ 45% 视为高度相似),
128
+ 命中时在返回里提示「考虑改用 `memory_update`」。关掉:`dedupeHint: false`。
129
+
130
+ **最近归档注入**:`recentLimit: 5` 会在每轮系统提示末尾附上最近 5 条归档的 id/标题/标签,
131
+ 让模型"看得见目录",召回率比纯靠 `memory_search` 高。默认关闭(`0`)。
132
+
133
+
123
134
  模型侧的使用约定由插件注入的系统提示承载:核心记忆里写"永远要记住的",
124
135
  归档里写"以后可能用得上、但不用每轮都占上下文的"。
125
136
 
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,8 @@ const DEFAULTS = {
34
34
  pushOnTurnEnd: false, // 每轮结束时若有未提交改动就推送
35
35
  remindEvery: 6, // 每 N 条用户消息、且期间没动过记忆 → 提醒(0=关)
36
36
  staleMinutes: 0, // 距上次同步超过 N 分钟 → 提醒(0=关)
37
+ recentLimit: 0, // 每轮额外注入最近 N 条归档的标题与标签(0=关)
38
+ dedupeHint: true, // 写入时检查是否与已有归档高度相似
37
39
  commitMessage: 'memory: {tool}',
38
40
  gitBin: 'git',
39
41
  authorName: 'dsh-memory',
@@ -179,6 +181,85 @@ function saveIndex(p, notes) {
179
181
  return sorted;
180
182
  }
181
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
+
182
263
  // ---------------------------------------------------------------- core 记忆
183
264
 
184
265
  function coreLimit(config) {
@@ -495,7 +576,7 @@ export function apply(ctx, config0) {
495
576
  name: 'dsh-memory-github:core',
496
577
  order: 60,
497
578
  text: () => {
498
- const base = coreSection(readCore(p), coreLimit(config));
579
+ const base = coreSection(readCore(p), coreLimit(config)) + recentSection(p, config.recentLimit);
499
580
  const stamp = activityStamp(p);
500
581
  if (stamp > syncState.lastActivity) {
501
582
  // 记忆被写过(本进程写的,或别的进程/设备同步下来的),计数归零
@@ -564,6 +645,9 @@ export function apply(ctx, config0) {
564
645
  ? args.tags.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
565
646
  : [];
566
647
  try {
648
+ const dupes = config.dedupeHint === false
649
+ ? []
650
+ : similarNotes(p, title, content);
567
651
  return await enqueue(() => {
568
652
  ensureDirs(p);
569
653
  const notes = loadIndex(p);
@@ -576,7 +660,11 @@ export function apply(ctx, config0) {
576
660
  atomicWrite(join(p.notes, `${id}.md`), text);
577
661
  saveIndex(p, [meta, ...notes]);
578
662
  const sync = afterWrite('memory_write');
579
- return `已保存 [${id}] ${title}${tags.length ? ' #' + tags.join(' #') : ''}。${sync}`;
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}`;
580
668
  });
581
669
  } catch (e) {
582
670
  return errText(e);
@@ -699,6 +787,142 @@ export function apply(ctx, config0) {
699
787
  }
700
788
  },
701
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
+ },
702
926
  {
703
927
  name: 'memory_sync',
704
928
  description: 'GitHub 后端专用:action=status 看仓库状态,pull 拉取远端,push 提交并推送本地改动。local 后端调用会提示未启用。',
@@ -744,7 +968,7 @@ export function apply(ctx, config0) {
744
968
  },
745
969
  ];
746
970
 
747
- const WRITE_TOOLS = new Set(['memory_write', 'memory_core']);
971
+ const WRITE_TOOLS = new Set(['memory_write', 'memory_core', 'memory_update', 'memory_forget']);
748
972
  const registered = [];
749
973
  for (const def of defs) {
750
974
  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.3.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",