@max-null/dsh-memory 0.19.1 → 0.21.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 +14 -2
- package/dist/backup.d.ts +8 -2
- package/dist/backup.js +18 -4
- package/dist/engine.d.ts +41 -0
- package/dist/engine.js +77 -9
- package/dist/index.js +42 -12
- package/dist/schema.d.ts +1 -0
- package/dist/schema.js +8 -0
- package/dist/sync-export.d.ts +60 -0
- package/dist/sync-export.js +141 -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 起补了三个方向——针对的是同一个盲区:**不知道存在**(检索是有意图的动作,搜不出自己不知道存在的东西)。
|
|
@@ -118,7 +130,7 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
118
130
|
|
|
119
131
|
| 项 | 默认值 |
|
|
120
132
|
|---|---|
|
|
121
|
-
| 备份内容 | `memory.json`(global)+ `memory_project_*.json
|
|
133
|
+
| 备份内容 | `memory.json`(global)+ 每个打开过的工作区各一份 `memory_project_*.json` + `<DSH_HOME>/diary/*.md` |
|
|
122
134
|
| 目标目录 | 记忆:`~/.dsh-backups/memory/<时间戳>/`(**在 `DSH_HOME` 之外**);日记:`~/.dsh-backups/memory/diary-archive/` |
|
|
123
135
|
| 间隔 | 60 分钟(首次延迟 1 分钟);**插件关闭时另补一次** |
|
|
124
136
|
| 保留 | 记忆:最近 14 份;日记:全部(只增不删) |
|
|
@@ -126,7 +138,7 @@ DSH 存储层的写入是「读—改—写全量覆盖」,且官方两个后
|
|
|
126
138
|
|
|
127
139
|
**边界**:
|
|
128
140
|
|
|
129
|
-
-
|
|
141
|
+
- 备份**本轮运行打开过的所有工作区**的 project 文件:来源是引擎按会话 cwd 记下的那份工作区账,每轮备份时现取。**本轮一次都没打开过的工作区不在范围内**——备份是定时任务,没有「当前工作区」可问,打开过的才留得下痕迹。多个工作区给出同名文件时加递增后缀,绝不覆盖。
|
|
130
142
|
- 只读文件、只复制——**绝不复用引擎的写入路径**。写路径会先 `reload()` 再合并,磁盘缺失时它会把内存覆盖掉,正是这次事故的成因;备份不能走同一条路。
|
|
131
143
|
- 日记按**逐字节内容**判断是否已备份过,不按 mtime:复制与编辑都会动 mtime,内容才是事实。
|
|
132
144
|
|
package/dist/backup.d.ts
CHANGED
|
@@ -31,8 +31,14 @@ export interface MemoryBackupConfig {
|
|
|
31
31
|
export interface MemoryBackupOptions {
|
|
32
32
|
/** global 记忆的根(`$DSH_HOME/storages`)。 */
|
|
33
33
|
globalRoot: string;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/**
|
|
35
|
+
* project 记忆的根,一个工作区一项(`<cwd>/.dsh/storages`)。
|
|
36
|
+
*
|
|
37
|
+
* 是数组而非单值:项目记忆**按工作区分散**,一次运行里通常会打开好几个
|
|
38
|
+
* (本机实测同时存在 WorkStation / deepseek-harness / seek-soul-in-darkness
|
|
39
|
+
* 三份各自独立)。只备份一个等于默认放弃其余工作区的历史。
|
|
40
|
+
*/
|
|
41
|
+
projectRoots: readonly string[];
|
|
36
42
|
/** 日记的根(`$DSH_HOME/diary`);缺省不备份日记。 */
|
|
37
43
|
diaryRoot?: string;
|
|
38
44
|
/** 备份根目录。 */
|
package/dist/backup.js
CHANGED
|
@@ -129,7 +129,7 @@ export function runMemoryBackup(options) {
|
|
|
129
129
|
? undefined
|
|
130
130
|
: archiveDiary(options.diaryRoot, options.targetRoot, log);
|
|
131
131
|
const globalFiles = candidateFiles(options.globalRoot, log).map(name => ({ name, from: join(options.globalRoot, name) }));
|
|
132
|
-
const projectFiles = candidateFiles(
|
|
132
|
+
const projectFiles = options.projectRoots.flatMap(root => candidateFiles(root, log).map(name => ({ name, from: join(root, name) })));
|
|
133
133
|
const sources = [...globalFiles, ...projectFiles];
|
|
134
134
|
if (sources.length === 0) {
|
|
135
135
|
log('memory backup: 没有可备份的记忆文件');
|
|
@@ -150,10 +150,22 @@ export function runMemoryBackup(options) {
|
|
|
150
150
|
return { dir: null, files: [], pruned: [], error: message };
|
|
151
151
|
}
|
|
152
152
|
const files = [];
|
|
153
|
+
// 多个工作区可能给出同名文件(backend 名由 cwd 哈希得出,不同工作区几乎不会撞,
|
|
154
|
+
// 但不该把「备份的完整性」押在哈希碰撞概率上)。撞了就加递增后缀,绝不覆盖——
|
|
155
|
+
// 一次备份里静默少一份,等于这次备份没有做过。
|
|
156
|
+
const used = new Set();
|
|
153
157
|
for (const source of sources) {
|
|
154
158
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
let name = source.name;
|
|
160
|
+
for (let suffix = 1; used.has(name); suffix += 1) {
|
|
161
|
+
name = source.name.replace(/\.json$/u, `-${String(suffix)}.json`);
|
|
162
|
+
}
|
|
163
|
+
copyFileSync(source.from, join(dir, name));
|
|
164
|
+
used.add(name);
|
|
165
|
+
files.push(name);
|
|
166
|
+
if (name !== source.name) {
|
|
167
|
+
log(`memory backup: ${source.from} 与本次快照里的同名文件冲突,落位为 ${name}`);
|
|
168
|
+
}
|
|
157
169
|
}
|
|
158
170
|
catch (error) {
|
|
159
171
|
log(`memory backup: 复制 ${source.name} 失败: ${reason(error)}`);
|
|
@@ -163,7 +175,9 @@ export function runMemoryBackup(options) {
|
|
|
163
175
|
writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify({
|
|
164
176
|
createdAt: now.toISOString(),
|
|
165
177
|
globalRoot: options.globalRoot,
|
|
166
|
-
|
|
178
|
+
// 记全部来源根:事后要判断「某个工作区的记忆到底有没有被备份到」,
|
|
179
|
+
// 只能靠这份清单——文件名里的哈希反推不出工作区路径。
|
|
180
|
+
projectRoots: [...options.projectRoots],
|
|
167
181
|
files,
|
|
168
182
|
// 日记不在本次快照目录里,manifest 只记「这一轮归档做了什么」,不列篇名。
|
|
169
183
|
...(diary === undefined ? {} : { diary: { root: options.diaryRoot, ...diary } }),
|
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
|
*
|
|
@@ -349,6 +360,14 @@ export declare class MemoryEngine extends Service {
|
|
|
349
360
|
private globalTable?;
|
|
350
361
|
/** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
|
|
351
362
|
private projectTables;
|
|
363
|
+
/**
|
|
364
|
+
* 各 storage backend 的清理函数(摘名 + 关闭)。
|
|
365
|
+
*
|
|
366
|
+
* 不挂在独立 effect 上:cordis 的 dispose 后进先出,运行时才注册的那些会比
|
|
367
|
+
* `memory.domainsClose` 早执行,导致 domain 还握着 unit 时 backend 先关。
|
|
368
|
+
* 统一由 {@link closeBackends} 在 domain 全部关闭之后按插入顺序执行。
|
|
369
|
+
*/
|
|
370
|
+
private readonly backendClosers;
|
|
352
371
|
/** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
|
|
353
372
|
private readonly neighborCache;
|
|
354
373
|
/** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
|
|
@@ -383,6 +402,15 @@ export declare class MemoryEngine extends Service {
|
|
|
383
402
|
get lastSweepReport(): SweepReport | undefined;
|
|
384
403
|
constructor(ctx: import('@deepseek-ai/cordis').Context, config?: MemoryConfig);
|
|
385
404
|
protected [Service.init](): Promise<void>;
|
|
405
|
+
/**
|
|
406
|
+
* 注册一个 storage backend 并返回它的清理函数(摘名 + 关闭)。
|
|
407
|
+
*
|
|
408
|
+
* 清理函数**不立刻挂到 effect 上**,而是交给 {@link closeBackends} 在 domain 全部关闭
|
|
409
|
+
* 之后统一执行——理由见 `[Service.init]` 的注释。
|
|
410
|
+
*/
|
|
411
|
+
private registerBackend;
|
|
412
|
+
/** 依次摘名并关闭所有已注册的 backend;必须由外部保证 domain 已先关闭。 */
|
|
413
|
+
private closeBackends;
|
|
386
414
|
/** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
|
|
387
415
|
private openGlobalFacility;
|
|
388
416
|
/** 工作区 project 存储根(<workspace>/.dsh/storages,随 git 分享)。 */
|
|
@@ -394,6 +422,19 @@ export declare class MemoryEngine extends Service {
|
|
|
394
422
|
* 表已打开是常态,那时这条路径不该付出任何异步代价。
|
|
395
423
|
*/
|
|
396
424
|
projectOpen(projectCwd?: string): boolean;
|
|
425
|
+
/**
|
|
426
|
+
* 已经打开过的工作区 project 存储根(`<cwd>/.dsh/storages`),供备份遍历。
|
|
427
|
+
*
|
|
428
|
+
* 备份是定时任务、没有会话 cwd,所以「现在在哪个工作区」这个问题它答不出来。
|
|
429
|
+
* 此前它拿 `process.cwd()` 兜底,而那是**进程启动目录**(桌面壳里通常是 profile
|
|
430
|
+
* 目录),与真实工作区无关——2026-11-05 实测:292 条项目记忆从未进过快照,备份
|
|
431
|
+
* 一直对着一个空目录反复报成功。
|
|
432
|
+
*
|
|
433
|
+
* 打开过的表由会话 cwd 驱动,见过的工作区都留在 {@link projectTables} 里;这是
|
|
434
|
+
* 备份唯一可靠的工作区来源。返回空数组表示「这次运行还没打开过任何工作区」,
|
|
435
|
+
* 由调用方决定回退策略。
|
|
436
|
+
*/
|
|
437
|
+
projectStorageRoots(): string[];
|
|
397
438
|
/**
|
|
398
439
|
* 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
|
|
399
440
|
* 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
|
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
|
}
|
|
@@ -173,6 +174,14 @@ export class MemoryEngine extends Service {
|
|
|
173
174
|
globalTable;
|
|
174
175
|
/** project 域按工作区 cwd 懒打开 + 缓存(多会话并发各工作区独立)。 */
|
|
175
176
|
projectTables = new Map();
|
|
177
|
+
/**
|
|
178
|
+
* 各 storage backend 的清理函数(摘名 + 关闭)。
|
|
179
|
+
*
|
|
180
|
+
* 不挂在独立 effect 上:cordis 的 dispose 后进先出,运行时才注册的那些会比
|
|
181
|
+
* `memory.domainsClose` 早执行,导致 domain 还握着 unit 时 backend 先关。
|
|
182
|
+
* 统一由 {@link closeBackends} 在 domain 全部关闭之后按插入顺序执行。
|
|
183
|
+
*/
|
|
184
|
+
backendClosers = [];
|
|
176
185
|
/** 子项目发现缓存(④-B 2026-09-19):key = 工作区路径;值 = 其直接子目录中带记忆的(按条数降序)。 */
|
|
177
186
|
neighborCache = new Map();
|
|
178
187
|
/** 进行中的子项目发现(key → promise):预热是 fire-and-forget,之后的显式调用与它合流而非重复扫描。 */
|
|
@@ -212,17 +221,55 @@ export class MemoryEngine extends Service {
|
|
|
212
221
|
this.config = config;
|
|
213
222
|
}
|
|
214
223
|
async [Service.init]() {
|
|
215
|
-
// backend
|
|
216
|
-
//
|
|
224
|
+
// backend 注册是**一个 effect**:`dsh-storage` 的 `BackendRegistry.register()` 契约是
|
|
225
|
+
// 「registration is an effect: the returned disposer removes the name」。丢掉那个 disposer
|
|
226
|
+
// 会让名字活过 fiber——issue #3 的真实成因:插件市场的更新流程会在同一进程里重新组合
|
|
227
|
+
// profile 并做一次构建校验,本 entry 第二次激活时 `register('memory-global', …)` 撞上
|
|
228
|
+
// 自己上次留下的名字,报 `duplicate-backend`;而那句报错被上层包装成「更新前的构建
|
|
229
|
+
// 未能验证恢复,请检查该 profile」,把用户引向「配置坏了」这个错误方向。
|
|
230
|
+
//
|
|
231
|
+
// **摘名与关闭不能各挂一个 effect**:cordis 的 dispose 后进先出,而 project backend 的
|
|
232
|
+
// effect 是运行时(`openProjectTable`)才注册的,会比这里晚注册、早执行——于是 domain
|
|
233
|
+
// 还握着 unit 时 backend 先被关掉,留下没释放的 open-slot,下一个实例打开同一目录
|
|
234
|
+
// 就失败(表现为「表打开了但一条都读不到」)。所以统一收进一个 closers 列表,
|
|
235
|
+
// 由本 effect 在**所有 domain 关闭之后**依次执行。
|
|
236
|
+
//
|
|
237
|
+
// reload 不重新注册 backend(见 `reload()`:它只重开 domain),所以这样改不改变
|
|
238
|
+
// reload 的语义——这些 closers 只在 dispose 时触发。
|
|
217
239
|
const globalBackend = new JsonStorageBackend(this.config.globalRoot ?? globalRoot());
|
|
218
|
-
this.
|
|
240
|
+
this.backendClosers.push(this.registerBackend('memory-global', globalBackend));
|
|
219
241
|
await this.openGlobalFacility();
|
|
220
|
-
this.ctx.effect(() => () => {
|
|
221
|
-
|
|
242
|
+
this.ctx.effect(() => async () => {
|
|
243
|
+
await this.facility?.closeAll();
|
|
222
244
|
for (const facility of this.projectFacilities.values())
|
|
223
|
-
|
|
245
|
+
await facility.closeAll();
|
|
246
|
+
await this.closeBackends();
|
|
224
247
|
}, 'memory.domainsClose');
|
|
225
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* 注册一个 storage backend 并返回它的清理函数(摘名 + 关闭)。
|
|
251
|
+
*
|
|
252
|
+
* 清理函数**不立刻挂到 effect 上**,而是交给 {@link closeBackends} 在 domain 全部关闭
|
|
253
|
+
* 之后统一执行——理由见 `[Service.init]` 的注释。
|
|
254
|
+
*/
|
|
255
|
+
registerBackend(name, backend) {
|
|
256
|
+
const unregister = this.ctx.storage.backend.register(name, backend);
|
|
257
|
+
return async () => {
|
|
258
|
+
unregister();
|
|
259
|
+
await backend.close();
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/** 依次摘名并关闭所有已注册的 backend;必须由外部保证 domain 已先关闭。 */
|
|
263
|
+
async closeBackends() {
|
|
264
|
+
for (const close of this.backendClosers.splice(0)) {
|
|
265
|
+
try {
|
|
266
|
+
await close();
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// 清理失败不该让 dispose 整体失败——名字可能已被别处摘掉。
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
226
273
|
/** 打开全局存储域(init 与 reload 共用;backend 复用已注册实例)。 */
|
|
227
274
|
async openGlobalFacility() {
|
|
228
275
|
const facility = new DomainFacility(this.ctx, { backend: 'memory-global', routes: {} });
|
|
@@ -251,6 +298,21 @@ export class MemoryEngine extends Service {
|
|
|
251
298
|
return false;
|
|
252
299
|
return this.projectTables.has(join(projectCwd));
|
|
253
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* 已经打开过的工作区 project 存储根(`<cwd>/.dsh/storages`),供备份遍历。
|
|
303
|
+
*
|
|
304
|
+
* 备份是定时任务、没有会话 cwd,所以「现在在哪个工作区」这个问题它答不出来。
|
|
305
|
+
* 此前它拿 `process.cwd()` 兜底,而那是**进程启动目录**(桌面壳里通常是 profile
|
|
306
|
+
* 目录),与真实工作区无关——2026-11-05 实测:292 条项目记忆从未进过快照,备份
|
|
307
|
+
* 一直对着一个空目录反复报成功。
|
|
308
|
+
*
|
|
309
|
+
* 打开过的表由会话 cwd 驱动,见过的工作区都留在 {@link projectTables} 里;这是
|
|
310
|
+
* 备份唯一可靠的工作区来源。返回空数组表示「这次运行还没打开过任何工作区」,
|
|
311
|
+
* 由调用方决定回退策略。
|
|
312
|
+
*/
|
|
313
|
+
projectStorageRoots() {
|
|
314
|
+
return [...this.projectTables.keys()].map(cwd => this.projectRootFor(cwd));
|
|
315
|
+
}
|
|
254
316
|
/**
|
|
255
317
|
* 按工作区 cwd 取 project 表(懒打开 + 缓存)。无 cwd(未选工作区)
|
|
256
318
|
* 返回 undefined——调用方(工具/面板)按此跳过 project 部分。
|
|
@@ -322,7 +384,10 @@ export class MemoryEngine extends Service {
|
|
|
322
384
|
}
|
|
323
385
|
if (!this.registeredProjectBackends.has(backendName)) {
|
|
324
386
|
const backend = new JsonStorageBackend(root);
|
|
325
|
-
|
|
387
|
+
// 与 `memory-global` 同样走 closers 列表:`registeredProjectBackends` 只能防**同一
|
|
388
|
+
// 实例**内的重复注册,换一个实例这个 Set 就是空的,挡不住上一个 fiber 残留的名字
|
|
389
|
+
// (issue #3 里这一点被 `memory-global` 先撞上,所以看不出来)。
|
|
390
|
+
this.backendClosers.push(this.registerBackend(backendName, backend));
|
|
326
391
|
this.registeredProjectBackends.add(backendName);
|
|
327
392
|
}
|
|
328
393
|
const facility = new DomainFacility(this.ctx, { backend: backendName, routes: {} });
|
|
@@ -758,6 +823,8 @@ export class MemoryEngine extends Service {
|
|
|
758
823
|
...input.injected === undefined ? {} : { injectedAuto: false },
|
|
759
824
|
// 有效性锚点(可选):值变了这条记忆会被标 stale,不再被当成仍然正确
|
|
760
825
|
...input.anchor === undefined ? {} : { anchor: input.anchor },
|
|
826
|
+
// 主题(0.20.0):省略即未分类,与 `kind`(fact/prompt)正交
|
|
827
|
+
...input.topic === undefined || input.topic.trim() === '' ? {} : { topic: input.topic.trim() },
|
|
761
828
|
...verdict.quarantined
|
|
762
829
|
? { quarantined: true, ...verdict.reason === undefined ? {} : { quarantineReason: verdict.reason } }
|
|
763
830
|
: {},
|
|
@@ -778,7 +845,8 @@ export class MemoryEngine extends Service {
|
|
|
778
845
|
// 0.12.0:按更新时间筛——与注入排序、注入行的日期标记同一个依据,不制造第二个「新」的定义
|
|
779
846
|
&& (filter?.after === undefined || record.updatedAt >= filter.after)
|
|
780
847
|
&& (filter?.before === undefined || record.updatedAt <= filter.before)
|
|
781
|
-
&& (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind)
|
|
848
|
+
&& (filter?.kind === undefined || (record.kind ?? 'fact') === filter.kind)
|
|
849
|
+
&& (filter?.topic === undefined || record.topic === filter.topic));
|
|
782
850
|
}
|
|
783
851
|
/**
|
|
784
852
|
* 关键词检索。每次成功返回的检索都记一条查询日志(⑥ 2026-09-15)——命中与否都记:
|
package/dist/index.js
CHANGED
|
@@ -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),
|
|
@@ -989,25 +997,47 @@ export async function apply(ctx, config) {
|
|
|
989
997
|
const targetRoot = backup?.targetRoot ?? join(homedir(), '.dsh-backups', 'memory');
|
|
990
998
|
const intervalMs = Math.max(1, backup?.intervalMinutes ?? 60) * 60_000;
|
|
991
999
|
const keep = Math.max(1, backup?.keep ?? 14);
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1000
|
+
const globalRoot = config?.globalRoot ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'storages');
|
|
1001
|
+
/**
|
|
1002
|
+
* 本次要备份的 project 根。
|
|
1003
|
+
*
|
|
1004
|
+
* **每次备份时现算**,不能像 `globalRoot` 那样在启动时定死:工作区是随会话逐步
|
|
1005
|
+
* 打开的,启动那一刻引擎里一个都没有,那时算出来永远是空的。
|
|
1006
|
+
*
|
|
1007
|
+
* 三档来源:引擎打开过的工作区(由会话 cwd 驱动,最可靠)、显式配置的
|
|
1008
|
+
* `projectRoot`、两者都空时回退 `process.cwd()`。中间那一档要注意:`MemoryConfig`
|
|
1009
|
+
* 把它注释成「project 记忆的根」,但**引擎实现从不读它**——它只在这里影响备份。
|
|
1010
|
+
* 最后一档只是兜底——它是进程启动目录而非工作区,桌面壳里通常指向 profile
|
|
1011
|
+
* 目录,那正是「项目记忆零快照」的成因(2026-11-05 实测:738 KB / 292 条从未
|
|
1012
|
+
* 进过快照,备份一直对着空目录报成功)。
|
|
1013
|
+
*/
|
|
1014
|
+
const projectRoots = () => {
|
|
1015
|
+
const found = new Set(memory.projectStorageRoots());
|
|
1016
|
+
if (config?.projectRoot !== undefined)
|
|
1017
|
+
found.add(config.projectRoot);
|
|
1018
|
+
if (found.size === 0)
|
|
1019
|
+
found.add(join(process.cwd(), '.dsh', 'storages'));
|
|
1020
|
+
return [...found];
|
|
1021
|
+
};
|
|
1022
|
+
const backupOnce = () => {
|
|
1023
|
+
runMemoryBackup({
|
|
1024
|
+
globalRoot,
|
|
1025
|
+
projectRoots: projectRoots(),
|
|
1026
|
+
// 日记在 `storages` 之外,得单独给根——漏了这一项就等于「备份记忆、不备份日记」。
|
|
1027
|
+
diaryRoot: diaryDir(),
|
|
1028
|
+
targetRoot,
|
|
1029
|
+
keep,
|
|
1030
|
+
});
|
|
997
1031
|
};
|
|
998
1032
|
ctx.effect(() => {
|
|
999
|
-
const first = setTimeout(
|
|
1000
|
-
|
|
1001
|
-
}, 60_000);
|
|
1002
|
-
const timer = setInterval(() => {
|
|
1003
|
-
runMemoryBackup({ ...roots, targetRoot, keep });
|
|
1004
|
-
}, intervalMs);
|
|
1033
|
+
const first = setTimeout(backupOnce, 60_000);
|
|
1034
|
+
const timer = setInterval(backupOnce, intervalMs);
|
|
1005
1035
|
timer.unref();
|
|
1006
1036
|
return () => {
|
|
1007
1037
|
clearTimeout(first);
|
|
1008
1038
|
clearInterval(timer);
|
|
1009
1039
|
// 关闭时补一次:定时器只在运行期间生效,关 DSH 前最后一段变更靠这里兜住。
|
|
1010
|
-
|
|
1040
|
+
backupOnce();
|
|
1011
1041
|
};
|
|
1012
1042
|
});
|
|
1013
1043
|
}
|
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(),
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
|
|
5
|
+
* 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
|
|
6
|
+
* 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
|
|
7
|
+
* `Merge made by the 'ort' strategy`,0 冲突。
|
|
8
|
+
*
|
|
9
|
+
* **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
|
|
10
|
+
* cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
|
|
11
|
+
* 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
|
|
12
|
+
*
|
|
13
|
+
* **同步面不带什么**(见设计文档 §5):
|
|
14
|
+
* - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
|
|
15
|
+
* 毫无变化时持续变脏,git 历史被噪声淹没。
|
|
16
|
+
* - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
|
|
17
|
+
* - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
|
|
18
|
+
* 推到远端仓库等于把那层保护绕开。
|
|
19
|
+
*
|
|
20
|
+
* @see docs/设计/2026-11-06-记忆云同步-设计方案.md
|
|
21
|
+
*/
|
|
22
|
+
/** 一条记录在镜像里的位置与内容。 */
|
|
23
|
+
export interface SyncRecordFile {
|
|
24
|
+
/** 相对导出根的路径,如 `records/global/<uuid>.json`。 */
|
|
25
|
+
readonly path: string;
|
|
26
|
+
/** 文件内容(JSON + 末尾换行;已剔除同步面之外的字段)。 */
|
|
27
|
+
readonly content: string;
|
|
28
|
+
}
|
|
29
|
+
/** 导出的输入:各层记忆库的**解析后**文档。 */
|
|
30
|
+
export interface SyncExportOptions {
|
|
31
|
+
/** `memory.json` 解析后的对象(global 层)。 */
|
|
32
|
+
readonly globalDocument: unknown;
|
|
33
|
+
/** 各工作区的记忆库文档;`cwd` 用于算目录名,须与引擎写入时一致。 */
|
|
34
|
+
readonly projectDocuments?: ReadonlyArray<{
|
|
35
|
+
readonly cwd: string;
|
|
36
|
+
readonly document: unknown;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
/** 一次导出的结果。 */
|
|
40
|
+
export interface SyncExportRun {
|
|
41
|
+
/** 待写入的记录文件(顺序稳定:global 在前,工作区按 cwd 排序)。 */
|
|
42
|
+
readonly files: readonly SyncRecordFile[];
|
|
43
|
+
/** 因处于隔离区而未导出的条数。 */
|
|
44
|
+
readonly skippedQuarantined: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
|
|
48
|
+
* `projectBackendName`:cwd 小写后过 djb2,取 base36)。
|
|
49
|
+
*
|
|
50
|
+
* 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
|
|
51
|
+
* 看起来像「这个项目的记忆凭空少了一批」。
|
|
52
|
+
*/
|
|
53
|
+
export declare function workspaceSlug(cwd: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
|
|
56
|
+
*
|
|
57
|
+
* @param options - 各层文档。
|
|
58
|
+
* @returns 待写入的文件与隔离跳过数。
|
|
59
|
+
*/
|
|
60
|
+
export declare function exportRecords(options: SyncExportOptions): SyncExportRun;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导出层:把记忆库展开成「一条记忆一个文件」的镜像。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要它**:整份 `memory.json` 放进 git 会撞。2026-11-06 实测:两端各新增一条
|
|
5
|
+
* 记忆后 merge,`git` 报 `CONFLICT (content)`——两条记录挤在同一个插入点,按「取一边」
|
|
6
|
+
* 解决会静默丢掉另一端那条,而 git 仍报「合并成功」。改成一条一个文件后同一场景是
|
|
7
|
+
* `Merge made by the 'ort' strategy`,0 冲突。
|
|
8
|
+
*
|
|
9
|
+
* **为什么单独成模块**(与 `schema.ts` 同理由):本模块只依赖 `node:path`,不 import
|
|
10
|
+
* cordis / dsh-storage / engine。这样单元测试与离线 CLI 能直接加载它,不必拉起整套
|
|
11
|
+
* 宿主依赖——否则「用插件自己的能力导出一份镜像」这条在用户环境里跑不起来。
|
|
12
|
+
*
|
|
13
|
+
* **同步面不带什么**(见设计文档 §5):
|
|
14
|
+
* - `hitCount` / `lastUsedAt`:本地行为统计,每次检索命中都会改。带上它们会让镜像在内容
|
|
15
|
+
* 毫无变化时持续变脏,git 历史被噪声淹没。
|
|
16
|
+
* - `vector`:本地嵌入产物,体积大且可从 `content` 重算。
|
|
17
|
+
* - `quarantined` 的记录**整条不导出**:隔离区的存在理由就是它可能含凭据类内容,
|
|
18
|
+
* 推到远端仓库等于把那层保护绕开。
|
|
19
|
+
*
|
|
20
|
+
* @see docs/设计/2026-11-06-记忆云同步-设计方案.md
|
|
21
|
+
*/
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
/**
|
|
24
|
+
* 进入同步面的字段白名单。
|
|
25
|
+
*
|
|
26
|
+
* 用白名单而不是黑名单:`StoredBlock` 以后还会长出新字段(`topic` 就是 0.20.0 才加的),
|
|
27
|
+
* 黑名单会让新字段**默认泄漏**进来——而「一个只该存在本地的字段漏进远端仓库」这类事故,
|
|
28
|
+
* 发现的时候内容已经在别人能拉到的地方了。
|
|
29
|
+
*/
|
|
30
|
+
const SYNCED_FIELDS = [
|
|
31
|
+
'namespace',
|
|
32
|
+
'status',
|
|
33
|
+
'content',
|
|
34
|
+
'keywords',
|
|
35
|
+
'createdAt',
|
|
36
|
+
'updatedAt',
|
|
37
|
+
'injected',
|
|
38
|
+
'injectedAuto',
|
|
39
|
+
'topic',
|
|
40
|
+
'kind',
|
|
41
|
+
'anchor',
|
|
42
|
+
'stale',
|
|
43
|
+
'staleReason',
|
|
44
|
+
'source',
|
|
45
|
+
'meta',
|
|
46
|
+
];
|
|
47
|
+
/** 记录文件的目录名(与清单文件同级)。 */
|
|
48
|
+
const RECORDS_DIR = 'records';
|
|
49
|
+
/** 把 `unknown` 收窄成对象;不是对象返回 `undefined`。 */
|
|
50
|
+
function asObject(value) {
|
|
51
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
52
|
+
? value
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 取出一份 unit 文档里的 blocks 表。
|
|
57
|
+
*
|
|
58
|
+
* 文档形状是 DSH storage 的 unit 格式:`{ unit, global, tables: { blocks: {...} } }`。
|
|
59
|
+
* 形状不符时返回空表而不是抛错——镜像导出不该因为一个损坏的库把整个同步卡死,
|
|
60
|
+
* 条数为 0 会被上层当作「没有可导出的东西」处理。
|
|
61
|
+
*/
|
|
62
|
+
function blocksOf(document) {
|
|
63
|
+
const doc = asObject(document);
|
|
64
|
+
const tables = asObject(doc?.tables);
|
|
65
|
+
const blocks = asObject(tables?.blocks);
|
|
66
|
+
if (blocks === undefined)
|
|
67
|
+
return {};
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [id, block] of Object.entries(blocks)) {
|
|
70
|
+
const value = asObject(block);
|
|
71
|
+
if (value !== undefined)
|
|
72
|
+
out[id] = value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 工作区目录名——必须与引擎写记忆文件名时用的口径一致(`engine.ts` 的
|
|
78
|
+
* `projectBackendName`:cwd 小写后过 djb2,取 base36)。
|
|
79
|
+
*
|
|
80
|
+
* 两边算法分叉的后果很隐蔽:镜像会落在另一个目录名下,另一端拉下来后
|
|
81
|
+
* 看起来像「这个项目的记忆凭空少了一批」。
|
|
82
|
+
*/
|
|
83
|
+
export function workspaceSlug(cwd) {
|
|
84
|
+
let hash = 5381;
|
|
85
|
+
const text = cwd.toLowerCase();
|
|
86
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
87
|
+
hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0;
|
|
88
|
+
}
|
|
89
|
+
return Math.abs(hash).toString(36);
|
|
90
|
+
}
|
|
91
|
+
/** 只保留白名单字段;值为 `undefined` 的键不落进文件(避免 `"x": null` 噪声)。 */
|
|
92
|
+
function pickSyncedFields(block) {
|
|
93
|
+
const out = {};
|
|
94
|
+
for (const field of SYNCED_FIELDS) {
|
|
95
|
+
const value = block[field];
|
|
96
|
+
if (value !== undefined)
|
|
97
|
+
out[field] = value;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** 一条记录 → 一个文件;返回 `undefined` 表示这条不该进镜像(隔离区)。 */
|
|
102
|
+
function recordFile(id, block, segments) {
|
|
103
|
+
if (block.quarantined === true)
|
|
104
|
+
return undefined;
|
|
105
|
+
const payload = pickSyncedFields(block);
|
|
106
|
+
return {
|
|
107
|
+
path: join(...segments, `${id}.json`),
|
|
108
|
+
content: `${JSON.stringify(payload, null, 2)}\n`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 把记忆库展开成镜像文件列表。**纯函数**:不读写磁盘、不看环境变量。
|
|
113
|
+
*
|
|
114
|
+
* @param options - 各层文档。
|
|
115
|
+
* @returns 待写入的文件与隔离跳过数。
|
|
116
|
+
*/
|
|
117
|
+
export function exportRecords(options) {
|
|
118
|
+
const files = [];
|
|
119
|
+
let skippedQuarantined = 0;
|
|
120
|
+
for (const [id, block] of Object.entries(blocksOf(options.globalDocument))) {
|
|
121
|
+
const file = recordFile(id, block, [RECORDS_DIR, 'global']);
|
|
122
|
+
if (file === undefined) {
|
|
123
|
+
skippedQuarantined += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
files.push(file);
|
|
127
|
+
}
|
|
128
|
+
// 工作区按 cwd 排序:让两次导出的文件顺序稳定,git 不必为顺序抖动产生 diff。
|
|
129
|
+
const projects = [...(options.projectDocuments ?? [])].sort((left, right) => left.cwd.localeCompare(right.cwd));
|
|
130
|
+
for (const { cwd, document } of projects) {
|
|
131
|
+
for (const [id, block] of Object.entries(blocksOf(document))) {
|
|
132
|
+
const file = recordFile(id, block, [RECORDS_DIR, 'project', workspaceSlug(cwd)]);
|
|
133
|
+
if (file === undefined) {
|
|
134
|
+
skippedQuarantined += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
files.push(file);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { files, skippedQuarantined };
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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": {
|