@max-null/dsh-memory 0.19.0 → 0.20.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/README.md +21 -6
- package/dist/backup.d.ts +29 -5
- package/dist/backup.js +107 -15
- package/dist/engine.d.ts +11 -0
- package/dist/engine.js +6 -2
- package/dist/index.js +11 -1
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -76,6 +76,18 @@ npm install @max-null/dsh-memory
|
|
|
76
76
|
|
|
77
77
|
两个根都可用 config 覆盖(`globalRoot` / `projectRoot`)。`memory_list` / `memory_search` 不带 `namespace` 过滤时会同时查两层。旧版双重前缀文件名(`memory_project_memory_project_<hash>.json`)在打开时自动迁移为规范名。
|
|
78
78
|
|
|
79
|
+
## 主题(topic,0.20.0)
|
|
80
|
+
|
|
81
|
+
`namespace` 管「在哪生效」,`topic` 管「这条讲什么」,两者正交:
|
|
82
|
+
|
|
83
|
+
| 字段 | 回答的问题 | 取值 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `namespace` | 换个项目还成立吗 | `global` / `project` |
|
|
86
|
+
| `kind` | 是记忆还是提示词模板 | `fact` / `prompt` |
|
|
87
|
+
| `topic` | 这条记忆讲什么 | 自由字符串:`见闻` / `约定` / `坑` / `偏好` / `事实` / `判据` … |
|
|
88
|
+
|
|
89
|
+
`memory_save` 传 `topic` 落库,`memory_list` / `memory_search` 传 `topic` 切片。留空或省略即「未分类」。用它把「自己出去逛到的」(`见闻`)和「一起干活时记下的」分开翻,不必再往正文里手写前缀。
|
|
90
|
+
|
|
79
91
|
## 跨工作区可见性(0.10.0 / 0.11.0)
|
|
80
92
|
|
|
81
93
|
project 记忆按**会话工作区**分文件存放,所以「工作区」就是可见性的边界。0.10.0 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
@@ -106,26 +118,29 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
106
118
|
|
|
107
119
|
**未覆盖**:`workspace.json`(工作区登记)由 DSH 自己的 workspace 服务写,插件层够不着,仍会被双实例互相覆盖;根因处置在上游——[discussion #6882](https://github.com/deepseek-ai/deepseek-harness/discussions/6882)。当前防线是轮转快照备份(SSiD 侧 `shell/scripts/backup-storages.mjs` + 计划任务)。
|
|
108
120
|
|
|
109
|
-
## 定时备份(2026-10-18)
|
|
121
|
+
## 定时备份(2026-10-18;日记归档 2026-10-25)
|
|
110
122
|
|
|
111
123
|
**动机**:2026-10-18 的事故——`.dsh` 下的 storages 被外部整目录删除后,本插件的写入路径(读—改—写全量覆盖)在空磁盘上把内存里的 global 记忆覆盖成一条;而当时 `memory.json` **没有任何一份存在于 `.dsh` 之外**,无从恢复。
|
|
112
124
|
|
|
113
|
-
**机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14
|
|
125
|
+
**机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14 份(可配,**只作用于记忆快照**),超期自动清理。
|
|
126
|
+
|
|
127
|
+
**日记另走一条路**:`<DSH_HOME>/diary/*.md` 与记忆同一轮复制,但落在**固定目录** `~/.dsh-backups/memory/diary-archive/`,**只增不删**,不参与上面的轮转。两边的不对称是实的:记忆快照丢一份还有别的份(同一份数据的多个时间点副本),归档里每一篇都是日记的唯一副本,丢了没有任何地方能重建。日记那一侧改过就以日记为准覆盖(人可能在事后改过错字),归档里有而日记里已没有的篇目一律保留——那正是归档的全部价值。归档目录名不匹配轮转正则,因此不需要给清理逻辑开白名单。
|
|
114
128
|
|
|
115
|
-
**无界面**:这是行为类功能,不新增按钮、面板或设置项——行为效果即上面那段:部署后 `~/.dsh-backups/memory/` 下每小时多一个目录,目录里是当次复制出的 `memory.json`、`memory_project_*.json` 与 `manifest.json`。
|
|
129
|
+
**无界面**:这是行为类功能,不新增按钮、面板或设置项——行为效果即上面那段:部署后 `~/.dsh-backups/memory/` 下每小时多一个目录,目录里是当次复制出的 `memory.json`、`memory_project_*.json` 与 `manifest.json`;`diary-archive/` 里则按天累积 `YYYY-MM-DD.md`。
|
|
116
130
|
|
|
117
131
|
| 项 | 默认值 |
|
|
118
132
|
|---|---|
|
|
119
|
-
| 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project
|
|
120
|
-
| 目标目录 |
|
|
133
|
+
| 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project)+ `<DSH_HOME>/diary/*.md` |
|
|
134
|
+
| 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
|
|
121
135
|
| 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
|
|
122
|
-
| 保留 |
|
|
136
|
+
| 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
|
|
123
137
|
| 失败行为 | 只写日志,不影响记忆读写与启动 |
|
|
124
138
|
|
|
125
139
|
**边界**:
|
|
126
140
|
|
|
127
141
|
- 只备份**当前工作区**的 project 文件(`<cwd>/.dsh/storages/`);其它工作区的 project 记忆不在备份范围。
|
|
128
142
|
- 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
|
|
143
|
+
- 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
|
|
129
144
|
|
|
130
145
|
## 使用流程(写入即生效,人为例外干预)
|
|
131
146
|
|
package/dist/backup.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `DSH_HOME` 之外。
|
|
2
|
+
* 定时备份:把 `memory.json`(global)、`memory_project_*.json`(project)与
|
|
3
|
+
* `<DSH_HOME>/diary` 下的日记复制到 `DSH_HOME` 之外。
|
|
4
4
|
*
|
|
5
5
|
* 存在的理由(2026-10-18 事故):`.dsh` 下的 storages 被外部整目录删除后,dsh-memory 的
|
|
6
6
|
* 写入路径是「reload → 合并 → 写」——空磁盘让 reload 得到空库,写入把内存里的 global
|
|
7
7
|
* 记忆覆盖成一条;而当时 `memory.json` 没有任何一份存在于 `.dsh` 之外,无从恢复。
|
|
8
8
|
*
|
|
9
|
+
* 两类数据在同一个备份根下、用两种保留策略:记忆是**快照**(时间戳目录、只留最近 N 份),
|
|
10
|
+
* 日记是**归档**(固定目录、只增不删)——理由见 {@link archiveDiary}。
|
|
11
|
+
*
|
|
12
|
+
* 备份能力属于本插件而非宿主壳:上面两类数据都由本插件产生,换个宿主编排它同样该成立。
|
|
13
|
+
*
|
|
9
14
|
* 铁律:这里**只读文件、只复制**,绝不复用引擎的写入路径、不碰内存状态。备份失败只记
|
|
10
15
|
* 日志,绝不影响记忆本身的读写与启动。
|
|
11
16
|
*
|
|
@@ -28,6 +33,8 @@ export interface MemoryBackupOptions {
|
|
|
28
33
|
globalRoot: string;
|
|
29
34
|
/** project 记忆的根(`<cwd>/.dsh/storages`)。 */
|
|
30
35
|
projectRoot: string;
|
|
36
|
+
/** 日记的根(`$DSH_HOME/diary`);缺省不备份日记。 */
|
|
37
|
+
diaryRoot?: string;
|
|
31
38
|
/** 备份根目录。 */
|
|
32
39
|
targetRoot: string;
|
|
33
40
|
/** 保留份数(≥1)。 */
|
|
@@ -37,6 +44,13 @@ export interface MemoryBackupOptions {
|
|
|
37
44
|
/** 日志出口;缺省丢弃。 */
|
|
38
45
|
log?: (text: string) => void;
|
|
39
46
|
}
|
|
47
|
+
/** 日记归档的结果。 */
|
|
48
|
+
export interface DiaryArchiveRun {
|
|
49
|
+
/** 本次复制进归档的篇数(含内容变化后的覆盖)。 */
|
|
50
|
+
readonly copied: number;
|
|
51
|
+
/** 归档里已存在且与日记逐字节相同的篇数。 */
|
|
52
|
+
readonly kept: number;
|
|
53
|
+
}
|
|
40
54
|
/** 一次备份的结果。 */
|
|
41
55
|
export interface MemoryBackupRun {
|
|
42
56
|
/** 本次写入的目录;没有可备份文件时为 `null`。 */
|
|
@@ -45,18 +59,28 @@ export interface MemoryBackupRun {
|
|
|
45
59
|
readonly files: readonly string[];
|
|
46
60
|
/** 本次清理掉的旧目录名。 */
|
|
47
61
|
readonly pruned: readonly string[];
|
|
62
|
+
/**
|
|
63
|
+
* 日记归档的结果;入参没给 `diaryRoot` 时为 `undefined`(视为「不备份日记」)。
|
|
64
|
+
*
|
|
65
|
+
* 与 `files` 分开记:日记**不**落进本次的快照目录,它在固定的 `diary-archive/` 里
|
|
66
|
+
* 保持一份持续累积的副本,不做时间点切片。
|
|
67
|
+
*/
|
|
68
|
+
readonly diary?: DiaryArchiveRun;
|
|
48
69
|
/** 失败原因;成功时缺省。 */
|
|
49
70
|
readonly error?: string;
|
|
50
71
|
}
|
|
51
72
|
/**
|
|
52
|
-
*
|
|
73
|
+
* 备份一次:记忆走时间戳快照,日记走 {@link archiveDiary} 的累积归档。
|
|
53
74
|
*
|
|
54
75
|
* 目标目录按 `now` 命名;同名目录已存在时追加 `-1`、`-2`……每个文件独立复制,单个失败
|
|
55
76
|
* 只告警、不中断其余文件;`manifest.json` 记录本次来源与文件清单,便于事后核对。
|
|
56
77
|
* 没有可复制文件时返回 `dir: null`,不创建空目录。
|
|
57
78
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
79
|
+
* 日记归档先于「记忆文件是否存在」的判断执行:它落在固定的 `diary-archive/`,不依赖本次
|
|
80
|
+
* 快照目录,所以只剩日记可备份时照样该归档。
|
|
81
|
+
*
|
|
82
|
+
* @param options - 两个来源根、日记根、目标根、保留份数与时间注入点。
|
|
83
|
+
* @returns 本次目录、复制清单、清理清单、日记归档结果与失败原因。
|
|
60
84
|
*/
|
|
61
85
|
export declare function runMemoryBackup(options: MemoryBackupOptions): MemoryBackupRun;
|
|
62
86
|
/** 备份目录是否像一个已存在的备份树(测试与自检用)。 */
|
package/dist/backup.js
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* `DSH_HOME` 之外。
|
|
2
|
+
* 定时备份:把 `memory.json`(global)、`memory_project_*.json`(project)与
|
|
3
|
+
* `<DSH_HOME>/diary` 下的日记复制到 `DSH_HOME` 之外。
|
|
4
4
|
*
|
|
5
5
|
* 存在的理由(2026-10-18 事故):`.dsh` 下的 storages 被外部整目录删除后,dsh-memory 的
|
|
6
6
|
* 写入路径是「reload → 合并 → 写」——空磁盘让 reload 得到空库,写入把内存里的 global
|
|
7
7
|
* 记忆覆盖成一条;而当时 `memory.json` 没有任何一份存在于 `.dsh` 之外,无从恢复。
|
|
8
8
|
*
|
|
9
|
+
* 两类数据在同一个备份根下、用两种保留策略:记忆是**快照**(时间戳目录、只留最近 N 份),
|
|
10
|
+
* 日记是**归档**(固定目录、只增不删)——理由见 {@link archiveDiary}。
|
|
11
|
+
*
|
|
12
|
+
* 备份能力属于本插件而非宿主壳:上面两类数据都由本插件产生,换个宿主编排它同样该成立。
|
|
13
|
+
*
|
|
9
14
|
* 铁律:这里**只读文件、只复制**,绝不复用引擎的写入路径、不碰内存状态。备份失败只记
|
|
10
15
|
* 日志,绝不影响记忆本身的读写与启动。
|
|
11
16
|
*
|
|
12
17
|
* @module dsh-memory/backup
|
|
13
18
|
*/
|
|
14
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
20
|
import { join } from 'node:path';
|
|
16
21
|
/** 参与备份的名字:global 的 `memory.json` 与 project 的 `memory_project_*.json`。 */
|
|
17
22
|
const BACKUP_FILE = /^(?:memory\.json|memory_project_[A-Za-z0-9_-]+\.json)$/u;
|
|
23
|
+
/** 参与归档的日记篇目:与 `diary.ts` 的按天分文件一致(`YYYY-MM-DD.md`)。 */
|
|
24
|
+
const DIARY_FILE = /^\d{4}-\d{2}-\d{2}\.md$/u;
|
|
25
|
+
/**
|
|
26
|
+
* 日记归档目录名,固定在备份根下。
|
|
27
|
+
*
|
|
28
|
+
* 之所以是固定名而不是时间戳:它**必须不匹配** {@link pruneBackups} 的
|
|
29
|
+
* `^\d{8}-\d{6}(?:-\d+)?$`。记忆快照丢一份还有别的份(同一份数据的多个时间点副本),
|
|
30
|
+
* 日记归档里每一篇都是**唯一**的备份——丢了没有任何地方能重建它。只增不删因此是硬要求,
|
|
31
|
+
* 而靠这个名字天然躲开轮转,不必再给清理逻辑开白名单。
|
|
32
|
+
*/
|
|
33
|
+
const DIARY_ARCHIVE_DIR = 'diary-archive';
|
|
34
|
+
/** 从 `unknown` 错误里取可读信息(`catch` 绑定的类型是 `unknown`)。 */
|
|
35
|
+
function reason(error) {
|
|
36
|
+
return error instanceof Error ? error.message : String(error);
|
|
37
|
+
}
|
|
18
38
|
/** 备份目录名:`YYYYMMDD-HHmmss`,可排序、可读。 */
|
|
19
39
|
function stamp(now) {
|
|
20
40
|
const pad = (value) => String(value).padStart(2, '0');
|
|
@@ -29,29 +49,93 @@ function candidateFiles(root, log) {
|
|
|
29
49
|
return readdirSync(root).filter((name) => BACKUP_FILE.test(name));
|
|
30
50
|
}
|
|
31
51
|
catch (error) {
|
|
32
|
-
log(`memory backup: 无法读取 ${root}: ${
|
|
52
|
+
log(`memory backup: 无法读取 ${root}: ${reason(error)}`);
|
|
33
53
|
return [];
|
|
34
54
|
}
|
|
35
55
|
}
|
|
36
56
|
/**
|
|
37
|
-
*
|
|
57
|
+
* 把日记镜像进 `<targetRoot>/diary-archive/`。
|
|
58
|
+
*
|
|
59
|
+
* 保留策略与记忆快照相反:快照按时间戳分目录、只留最近 `keep` 份;归档**只增不删**,
|
|
60
|
+
* 理由见 {@link DIARY_ARCHIVE_DIR}。
|
|
61
|
+
*
|
|
62
|
+
* 三档处理:
|
|
63
|
+
* - 归档里没有 → 复制,计入 `copied`
|
|
64
|
+
* - 两边逐字节相同 → 跳过,计入 `kept`(绝大多数情况:日记写到归档之间没有变化)
|
|
65
|
+
* - 不同 → **以日记为准覆盖**,计入 `copied`:人可能在事后改过错字,日记那一侧才是当前的;
|
|
66
|
+
* 归档不是版本库,它只为「原件没了」这一件事存在
|
|
67
|
+
*
|
|
68
|
+
* 归档里独有、日记里已经没有的篇目**一律保留**——那正是归档的全部价值。
|
|
69
|
+
* 逐篇独立处理,单篇失败只告警、不抛出(模块头的铁律)。
|
|
70
|
+
*
|
|
71
|
+
* @param diaryRoot - 日记根(`$DSH_HOME/diary`)。
|
|
72
|
+
* @param targetRoot - 备份根;归档落在其下的 `diary-archive/`。
|
|
73
|
+
* @param log - 日志出口。
|
|
74
|
+
* @returns 写入与跳过的篇数;没有日记可归档时两项都是 0,且不创建任何目录。
|
|
75
|
+
*/
|
|
76
|
+
function archiveDiary(diaryRoot, targetRoot, log) {
|
|
77
|
+
const nothing = { copied: 0, kept: 0 };
|
|
78
|
+
if (!existsSync(diaryRoot))
|
|
79
|
+
return nothing;
|
|
80
|
+
let names;
|
|
81
|
+
try {
|
|
82
|
+
names = readdirSync(diaryRoot).filter((name) => DIARY_FILE.test(name));
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
log(`memory backup: 无法读取日记目录 ${diaryRoot}: ${reason(error)}`);
|
|
86
|
+
return nothing;
|
|
87
|
+
}
|
|
88
|
+
if (names.length === 0)
|
|
89
|
+
return nothing;
|
|
90
|
+
const archiveDir = join(targetRoot, DIARY_ARCHIVE_DIR);
|
|
91
|
+
let copied = 0;
|
|
92
|
+
let kept = 0;
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const from = join(diaryRoot, name);
|
|
95
|
+
const to = join(archiveDir, name);
|
|
96
|
+
try {
|
|
97
|
+
// 比字节而不是比 mtime:复制与编辑都会动 mtime,内容才是「是否已经备份过」的事实。
|
|
98
|
+
if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) {
|
|
99
|
+
kept += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
103
|
+
copyFileSync(from, to);
|
|
104
|
+
copied += 1;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
log(`memory backup: 归档日记 ${name} 失败: ${reason(error)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { copied, kept };
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 备份一次:记忆走时间戳快照,日记走 {@link archiveDiary} 的累积归档。
|
|
38
114
|
*
|
|
39
115
|
* 目标目录按 `now` 命名;同名目录已存在时追加 `-1`、`-2`……每个文件独立复制,单个失败
|
|
40
116
|
* 只告警、不中断其余文件;`manifest.json` 记录本次来源与文件清单,便于事后核对。
|
|
41
117
|
* 没有可复制文件时返回 `dir: null`,不创建空目录。
|
|
42
118
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
119
|
+
* 日记归档先于「记忆文件是否存在」的判断执行:它落在固定的 `diary-archive/`,不依赖本次
|
|
120
|
+
* 快照目录,所以只剩日记可备份时照样该归档。
|
|
121
|
+
*
|
|
122
|
+
* @param options - 两个来源根、日记根、目标根、保留份数与时间注入点。
|
|
123
|
+
* @returns 本次目录、复制清单、清理清单、日记归档结果与失败原因。
|
|
45
124
|
*/
|
|
46
125
|
export function runMemoryBackup(options) {
|
|
47
126
|
const log = options.log ?? (() => { });
|
|
48
127
|
const now = options.now ?? new Date();
|
|
128
|
+
const diary = options.diaryRoot === undefined
|
|
129
|
+
? undefined
|
|
130
|
+
: archiveDiary(options.diaryRoot, options.targetRoot, log);
|
|
49
131
|
const globalFiles = candidateFiles(options.globalRoot, log).map(name => ({ name, from: join(options.globalRoot, name) }));
|
|
50
132
|
const projectFiles = candidateFiles(options.projectRoot, log).map(name => ({ name, from: join(options.projectRoot, name) }));
|
|
51
133
|
const sources = [...globalFiles, ...projectFiles];
|
|
52
134
|
if (sources.length === 0) {
|
|
53
135
|
log('memory backup: 没有可备份的记忆文件');
|
|
54
|
-
return
|
|
136
|
+
return diary === undefined
|
|
137
|
+
? { dir: null, files: [], pruned: [] }
|
|
138
|
+
: { dir: null, files: [], pruned: [], diary };
|
|
55
139
|
}
|
|
56
140
|
let base = join(options.targetRoot, stamp(now));
|
|
57
141
|
let dir = base;
|
|
@@ -61,7 +145,7 @@ export function runMemoryBackup(options) {
|
|
|
61
145
|
mkdirSync(dir, { recursive: true });
|
|
62
146
|
}
|
|
63
147
|
catch (error) {
|
|
64
|
-
const message =
|
|
148
|
+
const message = reason(error);
|
|
65
149
|
log(`memory backup: 无法创建 ${dir}: ${message}`);
|
|
66
150
|
return { dir: null, files: [], pruned: [], error: message };
|
|
67
151
|
}
|
|
@@ -72,7 +156,7 @@ export function runMemoryBackup(options) {
|
|
|
72
156
|
files.push(source.name);
|
|
73
157
|
}
|
|
74
158
|
catch (error) {
|
|
75
|
-
log(`memory backup: 复制 ${source.name} 失败: ${
|
|
159
|
+
log(`memory backup: 复制 ${source.name} 失败: ${reason(error)}`);
|
|
76
160
|
}
|
|
77
161
|
}
|
|
78
162
|
try {
|
|
@@ -81,14 +165,22 @@ export function runMemoryBackup(options) {
|
|
|
81
165
|
globalRoot: options.globalRoot,
|
|
82
166
|
projectRoot: options.projectRoot,
|
|
83
167
|
files,
|
|
168
|
+
// 日记不在本次快照目录里,manifest 只记「这一轮归档做了什么」,不列篇名。
|
|
169
|
+
...(diary === undefined ? {} : { diary: { root: options.diaryRoot, ...diary } }),
|
|
84
170
|
}, null, 2)}\n`, 'utf8');
|
|
85
171
|
}
|
|
86
172
|
catch (error) {
|
|
87
|
-
log(`memory backup: 写 manifest 失败: ${
|
|
173
|
+
log(`memory backup: 写 manifest 失败: ${reason(error)}`);
|
|
88
174
|
}
|
|
89
|
-
|
|
175
|
+
const pruned = pruneBackups(options.targetRoot, options.keep, log);
|
|
176
|
+
return diary === undefined ? { dir, files, pruned } : { dir, files, pruned, diary };
|
|
90
177
|
}
|
|
91
|
-
/**
|
|
178
|
+
/**
|
|
179
|
+
* 只保留最近 `keep` 份时间戳快照目录(按名字排序即时间序),多余的删除。
|
|
180
|
+
*
|
|
181
|
+
* 只认 `YYYYMMDD-HHmmss`(含 `-1` 这类冲突后缀):`diary-archive` 等固定名目录因此永远
|
|
182
|
+
* 不在清理范围内——日记只增不删是硬要求,不是配置项。
|
|
183
|
+
*/
|
|
92
184
|
function pruneBackups(targetRoot, keep, log) {
|
|
93
185
|
if (!existsSync(targetRoot))
|
|
94
186
|
return [];
|
|
@@ -97,7 +189,7 @@ function pruneBackups(targetRoot, keep, log) {
|
|
|
97
189
|
entries = readdirSync(targetRoot).filter((name) => /^\d{8}-\d{6}(?:-\d+)?$/u.test(name));
|
|
98
190
|
}
|
|
99
191
|
catch (error) {
|
|
100
|
-
log(`memory backup: 无法读取 ${targetRoot}: ${
|
|
192
|
+
log(`memory backup: 无法读取 ${targetRoot}: ${reason(error)}`);
|
|
101
193
|
return [];
|
|
102
194
|
}
|
|
103
195
|
entries.sort();
|
|
@@ -112,7 +204,7 @@ function pruneBackups(targetRoot, keep, log) {
|
|
|
112
204
|
pruned.push(name);
|
|
113
205
|
}
|
|
114
206
|
catch (error) {
|
|
115
|
-
log(`memory backup: 清理 ${name} 失败: ${
|
|
207
|
+
log(`memory backup: 清理 ${name} 失败: ${reason(error)}`);
|
|
116
208
|
}
|
|
117
209
|
}
|
|
118
210
|
return pruned;
|
package/dist/engine.d.ts
CHANGED
|
@@ -81,6 +81,13 @@ export interface MemoryRecord {
|
|
|
81
81
|
lastUsedAt?: number;
|
|
82
82
|
/** 记录类别(缺省 'fact')。 */
|
|
83
83
|
kind?: MemoryKind;
|
|
84
|
+
/**
|
|
85
|
+
* 记忆主题(0.20.0):见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …
|
|
86
|
+
*
|
|
87
|
+
* 与 `kind` 正交:`kind` 分「记忆还是提示词模板」,`topic` 分「这条记忆讲什么」。
|
|
88
|
+
* 存自由字符串,方便「只翻我出去逛到的那些」这类切片。
|
|
89
|
+
*/
|
|
90
|
+
topic?: string;
|
|
84
91
|
/** prompt 记录元数据(仅 kind==='prompt')。 */
|
|
85
92
|
meta?: PromptMetaIndex;
|
|
86
93
|
/**
|
|
@@ -93,6 +100,8 @@ export interface MemoryWrite {
|
|
|
93
100
|
content: string;
|
|
94
101
|
namespace?: MemoryNamespace;
|
|
95
102
|
keywords?: string[];
|
|
103
|
+
/** 记忆主题(0.20.0):见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …;省略表示未分类。 */
|
|
104
|
+
topic?: string;
|
|
96
105
|
/** 写入来源(2026-09-15);缺省 'agent'(主模型写入)。面板等人工路径传 'human'。 */
|
|
97
106
|
source?: MemorySource;
|
|
98
107
|
/**
|
|
@@ -115,6 +124,8 @@ export interface MemoryFilter {
|
|
|
115
124
|
quarantined?: boolean;
|
|
116
125
|
/** 记录类别过滤(0.6.0);缺省不过滤(工具层显式传 kind='fact' 保持旧行为)。 */
|
|
117
126
|
kind?: MemoryKind;
|
|
127
|
+
/** 主题过滤(0.20.0):只取这个 topic 的记录;缺省不过滤。 */
|
|
128
|
+
topic?: string;
|
|
118
129
|
/**
|
|
119
130
|
* 更新时间下界 / 上界(0.12.0;epoch ms,含端点)。
|
|
120
131
|
*
|
package/dist/engine.js
CHANGED
|
@@ -145,10 +145,11 @@ function toRecord(id, block) {
|
|
|
145
145
|
...block.stale === undefined ? {} : { stale: block.stale },
|
|
146
146
|
...block.staleReason === undefined ? {} : { staleReason: block.staleReason },
|
|
147
147
|
};
|
|
148
|
-
// 0.6.0 模板库字段(缺省 fact;prompt
|
|
148
|
+
// 0.6.0 模板库字段(缺省 fact;prompt 元数据透传);0.20.0 起带主题
|
|
149
149
|
return {
|
|
150
150
|
...base,
|
|
151
151
|
...block.kind === undefined ? {} : { kind: block.kind },
|
|
152
|
+
...block.topic === undefined ? {} : { topic: block.topic },
|
|
152
153
|
...block.meta === undefined ? {} : { meta: block.meta },
|
|
153
154
|
};
|
|
154
155
|
}
|
|
@@ -758,6 +759,8 @@ export class MemoryEngine extends Service {
|
|
|
758
759
|
...input.injected === undefined ? {} : { injectedAuto: false },
|
|
759
760
|
// 有效性锚点(可选):值变了这条记忆会被标 stale,不再被当成仍然正确
|
|
760
761
|
...input.anchor === undefined ? {} : { anchor: input.anchor },
|
|
762
|
+
// 主题(0.20.0):省略即未分类,与 `kind`(fact/prompt)正交
|
|
763
|
+
...input.topic === undefined || input.topic.trim() === '' ? {} : { topic: input.topic.trim() },
|
|
761
764
|
...verdict.quarantined
|
|
762
765
|
? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
|
|
763
766
|
: {},
|
|
@@ -778,7 +781,8 @@ export class MemoryEngine extends Service {
|
|
|
778
781
|
// 0.12.0:按更新时间筛——与注入排序、注入行的日期标记同一个依据,不制造第二个「新」的定义
|
|
779
782
|
&& (filter?.after === undefined || record.updatedAt >= filter.after)
|
|
780
783
|
&& (filter?.before === undefined || record.updatedAt <= filter.before)
|
|
781
|
-
&& (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind)
|
|
784
|
+
&& (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind)
|
|
785
|
+
&& (filter?.topic === undefined || record.topic === filter.topic));
|
|
782
786
|
}
|
|
783
787
|
/**
|
|
784
788
|
* 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { runMemoryBackup } from "./backup.js";
|
|
|
13
13
|
import { ANCHOR_KINDS } from "./anchors.js";
|
|
14
14
|
import { mountMemoryApi } from "./routes.js";
|
|
15
15
|
import { SELF_DESCRIPTION } from "./self.js";
|
|
16
|
-
import { appendEntry, lastWriteAt, pointerLine, readDay, recentDates } from "./diary.js";
|
|
16
|
+
import { appendEntry, diaryDir, lastWriteAt, pointerLine, readDay, recentDates } from "./diary.js";
|
|
17
17
|
import z from '@deepseek-ai/schemastery';
|
|
18
18
|
import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } from "./injection.js";
|
|
19
19
|
export { MemoryEngine } from "./engine.js";
|
|
@@ -125,6 +125,7 @@ const RECORD_SCHEMA = {
|
|
|
125
125
|
injected: { type: 'boolean', required: true },
|
|
126
126
|
content: { type: 'string', required: true },
|
|
127
127
|
keywords: { type: 'array', required: true, items: { type: 'string' } },
|
|
128
|
+
topic: { type: 'string', description: '主题(0.20.0):见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …;缺省表示未分类。' },
|
|
128
129
|
createdAt: { type: 'number', required: true },
|
|
129
130
|
updatedAt: { type: 'number', required: true },
|
|
130
131
|
lastUsedAt: { type: 'number' },
|
|
@@ -296,6 +297,7 @@ function recordValue(record) {
|
|
|
296
297
|
injected: record.injected,
|
|
297
298
|
content: record.content,
|
|
298
299
|
keywords: record.keywords,
|
|
300
|
+
...record.topic === undefined ? {} : { topic: record.topic },
|
|
299
301
|
createdAt: record.createdAt,
|
|
300
302
|
updatedAt: record.updatedAt,
|
|
301
303
|
...record.lastUsedAt === undefined ? {} : { lastUsedAt: record.lastUsedAt },
|
|
@@ -587,6 +589,7 @@ export async function apply(ctx, config) {
|
|
|
587
589
|
content: { type: 'string', required: true, description: 'Plaintext memory content.' },
|
|
588
590
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Where it applies; defaults to global.' },
|
|
589
591
|
keywords: { type: 'array', items: { type: 'string' }, description: 'Explicit searchable anchors for memory_search. Give several angles (synonyms, abbreviations, zh/en) or it will not be found later. 多角度关键词(同义词、缩写、中英),否则将来检索不到它。' },
|
|
592
|
+
topic: { type: 'string', description: 'What this memory is about, one short word: 见闻 (things noticed while wandering) / 约定 (standing agreement) / 坑 (a pitfall hit) / 偏好 (a preference) / 事实 / 判据. Orthogonal to namespace. 主题:见闻 / 约定 / 坑 / 偏好 / 事实 / 判据;与 namespace 正交,缺省表示未分类。' },
|
|
590
593
|
injected: { type: 'boolean', description: 'Set true to make this memory resident in every turn\'s context, as a deliberate decision that no longer auto-expires. Reserve it for rules, standing agreements and judgement criteria — the kind that must apply even when nobody thinks to search for them. Facts, references and case notes stay search-only (omit it). 设为 true 让这条记忆每轮常驻上下文,且视为有意决定、不再被自动淘汰。只用于规则、长期约定与判据——那些「没人想起来搜也必须生效」的记忆;事实、参考与案例留作按需检索(省略即可)。' },
|
|
591
594
|
anchor: {
|
|
592
595
|
type: 'object',
|
|
@@ -609,6 +612,7 @@ export async function apply(ctx, config) {
|
|
|
609
612
|
content: args.content,
|
|
610
613
|
...args.namespace === undefined ? {} : { namespace: args.namespace },
|
|
611
614
|
...args.keywords === undefined ? {} : { keywords: args.keywords },
|
|
615
|
+
...args.topic === undefined ? {} : { topic: args.topic },
|
|
612
616
|
...args.injected === undefined ? {} : { injected: args.injected },
|
|
613
617
|
// 参数已由 tool schema 校验,这里的窄化只为过类型
|
|
614
618
|
...args.anchor === undefined
|
|
@@ -632,6 +636,7 @@ export async function apply(ctx, config) {
|
|
|
632
636
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
|
|
633
637
|
status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态(suggested=待审核 / approved=已审核).' },
|
|
634
638
|
injected: { type: 'boolean', description: 'Restrict by the persistent-injection switch. 按常驻注入开关过滤.' },
|
|
639
|
+
topic: { type: 'string', description: 'Restrict to one topic (见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …). 按主题过滤(0.20.0).' },
|
|
635
640
|
after: { type: 'string', description: 'Only memories updated at/after this time: `YYYY-MM-DD`, or a relative form like `7d` / `30d`. 只返回此后更新过的记忆(`YYYY-MM-DD`,或相对形式 `7d` / `30d`).' },
|
|
636
641
|
before: { type: 'string', description: 'Only memories updated at/before this time (same forms as `after`). 只返回此前更新过的记忆(写法同 after).' },
|
|
637
642
|
},
|
|
@@ -647,6 +652,7 @@ export async function apply(ctx, config) {
|
|
|
647
652
|
...args.namespace === undefined ? {} : { namespace: args.namespace },
|
|
648
653
|
...args.status === undefined ? {} : { status: args.status },
|
|
649
654
|
...args.injected === undefined ? {} : { injected: args.injected },
|
|
655
|
+
...args.topic === undefined ? {} : { topic: args.topic },
|
|
650
656
|
...after === undefined ? {} : { after },
|
|
651
657
|
...before === undefined ? {} : { before },
|
|
652
658
|
}, execProjectCwd(exec)).then(records => records.map(recordValue));
|
|
@@ -661,6 +667,7 @@ export async function apply(ctx, config) {
|
|
|
661
667
|
namespace: { type: 'string', enum: ['global', 'project'], description: 'Restrict to one namespace. 限定单个命名空间.' },
|
|
662
668
|
status: { type: 'string', enum: ['suggested', 'approved'], description: 'Restrict to one review status. 限定审核状态.' },
|
|
663
669
|
kind: { type: 'string', enum: ['fact', 'prompt'], description: 'Restrict by record kind — fact (memories, default) or prompt (templates; use prompt_search instead). 按记录类别过滤:fact=记忆(默认)/ prompt=模板(模板请用 prompt_search).' },
|
|
670
|
+
topic: { type: 'string', description: 'Restrict to one topic (见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 …). 按主题过滤(0.20.0).' },
|
|
664
671
|
limit: { type: 'integer', default: DEFAULT_SEARCH_LIMIT, description: 'Maximum hits to return (default 20; 0 = no limit; negative falls back to 20). 最多返回条数(默认 20;0 = 不限;负数按 20).' },
|
|
665
672
|
},
|
|
666
673
|
output: {
|
|
@@ -676,6 +683,7 @@ export async function apply(ctx, config) {
|
|
|
676
683
|
...args.namespace === undefined ? {} : { namespace: args.namespace },
|
|
677
684
|
...args.status === undefined ? {} : { status: args.status },
|
|
678
685
|
...args.kind === undefined ? {} : { kind: args.kind },
|
|
686
|
+
...args.topic === undefined ? {} : { topic: args.topic },
|
|
679
687
|
}, execProjectCwd(exec)).then(hits => (limit === 0 ? hits : hits.slice(0, limit)).map(hitValue));
|
|
680
688
|
},
|
|
681
689
|
presentCall: args => present('Search memory', 'read', args.query),
|
|
@@ -992,6 +1000,8 @@ export async function apply(ctx, config) {
|
|
|
992
1000
|
const roots = {
|
|
993
1001
|
globalRoot: config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages'),
|
|
994
1002
|
projectRoot: config?.projectRoot ?? join(process.cwd(), '.dsh', 'storages'),
|
|
1003
|
+
// 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
|
|
1004
|
+
diaryRoot: diaryDir(),
|
|
995
1005
|
};
|
|
996
1006
|
ctx.effect(() => {
|
|
997
1007
|
const first = setTimeout(() => {
|
package/dist/schema.d.ts
CHANGED
package/dist/schema.js
CHANGED
|
@@ -45,6 +45,14 @@ export const blockSchema = z.object({
|
|
|
45
45
|
lastUsedAt: z.number().optional(),
|
|
46
46
|
vector: z.array(z.number()).optional(),
|
|
47
47
|
kind: z.enum(['fact', 'prompt']).optional(),
|
|
48
|
+
/**
|
|
49
|
+
* 记忆主题(0.20.0):见闻 / 约定 / 坑 / 偏好 / 事实 / 判据 … 自由字符串。
|
|
50
|
+
*
|
|
51
|
+
* 与 `kind` 正交——`kind` 分「记忆还是提示词模板」(记录类别),`topic` 分「这条记忆讲什么」
|
|
52
|
+
* (内容主题)。用自由字符串而不是枚举:新增主题不该要求改 schema,而枚举一旦写死,
|
|
53
|
+
* 存量文件里出现未登记的值会被门三判成不合规、进而整块读不出来。
|
|
54
|
+
*/
|
|
55
|
+
topic: z.string().optional(),
|
|
48
56
|
meta: z.object({
|
|
49
57
|
seq: z.number().optional(),
|
|
50
58
|
name: z.string(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Cross-session plaintext memory plugin for the DeepSeek Harness — deterministic BM25 recall, writes take effect immediately, signal-driven injection, no embeddings",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|