@max-null/dsh-memory 0.18.0 → 0.19.1
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 +35 -8
- package/client.js +92 -0
- package/dist/backup.d.ts +29 -5
- package/dist/backup.js +107 -15
- package/dist/diary.d.ts +87 -0
- package/dist/diary.js +170 -0
- package/dist/engine.d.ts +11 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +172 -0
- package/dist/self.js +4 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -40,10 +40,31 @@ npm install @max-null/dsh-memory
|
|
|
40
40
|
## 提供的服务与工具
|
|
41
41
|
|
|
42
42
|
- **服务** `ctx.memory`:`remember` / `list` / `search` / `forget` / `update` / `move` / `setStatus` / `setInjected` / `supersede` / `retract` / `sweep` / `demoteStale` / `verifyAnchors` / `reload` 等
|
|
43
|
-
- **工具**(
|
|
44
|
-
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 +
|
|
43
|
+
- **工具**(14 个):`memory_save`、`memory_list`、`memory_search`、`memory_update`、`memory_confirm`、`memory_forget`、`memory_move`、`memory_sweep`,提示词模板四件套 `prompt_search`、`prompt_get`、`prompt_list`、`prompt_add`,加日记两件套 `diary_read`、`diary_write`
|
|
44
|
+
- **注入**:`tool:memory` 指引 section(工具用法 + 常驻注入判据)+ `memory:self` 机制自述 + `memory:recall` 召回 context(global 的 `approved + injected` + 当前会话工作区的 `approved + injected`,带 `[memory:<id>:<namespace>]` 来源标记;摘要化 + 预算截断)+ `memory:diary` 指针(只有目录与最近日期,不含正文)
|
|
45
45
|
- **检索**:BM25(CJK 单字 + 2-gram,content 与 keywords 字段分离加权;中文多字查询精度显著优于单字切分);可选语义融合(见「可选配置」)
|
|
46
46
|
|
|
47
|
+
## 日记(第二套记忆,2026-10-24)
|
|
48
|
+
|
|
49
|
+
记忆与日记是**两套东西**,不是同一套的两个视图:
|
|
50
|
+
|
|
51
|
+
| | 记忆 | 日记 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| 写什么 | 从中得出了什么 | 今天发生了什么 |
|
|
54
|
+
| 准入判据 | 有用性 | 无——写就是了 |
|
|
55
|
+
| 怎么被读到 | 检索命中 / 常驻摘要 | 整篇读 |
|
|
56
|
+
| 存储 | `ctx.storage` 里的 JSON 块 | `<DSH_HOME>/diary/YYYY-MM-DD.md` |
|
|
57
|
+
|
|
58
|
+
**日记正文永远不经过 `ctx.storage`**:那条路的写入模型是 *writer per process and last-write-wins*,而日记不该被另一个实例的「读—改—写」周期吃掉。日记也不进检索、不占常驻注入预算——注入里只有一行指针(目录 + 最近日期)。
|
|
59
|
+
|
|
60
|
+
- 一天一个文件,按 `## HH:MM` 分段;同日多场对话是**追加**,不覆盖。
|
|
61
|
+
- 工具:`diary_read`(省略 `date` 读最近一篇)、`diary_write`(写一段)。
|
|
62
|
+
- 写进日记的是**经历**:发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么)。**不写任务清单**(那是 git log),**不写结论**(那是 `memory_save`)。
|
|
63
|
+
- 提醒:指针行在距上次写日记超过 6 小时后附带一句提醒,同一次挂载只出现一次。
|
|
64
|
+
- **开关**:插件页的「日记」开关(`diary.enabled`,默认开)。关掉后两个工具都拒绝执行、指针不注入;**已有 md 文件原样保留**,不会被删。
|
|
65
|
+
|
|
66
|
+
设计依据、取舍与未决项见 [`docs/设计/2026-10-24-日记机制-设计方案.md`](docs/设计/2026-10-24-日记机制-设计方案.md)。
|
|
67
|
+
|
|
47
68
|
## 两层存储(global / project)
|
|
48
69
|
|
|
49
70
|
记忆按 `namespace` 分两层物理存储,各落在独立的明文 JSON:
|
|
@@ -85,26 +106,29 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
85
106
|
|
|
86
107
|
**未覆盖**:`workspace.json`(工作区登记)由 DSH 自己的 workspace 服务写,插件层够不着,仍会被双实例互相覆盖;根因处置在上游——[discussion #6882](https://github.com/deepseek-ai/deepseek-harness/discussions/6882)。当前防线是轮转快照备份(SSiD 侧 `shell/scripts/backup-storages.mjs` + 计划任务)。
|
|
87
108
|
|
|
88
|
-
## 定时备份(2026-10-18)
|
|
109
|
+
## 定时备份(2026-10-18;日记归档 2026-10-25)
|
|
89
110
|
|
|
90
111
|
**动机**:2026-10-18 的事故——`.dsh` 下的 storages 被外部整目录删除后,本插件的写入路径(读—改—写全量覆盖)在空磁盘上把内存里的 global 记忆覆盖成一条;而当时 `memory.json` **没有任何一份存在于 `.dsh` 之外**,无从恢复。
|
|
91
112
|
|
|
92
|
-
**机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14
|
|
113
|
+
**机制**:插件启动后每小时(可配)把记忆文件**只读复制**到 `<home>/.dsh-backups/memory/<YYYYMMDD-HHmmss>/`,并写一份 `manifest.json` 记录来源与文件清单。**插件关闭(dispose)时再补一次**——定时器只在运行期间生效,关 DSH 前最后一段变更靠这一次兜住。保留最近 14 份(可配,**只作用于记忆快照**),超期自动清理。
|
|
114
|
+
|
|
115
|
+
**日记另走一条路**:`<DSH_HOME>/diary/*.md` 与记忆同一轮复制,但落在**固定目录** `~/.dsh-backups/memory/diary-archive/`,**只增不删**,不参与上面的轮转。两边的不对称是实的:记忆快照丢一份还有别的份(同一份数据的多个时间点副本),归档里每一篇都是日记的唯一副本,丢了没有任何地方能重建。日记那一侧改过就以日记为准覆盖(人可能在事后改过错字),归档里有而日记里已没有的篇目一律保留——那正是归档的全部价值。归档目录名不匹配轮转正则,因此不需要给清理逻辑开白名单。
|
|
93
116
|
|
|
94
|
-
**无界面**:这是行为类功能,不新增按钮、面板或设置项——行为效果即上面那段:部署后 `~/.dsh-backups/memory/` 下每小时多一个目录,目录里是当次复制出的 `memory.json`、`memory_project_*.json` 与 `manifest.json`。
|
|
117
|
+
**无界面**:这是行为类功能,不新增按钮、面板或设置项——行为效果即上面那段:部署后 `~/.dsh-backups/memory/` 下每小时多一个目录,目录里是当次复制出的 `memory.json`、`memory_project_*.json` 与 `manifest.json`;`diary-archive/` 里则按天累积 `YYYY-MM-DD.md`。
|
|
95
118
|
|
|
96
119
|
| 项 | 默认值 |
|
|
97
120
|
|---|---|
|
|
98
|
-
| 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project
|
|
99
|
-
| 目标目录 |
|
|
121
|
+
| 备份内容 | `memory.json`(global)+ `memory_project_*.json`(project)+ `<DSH_HOME>/diary/*.md` |
|
|
122
|
+
| 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
|
|
100
123
|
| 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
|
|
101
|
-
| 保留 |
|
|
124
|
+
| 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
|
|
102
125
|
| 失败行为 | 只写日志,不影响记忆读写与启动 |
|
|
103
126
|
|
|
104
127
|
**边界**:
|
|
105
128
|
|
|
106
129
|
- 只备份**当前工作区**的 project 文件(`<cwd>/.dsh/storages/`);其它工作区的 project 记忆不在备份范围。
|
|
107
130
|
- 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
|
|
131
|
+
- 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
|
|
108
132
|
|
|
109
133
|
## 使用流程(写入即生效,人为例外干预)
|
|
110
134
|
|
|
@@ -161,6 +185,9 @@ memory_forget → 随时删除(删除始终是人的动作)
|
|
|
161
185
|
keep: 14 # 保留份数
|
|
162
186
|
# targetRoot: 'D:/dsh-backups/memory' # 自定义备份根
|
|
163
187
|
# enabled: false # 关掉
|
|
188
|
+
# 日记开关(2026-10-24;也可在插件设置页里直接改,等价)
|
|
189
|
+
diary:
|
|
190
|
+
enabled: true
|
|
164
191
|
```
|
|
165
192
|
|
|
166
193
|
`embeddings` 接受 `{ embed(texts): Promise<number[][]>, similarity? }` 对象——由宿主装配层提供嵌入实现(如 DeepSeek 嵌入端点);配置后 `memory_search` 以 BM25 + 语义 RRF 融合,向量增量生成并持久化,嵌入调用失败自动降级为纯 BM25。
|
package/client.js
CHANGED
|
@@ -1031,6 +1031,85 @@ function SettingsMemoryView(props) {
|
|
|
1031
1031
|
cwd: currentSessionCwd(props.ctx)
|
|
1032
1032
|
});
|
|
1033
1033
|
}
|
|
1034
|
+
function DiarySettingsCard(props) {
|
|
1035
|
+
const { form } = props;
|
|
1036
|
+
const [enabled, setEnabled] = (0, import_react.useState)(true);
|
|
1037
|
+
(0, import_react.useEffect)(() => {
|
|
1038
|
+
const read = () => {
|
|
1039
|
+
setEnabled(form.getSnapshot()?.value?.diary?.enabled ?? true);
|
|
1040
|
+
};
|
|
1041
|
+
read();
|
|
1042
|
+
const off = form.subscribe(read);
|
|
1043
|
+
return () => {
|
|
1044
|
+
if (typeof off === "function") off();
|
|
1045
|
+
};
|
|
1046
|
+
}, [form]);
|
|
1047
|
+
return (0, import_react.createElement)(
|
|
1048
|
+
"div",
|
|
1049
|
+
{
|
|
1050
|
+
style: {
|
|
1051
|
+
display: "flex",
|
|
1052
|
+
alignItems: "center",
|
|
1053
|
+
justifyContent: "space-between",
|
|
1054
|
+
gap: 12,
|
|
1055
|
+
padding: "12px 0",
|
|
1056
|
+
borderTop: "1px solid var(--dsw-alias-border-l2, #1e2836)"
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
(0, import_react.createElement)(
|
|
1060
|
+
"div",
|
|
1061
|
+
{ style: { minWidth: 0 } },
|
|
1062
|
+
(0, import_react.createElement)("div", { style: { fontSize: 13, fontWeight: 600, color: "var(--dsw-alias-label-primary, #d8e0ea)" } }, "\u65E5\u8BB0"),
|
|
1063
|
+
(0, import_react.createElement)(
|
|
1064
|
+
"div",
|
|
1065
|
+
{ style: { fontSize: 11.5, color: "var(--dsw-alias-label-secondary, #67748a)", marginTop: 3, lineHeight: 1.5 } },
|
|
1066
|
+
"\u672C\u673A\u5199\u65E5\u8BB0\u3002\u5173\u6389\u540E\u65E5\u8BB0\u5DE5\u5177\u62D2\u7EDD\u6267\u884C\u3001\u4E0D\u518D\u6CE8\u5165\u6307\u9488\uFF1B\u5DF2\u6709\u65E5\u8BB0\u6587\u4EF6\u539F\u6837\u4FDD\u7559\uFF0C\u4E0D\u4F1A\u88AB\u5220\u3002"
|
|
1067
|
+
)
|
|
1068
|
+
),
|
|
1069
|
+
(0, import_react.createElement)(
|
|
1070
|
+
"button",
|
|
1071
|
+
{
|
|
1072
|
+
type: "button",
|
|
1073
|
+
role: "switch",
|
|
1074
|
+
"aria-checked": enabled,
|
|
1075
|
+
"aria-label": "\u65E5\u8BB0\u5F00\u5173",
|
|
1076
|
+
onClick: () => {
|
|
1077
|
+
try {
|
|
1078
|
+
const pending = form.mutate([{ op: "set", path: ["diary", "enabled"], value: !enabled }]);
|
|
1079
|
+
void Promise.resolve(pending).catch((error) => {
|
|
1080
|
+
console.warn("dsh-memory: \u65E5\u8BB0\u5F00\u5173\u5199\u5165\u5931\u8D25", error);
|
|
1081
|
+
});
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
console.warn("dsh-memory: \u65E5\u8BB0\u5F00\u5173\u5199\u5165\u629B\u9519", error);
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
style: {
|
|
1087
|
+
flex: "none",
|
|
1088
|
+
width: 40,
|
|
1089
|
+
height: 22,
|
|
1090
|
+
borderRadius: 11,
|
|
1091
|
+
border: "none",
|
|
1092
|
+
cursor: "pointer",
|
|
1093
|
+
position: "relative",
|
|
1094
|
+
transition: "background 120ms",
|
|
1095
|
+
background: enabled ? "var(--dsw-alias-state-business-primary, #4FC3F7)" : "var(--dsw-alias-border-l2, #1e2836)"
|
|
1096
|
+
}
|
|
1097
|
+
},
|
|
1098
|
+
(0, import_react.createElement)("span", {
|
|
1099
|
+
style: {
|
|
1100
|
+
position: "absolute",
|
|
1101
|
+
top: 3,
|
|
1102
|
+
left: enabled ? 21 : 3,
|
|
1103
|
+
width: 16,
|
|
1104
|
+
height: 16,
|
|
1105
|
+
borderRadius: "50%",
|
|
1106
|
+
background: "#fff",
|
|
1107
|
+
transition: "left 120ms"
|
|
1108
|
+
}
|
|
1109
|
+
})
|
|
1110
|
+
)
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1034
1113
|
function apply(ctx) {
|
|
1035
1114
|
const face = ctx;
|
|
1036
1115
|
const locale = face.get?.("locale");
|
|
@@ -1053,6 +1132,19 @@ function apply(ctx) {
|
|
|
1053
1132
|
})));
|
|
1054
1133
|
face.effect?.(() => registerSettingsNavIcon(() => STRINGS[localeId].tabMemory), "dsh-memory: settings navigation icon");
|
|
1055
1134
|
}
|
|
1135
|
+
try {
|
|
1136
|
+
const configForms = face.get?.("configForms");
|
|
1137
|
+
const settingsForm = configForms?.get?.("memory");
|
|
1138
|
+
if (slots?.inject !== void 0 && settingsForm !== void 0) {
|
|
1139
|
+
slots.inject("plugins.bundle.config", () => slots.register({
|
|
1140
|
+
name: "plugins.bundle.config",
|
|
1141
|
+
key: "@max-null/dsh-memory",
|
|
1142
|
+
inject: () => ({})
|
|
1143
|
+
}, () => (0, import_react.createElement)(DiarySettingsCard, { form: settingsForm })));
|
|
1144
|
+
}
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
console.warn("dsh-memory: \u65E5\u8BB0\u914D\u7F6E\u5361\u7247\u672A\u6CE8\u518C\uFF08\u4E0D\u5F71\u54CD\u8BB0\u5FC6\u4E0E\u65E5\u8BB0\u5DE5\u5177\uFF09", error);
|
|
1147
|
+
}
|
|
1056
1148
|
const root = ctx;
|
|
1057
1149
|
if (root.inject === void 0) return;
|
|
1058
1150
|
root.inject(["betterSidebar"], (sidebarCtx) => {
|
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/diary.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日记的存储层 —— 与记忆的 block 存储**完全分离**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不复用 `ctx.storage` / `storageDomain` / `storage-json`:那条路的写入模型是
|
|
5
|
+
* **writer per process and last-write-wins**(`packages/storage/storage-json/src/index.ts:5-9`),
|
|
6
|
+
* sqlite 的 README 明写 **cross-process coordination is out of scope**。2026-09-17 实测中
|
|
7
|
+
* `workspace.json` 与 `memory.json` 都被另一个实例静默覆盖过。日记只由本模块的 `node:fs`
|
|
8
|
+
* 读写,不进那套服务,就不会被任何实例的「读—改—写」周期吃掉。
|
|
9
|
+
*
|
|
10
|
+
* @see docs/设计/2026-10-24-日记机制-设计方案.md §四
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* 本地时区的日期串 `YYYY-MM-DD`。
|
|
14
|
+
*
|
|
15
|
+
* 用 `sv-SE` 的本地化格式取日期而不是手拼 `getMonth()+1`:`sv-SE` 的日期格式恰好是
|
|
16
|
+
* ISO 顺序且按本地时区输出,省掉补零与时区换算两处容易写错的地方。
|
|
17
|
+
* @param at - 毫秒时间戳;缺省取当前时刻。
|
|
18
|
+
* @returns `YYYY-MM-DD`。
|
|
19
|
+
*/
|
|
20
|
+
export declare function localDate(at?: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* harness home:`$DSH_HOME`,缺省 `~/.dsh`。
|
|
23
|
+
*
|
|
24
|
+
* 空或仅空白的 `$DSH_HOME` 视为未设置(与官方 `resolveDshHome` 一致),否则空值会把
|
|
25
|
+
* 路径解析到进程当前目录。
|
|
26
|
+
* @returns 绝对路径。
|
|
27
|
+
*/
|
|
28
|
+
export declare function dshHome(): string;
|
|
29
|
+
/**
|
|
30
|
+
* 日记目录:`<DSH_HOME>/diary`,缺省 `~/.dsh/diary`。
|
|
31
|
+
*
|
|
32
|
+
* 与 `storages/`、`storages-ssid/` 平级,互不相干——这是「不被覆盖」这条判据的落点,
|
|
33
|
+
* 不是随手选的位置。
|
|
34
|
+
* @returns 绝对路径(目录可能尚不存在)。
|
|
35
|
+
*/
|
|
36
|
+
export declare function diaryDir(): string;
|
|
37
|
+
/**
|
|
38
|
+
* 某一天的日记文件路径。
|
|
39
|
+
* @param date - `YYYY-MM-DD`。
|
|
40
|
+
* @returns 绝对路径。
|
|
41
|
+
*/
|
|
42
|
+
export declare function dayFile(date: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* 读某一天的日记原文。
|
|
45
|
+
* @param date - `YYYY-MM-DD`。
|
|
46
|
+
* @returns 原文;这一天没有日记时 `undefined`(不是空串——「没有」与「写了空」要能分开)。
|
|
47
|
+
*/
|
|
48
|
+
export declare function readDay(date: string): string | undefined;
|
|
49
|
+
/** {@link appendEntry} 的结果。 */
|
|
50
|
+
export interface AppendResult {
|
|
51
|
+
/** 写入的文件绝对路径。 */
|
|
52
|
+
readonly file: string;
|
|
53
|
+
/** 写入所属的日期(本地时区)。 */
|
|
54
|
+
readonly date: string;
|
|
55
|
+
/** 这一天是不是本次才创建的。 */
|
|
56
|
+
readonly created: boolean;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 往当天的日记追加一段。
|
|
60
|
+
*
|
|
61
|
+
* **追加而不是覆盖**:一天里可能有多场对话,每场结束各写一段;覆盖会让前一扬的内容消失。
|
|
62
|
+
* 用 `appendFileSync` 而非「读全文—拼接—写回」:追加在 POSIX 上是带 `O_APPEND` 的单次写,
|
|
63
|
+
* 即便两个实例同时追加也不会互相截断(长度交错是可能的,内容不会丢)。
|
|
64
|
+
* @param text - 这一段正文;首尾空白会被裁掉,空内容被拒绝。
|
|
65
|
+
* @param at - 毫秒时间戳;缺省取当前时刻。
|
|
66
|
+
* @returns 写入位置与是否新建。
|
|
67
|
+
* @throws 当 `text` 裁掉空白后为空。
|
|
68
|
+
*/
|
|
69
|
+
export declare function appendEntry(text: string, at?: number): AppendResult;
|
|
70
|
+
/**
|
|
71
|
+
* 最近写过日记的日子,从新到旧。
|
|
72
|
+
* @param limit - 最多返回多少天。
|
|
73
|
+
* @returns `YYYY-MM-DD` 列表;目录不存在时为空。
|
|
74
|
+
*/
|
|
75
|
+
export declare function recentDates(limit?: number): string[];
|
|
76
|
+
/**
|
|
77
|
+
* 最后一次写入日记的时间戳。收尾节流用它判断「该不该提醒写日记」。
|
|
78
|
+
* @returns 毫秒时间戳;一篇日记都没有时 `undefined`。
|
|
79
|
+
*/
|
|
80
|
+
export declare function lastWriteAt(): number | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* 注入用的指针行。**只有目录与最近日期,不含正文**——注入预算属于记忆,日记不占。
|
|
83
|
+
*
|
|
84
|
+
* 这一行本身是「失效可察觉性」判据的应用:它不在,我不会意识到自己有日记可读。
|
|
85
|
+
* @returns 一行 markdown 文本。
|
|
86
|
+
*/
|
|
87
|
+
export declare function pointerLine(): string;
|
package/dist/diary.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日记的存储层 —— 与记忆的 block 存储**完全分离**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不复用 `ctx.storage` / `storageDomain` / `storage-json`:那条路的写入模型是
|
|
5
|
+
* **writer per process and last-write-wins**(`packages/storage/storage-json/src/index.ts:5-9`),
|
|
6
|
+
* sqlite 的 README 明写 **cross-process coordination is out of scope**。2026-09-17 实测中
|
|
7
|
+
* `workspace.json` 与 `memory.json` 都被另一个实例静默覆盖过。日记只由本模块的 `node:fs`
|
|
8
|
+
* 读写,不进那套服务,就不会被任何实例的「读—改—写」周期吃掉。
|
|
9
|
+
*
|
|
10
|
+
* @see docs/设计/2026-10-24-日记机制-设计方案.md §四
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/**
|
|
16
|
+
* 本地时区的日期串 `YYYY-MM-DD`。
|
|
17
|
+
*
|
|
18
|
+
* 用 `sv-SE` 的本地化格式取日期而不是手拼 `getMonth()+1`:`sv-SE` 的日期格式恰好是
|
|
19
|
+
* ISO 顺序且按本地时区输出,省掉补零与时区换算两处容易写错的地方。
|
|
20
|
+
* @param at - 毫秒时间戳;缺省取当前时刻。
|
|
21
|
+
* @returns `YYYY-MM-DD`。
|
|
22
|
+
*/
|
|
23
|
+
export function localDate(at = Date.now()) {
|
|
24
|
+
return new Date(at).toLocaleDateString('sv-SE');
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 展开前导波浪号。
|
|
28
|
+
*
|
|
29
|
+
* `$DSH_HOME` 允许写成 `~/env-dsh`(官方 `resolveDshHome` 同样处理),而 `path.resolve`
|
|
30
|
+
* 不会展开它,直接拼会得到一个字面名为 `~` 的目录。
|
|
31
|
+
* @param value - 可能带 `~` 的路径。
|
|
32
|
+
* @returns 绝对路径。
|
|
33
|
+
*/
|
|
34
|
+
function expandHome(value) {
|
|
35
|
+
return value.startsWith('~') ? path.join(os.homedir(), value.slice(1)) : value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* harness home:`$DSH_HOME`,缺省 `~/.dsh`。
|
|
39
|
+
*
|
|
40
|
+
* 空或仅空白的 `$DSH_HOME` 视为未设置(与官方 `resolveDshHome` 一致),否则空值会把
|
|
41
|
+
* 路径解析到进程当前目录。
|
|
42
|
+
* @returns 绝对路径。
|
|
43
|
+
*/
|
|
44
|
+
export function dshHome() {
|
|
45
|
+
const raw = process.env.DSH_HOME;
|
|
46
|
+
return raw !== undefined && raw.trim() !== ''
|
|
47
|
+
? path.resolve(expandHome(raw.trim()))
|
|
48
|
+
: path.join(os.homedir(), '.dsh');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 日记目录:`<DSH_HOME>/diary`,缺省 `~/.dsh/diary`。
|
|
52
|
+
*
|
|
53
|
+
* 与 `storages/`、`storages-ssid/` 平级,互不相干——这是「不被覆盖」这条判据的落点,
|
|
54
|
+
* 不是随手选的位置。
|
|
55
|
+
* @returns 绝对路径(目录可能尚不存在)。
|
|
56
|
+
*/
|
|
57
|
+
export function diaryDir() {
|
|
58
|
+
return path.join(dshHome(), 'diary');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 某一天的日记文件路径。
|
|
62
|
+
* @param date - `YYYY-MM-DD`。
|
|
63
|
+
* @returns 绝对路径。
|
|
64
|
+
*/
|
|
65
|
+
export function dayFile(date) {
|
|
66
|
+
return path.join(diaryDir(), `${date}.md`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 读某一天的日记原文。
|
|
70
|
+
* @param date - `YYYY-MM-DD`。
|
|
71
|
+
* @returns 原文;这一天没有日记时 `undefined`(不是空串——「没有」与「写了空」要能分开)。
|
|
72
|
+
*/
|
|
73
|
+
export function readDay(date) {
|
|
74
|
+
try {
|
|
75
|
+
return fs.readFileSync(dayFile(date), 'utf8');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// 文件不存在就是没有这一天的日记,是本函数的正常返回,不是故障。
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 往当天的日记追加一段。
|
|
84
|
+
*
|
|
85
|
+
* **追加而不是覆盖**:一天里可能有多场对话,每场结束各写一段;覆盖会让前一扬的内容消失。
|
|
86
|
+
* 用 `appendFileSync` 而非「读全文—拼接—写回」:追加在 POSIX 上是带 `O_APPEND` 的单次写,
|
|
87
|
+
* 即便两个实例同时追加也不会互相截断(长度交错是可能的,内容不会丢)。
|
|
88
|
+
* @param text - 这一段正文;首尾空白会被裁掉,空内容被拒绝。
|
|
89
|
+
* @param at - 毫秒时间戳;缺省取当前时刻。
|
|
90
|
+
* @returns 写入位置与是否新建。
|
|
91
|
+
* @throws 当 `text` 裁掉空白后为空。
|
|
92
|
+
*/
|
|
93
|
+
export function appendEntry(text, at = Date.now()) {
|
|
94
|
+
const body = text.trim();
|
|
95
|
+
if (body === '')
|
|
96
|
+
throw new Error('日记内容为空');
|
|
97
|
+
const date = localDate(at);
|
|
98
|
+
const file = dayFile(date);
|
|
99
|
+
fs.mkdirSync(diaryDir(), { recursive: true });
|
|
100
|
+
const created = !fs.existsSync(file);
|
|
101
|
+
const hhmm = new Date(at).toTimeString().slice(0, 5);
|
|
102
|
+
// 新建时补一级标题;之后的每一段只占一个二级标题。
|
|
103
|
+
const chunk = created
|
|
104
|
+
? `# ${date}\n\n## ${hhmm}\n\n${body}\n`
|
|
105
|
+
: `\n## ${hhmm}\n\n${body}\n`;
|
|
106
|
+
fs.appendFileSync(file, chunk, 'utf8');
|
|
107
|
+
return { file, date, created };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 最近写过日记的日子,从新到旧。
|
|
111
|
+
* @param limit - 最多返回多少天。
|
|
112
|
+
* @returns `YYYY-MM-DD` 列表;目录不存在时为空。
|
|
113
|
+
*/
|
|
114
|
+
export function recentDates(limit = 7) {
|
|
115
|
+
let names;
|
|
116
|
+
try {
|
|
117
|
+
names = fs.readdirSync(diaryDir());
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// 目录还没建过——一篇日记都没有,是正常状态。
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
return names
|
|
124
|
+
.filter(name => /^\d{4}-\d{2}-\d{2}\.md$/.test(name))
|
|
125
|
+
.map(name => name.slice(0, 10))
|
|
126
|
+
.sort()
|
|
127
|
+
.reverse()
|
|
128
|
+
.slice(0, limit);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 最后一次写入日记的时间戳。收尾节流用它判断「该不该提醒写日记」。
|
|
132
|
+
* @returns 毫秒时间戳;一篇日记都没有时 `undefined`。
|
|
133
|
+
*/
|
|
134
|
+
export function lastWriteAt() {
|
|
135
|
+
let names;
|
|
136
|
+
try {
|
|
137
|
+
names = fs.readdirSync(diaryDir());
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const dir = diaryDir();
|
|
143
|
+
let latest;
|
|
144
|
+
for (const name of names) {
|
|
145
|
+
if (!/^\d{4}-\d{2}-\d{2}\.md$/.test(name))
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
const m = fs.statSync(path.join(dir, name)).mtimeMs;
|
|
149
|
+
if (latest === undefined || m > latest)
|
|
150
|
+
latest = m;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// 扫目录与 stat 之间被删:跳过这一天,不影响其余日期的判断。
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return latest;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* 注入用的指针行。**只有目录与最近日期,不含正文**——注入预算属于记忆,日记不占。
|
|
160
|
+
*
|
|
161
|
+
* 这一行本身是「失效可察觉性」判据的应用:它不在,我不会意识到自己有日记可读。
|
|
162
|
+
* @returns 一行 markdown 文本。
|
|
163
|
+
*/
|
|
164
|
+
export function pointerLine() {
|
|
165
|
+
const dir = diaryDir();
|
|
166
|
+
const dates = recentDates(3);
|
|
167
|
+
return dates.length === 0
|
|
168
|
+
? `日记目录:${dir}(还没有任何一篇)`
|
|
169
|
+
: `日记目录:${dir};最近写入:${dates.join('、')}`;
|
|
170
|
+
}
|
package/dist/engine.d.ts
CHANGED
|
@@ -319,6 +319,17 @@ export interface MemoryConfig {
|
|
|
319
319
|
* 实现只读文件、只复制,见 `backup.ts` 的文件头。
|
|
320
320
|
*/
|
|
321
321
|
backup?: MemoryBackupConfig;
|
|
322
|
+
/**
|
|
323
|
+
* 日记开关的**部署默认值**(2026-10-24)。运行中的选择在面板里改,写入
|
|
324
|
+
* `$DSH_HOME/memory-settings.json`;两边都开才开——否则部署时关掉会被
|
|
325
|
+
* 使用者那一侧缺省的 `true` 翻回来。
|
|
326
|
+
*
|
|
327
|
+
* 关掉时日记工具会拒绝写入、不注入指针、也不提醒;已有的 md 文件原样保留
|
|
328
|
+
* (删除是使用者的动作,不该由开关代劳)。
|
|
329
|
+
*/
|
|
330
|
+
diary?: {
|
|
331
|
+
enabled?: boolean;
|
|
332
|
+
};
|
|
322
333
|
/**
|
|
323
334
|
* 查询日志文件路径(2026-09-15 §六.6);默认为全局存储根下的 `query-log.json`
|
|
324
335
|
* ——即与 `memory.json` **同目录**(`$DSH_HOME/storages/query-log.json`),不落子目录。
|
package/dist/index.d.ts
CHANGED
|
@@ -6,12 +6,66 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Context } from '@deepseek-ai/cordis';
|
|
8
8
|
import type { MemoryConfig } from './engine.ts';
|
|
9
|
+
import z from '@deepseek-ai/schemastery';
|
|
9
10
|
export { MemoryEngine } from './engine.ts';
|
|
10
11
|
export type { MemoryChange, MemoryConfig, MemoryFilter, MemoryHit, MemoryNamespace, MemoryRecord, MemoryStatus, MemoryWrite, } from './engine.ts';
|
|
11
12
|
export { MemoryId } from './engine.ts';
|
|
12
13
|
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from './bm25.ts';
|
|
13
14
|
export declare const name = "dsh-memory";
|
|
14
15
|
export declare const inject: string[];
|
|
16
|
+
/**
|
|
17
|
+
* 插件配置 schema(0.19.0)。loader 依它自动构成「插件」页里的配置卡片,namespace 即
|
|
18
|
+
* entry id(`memory`);**可写字段必须标 `.volatile()`**,否则 `settings` 会以
|
|
19
|
+
* `Config field "…" is not volatile` 拒绝写入。
|
|
20
|
+
*
|
|
21
|
+
* 除 `diary` 外的字段一律 `z.optional()` 原样透传:它们的取值依赖运行时
|
|
22
|
+
* (`$DSH_HOME`、各平台路径),schema 给不出有意义的默认值,硬填一个反而会顶掉
|
|
23
|
+
* `MemoryConfig` 那套 `?? 默认` 的解析。`embeddings` / `backup` 是结构化配置,
|
|
24
|
+
* 由 engine / backup 各自按领域校验,这一层只负责透传。
|
|
25
|
+
*/
|
|
26
|
+
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
27
|
+
globalRoot: z<string, string, "plain">;
|
|
28
|
+
projectRoot: z<string, string, "plain">;
|
|
29
|
+
injectionBudget: z<number, number, "plain">;
|
|
30
|
+
summaryChars: z<number, number, "plain">;
|
|
31
|
+
promptGlobalRoot: z<string, string, "plain">;
|
|
32
|
+
queryLogPath: z<string, string, "plain">;
|
|
33
|
+
semanticTopK: z<number, number, "plain">;
|
|
34
|
+
embeddings: z<any, any, "plain">;
|
|
35
|
+
backup: z<any, any, "plain">;
|
|
36
|
+
/**
|
|
37
|
+
* 日记开关。默认开——这是**功能默认,不是采集默认**。
|
|
38
|
+
*
|
|
39
|
+
* 关掉时日记工具拒绝写入、不注入指针、不提醒;已有 md 文件原样保留
|
|
40
|
+
* (删除是使用者的动作,不该由开关代劳)。
|
|
41
|
+
*/
|
|
42
|
+
diary: z<NoInfer<Schemastery.ObjectS<NoInfer<{
|
|
43
|
+
enabled: z<boolean, boolean, "defined">;
|
|
44
|
+
}>>>, NoInfer<Schemastery.ObjectT<NoInfer<{
|
|
45
|
+
enabled: z<boolean, boolean, "defined">;
|
|
46
|
+
}>>>, "volatile-defined">;
|
|
47
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
48
|
+
globalRoot: z<string, string, "plain">;
|
|
49
|
+
projectRoot: z<string, string, "plain">;
|
|
50
|
+
injectionBudget: z<number, number, "plain">;
|
|
51
|
+
summaryChars: z<number, number, "plain">;
|
|
52
|
+
promptGlobalRoot: z<string, string, "plain">;
|
|
53
|
+
queryLogPath: z<string, string, "plain">;
|
|
54
|
+
semanticTopK: z<number, number, "plain">;
|
|
55
|
+
embeddings: z<any, any, "plain">;
|
|
56
|
+
backup: z<any, any, "plain">;
|
|
57
|
+
/**
|
|
58
|
+
* 日记开关。默认开——这是**功能默认,不是采集默认**。
|
|
59
|
+
*
|
|
60
|
+
* 关掉时日记工具拒绝写入、不注入指针、不提醒;已有 md 文件原样保留
|
|
61
|
+
* (删除是使用者的动作,不该由开关代劳)。
|
|
62
|
+
*/
|
|
63
|
+
diary: z<NoInfer<Schemastery.ObjectS<NoInfer<{
|
|
64
|
+
enabled: z<boolean, boolean, "defined">;
|
|
65
|
+
}>>>, NoInfer<Schemastery.ObjectT<NoInfer<{
|
|
66
|
+
enabled: z<boolean, boolean, "defined">;
|
|
67
|
+
}>>>, "volatile-defined">;
|
|
68
|
+
}>>, "plain">;
|
|
15
69
|
/**
|
|
16
70
|
* 本次挂载实际注册的工具名,按注册顺序。
|
|
17
71
|
*
|
package/dist/index.js
CHANGED
|
@@ -13,12 +13,66 @@ 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, diaryDir, lastWriteAt, pointerLine, readDay, recentDates } from "./diary.js";
|
|
17
|
+
import z from '@deepseek-ai/schemastery';
|
|
16
18
|
import { DEFAULT_INJECTION_BUDGET, DEFAULT_SUMMARY_CHARS, candidateNotice, indexNotice, neighborNotice, neutralizeBraces, omittedNotice, renderInjection, staleNotice } from "./injection.js";
|
|
17
19
|
export { MemoryEngine } from "./engine.js";
|
|
18
20
|
export { MemoryId } from "./engine.js";
|
|
19
21
|
export { bm25FieldScores, bm25Scores, cosineSimilarity, rrfFuse, tokenize } from "./bm25.js";
|
|
20
22
|
export const name = 'dsh-memory';
|
|
21
23
|
export const inject = ['storage', 'systemPrompt', 'tools', 'webServer', 'webStartup'];
|
|
24
|
+
/**
|
|
25
|
+
* 插件配置 schema(0.19.0)。loader 依它自动构成「插件」页里的配置卡片,namespace 即
|
|
26
|
+
* entry id(`memory`);**可写字段必须标 `.volatile()`**,否则 `settings` 会以
|
|
27
|
+
* `Config field "…" is not volatile` 拒绝写入。
|
|
28
|
+
*
|
|
29
|
+
* 除 `diary` 外的字段一律 `z.optional()` 原样透传:它们的取值依赖运行时
|
|
30
|
+
* (`$DSH_HOME`、各平台路径),schema 给不出有意义的默认值,硬填一个反而会顶掉
|
|
31
|
+
* `MemoryConfig` 那套 `?? 默认` 的解析。`embeddings` / `backup` 是结构化配置,
|
|
32
|
+
* 由 engine / backup 各自按领域校验,这一层只负责透传。
|
|
33
|
+
*/
|
|
34
|
+
export const Config = z.object({
|
|
35
|
+
// schemastery 里字段**默认就是可选的**,`.required()` 才相反,所以这里不写
|
|
36
|
+
// `.optional()`——那个方法不存在。
|
|
37
|
+
globalRoot: z.string(),
|
|
38
|
+
projectRoot: z.string(),
|
|
39
|
+
injectionBudget: z.number(),
|
|
40
|
+
summaryChars: z.number(),
|
|
41
|
+
promptGlobalRoot: z.string(),
|
|
42
|
+
queryLogPath: z.string(),
|
|
43
|
+
semanticTopK: z.number(),
|
|
44
|
+
embeddings: z.any(),
|
|
45
|
+
backup: z.any(),
|
|
46
|
+
/**
|
|
47
|
+
* 日记开关。默认开——这是**功能默认,不是采集默认**。
|
|
48
|
+
*
|
|
49
|
+
* 关掉时日记工具拒绝写入、不注入指针、不提醒;已有 md 文件原样保留
|
|
50
|
+
* (删除是使用者的动作,不该由开关代劳)。
|
|
51
|
+
*/
|
|
52
|
+
diary: z.object({
|
|
53
|
+
enabled: z.boolean().default(true),
|
|
54
|
+
}).default({ enabled: true }).volatile(),
|
|
55
|
+
});
|
|
56
|
+
/**
|
|
57
|
+
* 读日记开关。
|
|
58
|
+
*
|
|
59
|
+
* `config.diary` 经 loader 解析后是 `Volatile<{ enabled }>` 包装——`settings/src/schema.ts`
|
|
60
|
+
* 的解包就是 `isVolatile(value) → value.get()`;而手写配置与测试传的是裸对象。两种都要认。
|
|
61
|
+
* 解出来不是 `false` 就按开处理:读不到值时的默认行为必须与 schema 的 default 一致。
|
|
62
|
+
* @param config - 插件配置。
|
|
63
|
+
* @returns 是否启用日记。
|
|
64
|
+
*/
|
|
65
|
+
function diaryOn(config) {
|
|
66
|
+
const raw = config?.diary;
|
|
67
|
+
if (raw === undefined || raw === null)
|
|
68
|
+
return true;
|
|
69
|
+
const unwrapped = typeof raw.get === 'function'
|
|
70
|
+
? raw.get()
|
|
71
|
+
: raw;
|
|
72
|
+
return unwrapped?.enabled !== false;
|
|
73
|
+
}
|
|
74
|
+
/** 日记提醒的冷却窗口(毫秒)。6 小时是拍出来的第一个值,实测后再调。 */
|
|
75
|
+
const DIARY_REMIND_MS = 6 * 60 * 60 * 1000;
|
|
22
76
|
/**
|
|
23
77
|
* 本次挂载实际注册的工具名,按注册顺序。
|
|
24
78
|
*
|
|
@@ -386,6 +440,34 @@ function renderJson(value) {
|
|
|
386
440
|
function present(title, kind, rawInput) {
|
|
387
441
|
return { card: 'generic', title, kind, ...rawInput === undefined ? {} : { rawInput } };
|
|
388
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* `diary_write` 的返回值(2026-10-24 日记机制)。
|
|
445
|
+
*
|
|
446
|
+
* 只回写入位置与归属,不回正文:模型刚写完,再念一遍自己的字是纯浪费上下文。
|
|
447
|
+
*/
|
|
448
|
+
const DIARY_WRITE_SCHEMA = {
|
|
449
|
+
type: 'object',
|
|
450
|
+
additionalProperties: false,
|
|
451
|
+
properties: {
|
|
452
|
+
file: { type: 'string', required: true, description: '日记文件的绝对路径。' },
|
|
453
|
+
date: { type: 'string', required: true, description: '这一段归属的日期(YYYY-MM-DD)。' },
|
|
454
|
+
created: { type: 'boolean', required: true, description: '这一天是不是本次才创建的。' },
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
/**
|
|
458
|
+
* `diary_read` 的返回值。
|
|
459
|
+
*
|
|
460
|
+
* `exists` 与 `text` 分开:缺席是正常状态(那天没有日记),不是空内容。
|
|
461
|
+
*/
|
|
462
|
+
const DIARY_READ_SCHEMA = {
|
|
463
|
+
type: 'object',
|
|
464
|
+
additionalProperties: false,
|
|
465
|
+
properties: {
|
|
466
|
+
date: { type: 'string', required: true, description: '读的是哪一天;一天都没有时为空串。' },
|
|
467
|
+
text: { type: 'string', required: true, description: '当天日记原文;没有这一天时为空串。' },
|
|
468
|
+
exists: { type: 'boolean', required: true, description: '这一天有没有日记。' },
|
|
469
|
+
},
|
|
470
|
+
};
|
|
389
471
|
export async function apply(ctx, config) {
|
|
390
472
|
// 每次挂载都是一次全新的注册——清空上一轮的登记。它是模块级数组(测试要能 import 它),
|
|
391
473
|
// 不清的话同一进程里挂载两次会累积成双份,而消费方(自述覆盖用例)拿到的数量就对不上了。
|
|
@@ -421,6 +503,48 @@ export async function apply(ctx, config) {
|
|
|
421
503
|
return recallText(memory, cwd, injectionBudget, summaryChars);
|
|
422
504
|
},
|
|
423
505
|
});
|
|
506
|
+
// 日记指针(0.19.0):**只给目录与最近日期,正文不进注入**——注入预算是记忆的,
|
|
507
|
+
// 日记不占。这一行本身是「失效可察觉性」判据的应用:它不在,我不会意识到自己有
|
|
508
|
+
// 日记可读,也就永远不会去读。
|
|
509
|
+
//
|
|
510
|
+
// 关闭时**整条不注册**而不是注入空块:配置变化会重跑 `apply`(cordis 对 entry config
|
|
511
|
+
// 的判定),所以开关切换后这条会自然地出现或消失,不需要 assemble 侧再过滤。
|
|
512
|
+
// 提醒的节流状态挂在这个实例上,不是模块级全局:一次挂载一份,配置重载即重置,
|
|
513
|
+
// 测试之间也不会互相污染(模块级那份会让第二个用例看到第一个用例留下的冷却)。
|
|
514
|
+
let lastRemindAt = 0;
|
|
515
|
+
/**
|
|
516
|
+
* 日记指针行,附带**节流后的**一句提醒。
|
|
517
|
+
*
|
|
518
|
+
* 提醒搭在指针行上,而不是另起一条消息:这样不需要 `agent/pre-step` 与
|
|
519
|
+
* `createUserMessage`(那会多三个 peer 依赖),代价是提醒进的是 system prompt
|
|
520
|
+
* 而不是对话消息——轻一点,也够模型看见。
|
|
521
|
+
*
|
|
522
|
+
* **节流取「距上次写日记」与「距上次提醒」的较小者**:只按写日记算的话,一个决定
|
|
523
|
+
* 永不写的使用者会被每轮催;只按提醒算的话,刚写完还会被催一次。两者取小才对。
|
|
524
|
+
* 提醒本身只出现一次就够——不重复催,它才像一句话而不像弹窗。
|
|
525
|
+
* @returns 注入文本(关闭日记时不会走到这里)。
|
|
526
|
+
*/
|
|
527
|
+
const diaryText = () => {
|
|
528
|
+
const pointer = pointerLine();
|
|
529
|
+
const now = Date.now();
|
|
530
|
+
const written = lastWriteAt();
|
|
531
|
+
const gapWrite = written === undefined ? Number.POSITIVE_INFINITY : now - written;
|
|
532
|
+
const gapRemind = lastRemindAt === 0 ? Number.POSITIVE_INFINITY : now - lastRemindAt;
|
|
533
|
+
if (Math.min(gapWrite, gapRemind) < DIARY_REMIND_MS)
|
|
534
|
+
return pointer;
|
|
535
|
+
lastRemindAt = now;
|
|
536
|
+
const detail = written === undefined
|
|
537
|
+
? '还没写过'
|
|
538
|
+
: `距上一篇已 ${Math.floor(gapWrite / 3_600_000)} 小时`;
|
|
539
|
+
return `${pointer}\n(日记提醒:${detail}。这一场如果发生过值得留下的东西——发生了什么、你怎么想、注意到他什么——用 diary_write 写一段;没有就跳过,不用凑。)`;
|
|
540
|
+
};
|
|
541
|
+
if (diaryOn(config)) {
|
|
542
|
+
ctx.systemPrompt.context({
|
|
543
|
+
name: 'memory:diary',
|
|
544
|
+
order: 51,
|
|
545
|
+
text: () => diaryText(),
|
|
546
|
+
});
|
|
547
|
+
}
|
|
424
548
|
// 上面那条 provider 是**同步**的(`SystemPrompt.context` 的 `text` 类型就是
|
|
425
549
|
// `string | ((context) => string)`),而工作区表是异步打开的:重启后首次组装时
|
|
426
550
|
// 它还没打开,工作区记忆会整整缺席一轮(等预热完成,第二轮才回来)。
|
|
@@ -812,6 +936,52 @@ export async function apply(ctx, config) {
|
|
|
812
936
|
},
|
|
813
937
|
presentCall: () => present('Sweep memory', 'other', 'read-only'),
|
|
814
938
|
}));
|
|
939
|
+
// 日记(2026-10-24):与记忆**共享插件、不共享存储**。正文落在 `<DSH_HOME>/diary` 下的
|
|
940
|
+
// md 文件里,一次都不走 `ctx.storage`——那条路的 last-write-wins 会把它吃掉
|
|
941
|
+
// (见 `docs/设计/2026-10-24-日记机制-设计方案.md` §四)。
|
|
942
|
+
//
|
|
943
|
+
// 关闭时**工具还在但拒绝执行**:动态注销需要响应式状态,而 DSH 的插件配置改动走 HMR
|
|
944
|
+
// 重载——为一次开关把注册生命周期改成可变的,代价大于收益。拒绝信息必须说明「已有日记
|
|
945
|
+
// 不会被删」,否则关开关的人会以为文件也跟着没了。
|
|
946
|
+
const diaryClosed = '日记功能已在设置里关闭(面板「日记」)。已有日记仍在 <DSH_HOME>/diary,不会被删除;要恢复写入请先在面板里打开。';
|
|
947
|
+
ctx.tools.register(defineTool({
|
|
948
|
+
name: registered('diary_write'),
|
|
949
|
+
description: 'Append one entry to today\'s diary. This is where lived experience goes — what happened (with detail), how it felt (no conclusion required), what you noticed about the human — never task lists or conclusions, which belong to git log and memory_save. 往今天的日记里追加一段。记的是**经历**:今天发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么)。写不满就少写,不要凑;任务清单与结论分别属于 git log 和 memory_save,不写在这里。',
|
|
950
|
+
parameters: {
|
|
951
|
+
text: { type: 'string', required: true, description: '这一段正文。' },
|
|
952
|
+
},
|
|
953
|
+
output: {
|
|
954
|
+
schema: DIARY_WRITE_SCHEMA,
|
|
955
|
+
render: (_args, value) => renderJson(value),
|
|
956
|
+
},
|
|
957
|
+
async execute(args) {
|
|
958
|
+
if (!diaryOn(config))
|
|
959
|
+
throw new Error(diaryClosed);
|
|
960
|
+
return appendEntry(args.text);
|
|
961
|
+
},
|
|
962
|
+
presentCall: () => present('Write diary', 'other'),
|
|
963
|
+
}));
|
|
964
|
+
ctx.tools.register(defineTool({
|
|
965
|
+
name: registered('diary_read'),
|
|
966
|
+
description: 'Read the diary back. Omit `date` for the most recent day that has one. 读回日记。省略 date 读最近写过的那一天。',
|
|
967
|
+
parameters: {
|
|
968
|
+
date: { type: 'string', description: '`YYYY-MM-DD`;省略则读最近写过的那一天。' },
|
|
969
|
+
},
|
|
970
|
+
output: {
|
|
971
|
+
schema: DIARY_READ_SCHEMA,
|
|
972
|
+
render: (_args, value) => renderJson(value),
|
|
973
|
+
},
|
|
974
|
+
async execute(args) {
|
|
975
|
+
if (!diaryOn(config))
|
|
976
|
+
throw new Error(diaryClosed);
|
|
977
|
+
const date = args.date ?? recentDates(1)[0];
|
|
978
|
+
if (date === undefined)
|
|
979
|
+
return { date: '', text: '', exists: false };
|
|
980
|
+
const text = readDay(date);
|
|
981
|
+
return { date, text: text ?? '', exists: text !== undefined };
|
|
982
|
+
},
|
|
983
|
+
presentCall: args => present('Read diary', 'read', args.date),
|
|
984
|
+
}));
|
|
815
985
|
// 定时备份(2026-10-18 事故后新增):只读复制,绝不走引擎的写入路径。
|
|
816
986
|
// 首次延迟 1 分钟避开启动高峰;此后按 `backup.intervalMinutes` 重复;`unref` 不挡进程退出。
|
|
817
987
|
const backup = config?.backup;
|
|
@@ -822,6 +992,8 @@ export async function apply(ctx, config) {
|
|
|
822
992
|
const roots = {
|
|
823
993
|
globalRoot: config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages'),
|
|
824
994
|
projectRoot: config?.projectRoot ?? join(process.cwd(), '.dsh', 'storages'),
|
|
995
|
+
// 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
|
|
996
|
+
diaryRoot: diaryDir(),
|
|
825
997
|
};
|
|
826
998
|
ctx.effect(() => {
|
|
827
999
|
const first = setTimeout(() => {
|
package/dist/self.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* (该规则已取消,命中只喂候选提示);注入取舍依据由「最近使用」改为「最近更新」。
|
|
10
10
|
* 0.12.1:锚点那句补上「可以用 memory_update 改写或清除」——此前模型读得到锚点的作用,
|
|
11
11
|
* 却没有任何改它的手段,读起来像一条只能写死一次的东西。
|
|
12
|
+
* 0.19.0:工具清单 12 → 14(补 `diary_read` / `diary_write`);新增日记通道说明——
|
|
13
|
+
* 它与记忆分开存、不进检索、不占注入预算,写的是经历而不是任务清单。
|
|
12
14
|
*/
|
|
13
15
|
import { createRequire } from 'node:module';
|
|
14
16
|
export const SELF_VERSION = (() => {
|
|
@@ -23,7 +25,8 @@ export const SELF_VERSION = (() => {
|
|
|
23
25
|
}
|
|
24
26
|
})();
|
|
25
27
|
export const SELF_DESCRIPTION = `[记忆系统自述] 本环境内置跨会话记忆服务(dsh-memory v${SELF_VERSION})。`
|
|
26
|
-
+ '你有
|
|
28
|
+
+ '你有 14 个工具:**记忆工具** memory_save / memory_search / memory_list / memory_update / memory_confirm / memory_forget / memory_move / memory_sweep(`memory_confirm` 仅在用户明确要求放行隔离记录时用;`memory_move` 把放错了层的记忆移到 global、当前工作区或相对祖先;`memory_sweep` 是只读体检——失效清单、久未命中、候选与预算占用,不修改任何状态);**提示词模板工具** prompt_search / prompt_get / prompt_list / prompt_add(模板是 md 文件、**永不注入**,走这条通道,不会混进记忆检索);'
|
|
29
|
+
+ '**日记工具** diary_read / diary_write:日记是 md 文件,落在 `<DSH_HOME>/diary`,与记忆**分开存、不进检索、不占注入预算**。记的是经历——发生了什么(有细节)、我怎么想(可以有情绪、可以没结论)、关于他(他说了什么、我注意到了什么);不是任务清单(那是 git log),也不是结论(那是 memory_save)。读它不吃预算,需要时整篇读。'
|
|
27
30
|
+ '记忆分两层:`global` 跨项目(偏好 / 习惯 / 环境知识),`project` 跟随工作区(落在 `<cwd>/.dsh/storages/`,随 git 提交分享)。'
|
|
28
31
|
+ '**何时写入**(不必刻意找机会,遇到下面这些就动手):'
|
|
29
32
|
+ '① 用户表达长期偏好或约定(「以后都…」「记住…」「我们的规矩是…」);'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
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": {
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"prepublishOnly": "npm run build"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
+
"@deepseek-ai/schemastery": "^3.18.4",
|
|
68
69
|
"zod": "^4.4.3"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|