@max-null/dsh-memory 0.20.0 → 0.22.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 +50 -4
- package/dist/backup.d.ts +8 -2
- package/dist/backup.js +18 -4
- package/dist/engine.d.ts +98 -0
- package/dist/engine.js +109 -7
- package/dist/index.d.ts +12 -0
- package/dist/index.js +128 -14
- package/dist/self.js +2 -1
- package/dist/sync-export.d.ts +65 -0
- package/dist/sync-export.js +150 -0
- package/dist/sync-git.d.ts +57 -0
- package/dist/sync-git.js +88 -0
- package/dist/sync-import.d.ts +83 -0
- package/dist/sync-import.js +82 -0
- package/dist/sync-remote.d.ts +81 -0
- package/dist/sync-remote.js +157 -0
- package/dist/sync-service.d.ts +108 -0
- package/dist/sync-service.js +347 -0
- package/dist/sync-tombstone.d.ts +67 -0
- package/dist/sync-tombstone.js +83 -0
- package/dist/sync-writer.d.ts +46 -0
- package/dist/sync-writer.js +85 -0
- package/package.json +2 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步墓碑:把 `memory_forget` 的真删表达成「记录被修改」,而不是「文件消失」。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要**(设计 §3.1):多写者下,本地看着「git 里有、我这儿没有」有两种可能——
|
|
5
|
+
* 本机删了(该删远端),或另一端刚加、我还没拉到(该拉下来)。两种情况的正确动作**完全
|
|
6
|
+
* 相反**,靠猜一定出错。
|
|
7
|
+
*
|
|
8
|
+
* 区分它们的唯一依据是**上一次同步时本机有哪些 id**:曾同步过、现在没了 ⇒ 本机删的。
|
|
9
|
+
* 所以本模块配一个随仓库走的 `.sync-state.json`。
|
|
10
|
+
*
|
|
11
|
+
* **墓碑不删文件、只改文件**,还顺带避开 git 里最难处理的 modify/delete 冲突:删除与
|
|
12
|
+
* 「另一端改了同一条」于是变成同一类冲突。
|
|
13
|
+
*
|
|
14
|
+
* **不记删前内容**:内容在 git 历史里(文件被删除但仍在历史),墓碑只负责回答
|
|
15
|
+
* 「这条是被删的还是还没同步到」。省下的体积换的是快照体积可控。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-memory/sync-tombstone
|
|
18
|
+
*/
|
|
19
|
+
/** 状态文件在同步仓库里的位置。 */
|
|
20
|
+
export const SYNC_STATE_FILE = '.sync-state.json';
|
|
21
|
+
/** 状态文件的相对路径(含墓碑目录)。 */
|
|
22
|
+
export const TOMBSTONE_DIR = 'tombstones';
|
|
23
|
+
/**
|
|
24
|
+
* 对比上一次与当前的 id 集合,算出本次该写哪些墓碑。
|
|
25
|
+
*
|
|
26
|
+
* **首次同步(`previous` 为空)永远不产生墓碑**:那时没有「曾经有过」这个信息,
|
|
27
|
+
* 把所有"远端有、本地没有"都判成删除会把另一端刚记的东西全删掉。这是整个模块最容易
|
|
28
|
+
* 写错的一处。
|
|
29
|
+
*
|
|
30
|
+
* @param options - 上次状态、当前状态与删除时刻。
|
|
31
|
+
* @returns 待写墓碑列表。
|
|
32
|
+
*/
|
|
33
|
+
export function planTombstones(options) {
|
|
34
|
+
const { previous, current, at } = options;
|
|
35
|
+
if (previous === undefined)
|
|
36
|
+
return [];
|
|
37
|
+
const tombstones = [];
|
|
38
|
+
const currentGlobal = new Set(current.global);
|
|
39
|
+
for (const id of previous.global) {
|
|
40
|
+
if (!currentGlobal.has(id))
|
|
41
|
+
tombstones.push({ id, scope: 'global', deletedAt: at });
|
|
42
|
+
}
|
|
43
|
+
for (const [projectName, ids] of Object.entries(previous.project)) {
|
|
44
|
+
const still = new Set(current.project[projectName] ?? []);
|
|
45
|
+
for (const id of ids) {
|
|
46
|
+
if (!still.has(id))
|
|
47
|
+
tombstones.push({ id, scope: 'project', projectName, deletedAt: at });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tombstones;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 把墓碑合并进上一次的状态:写过的墓碑不该在下一轮又被判成"删除"。
|
|
54
|
+
*
|
|
55
|
+
* 具体做法是把墓碑 id 从 `previous` 里剔掉——它们的删除已经被记录过了。
|
|
56
|
+
*
|
|
57
|
+
* @param state - 刚刚用于比对的那份状态。
|
|
58
|
+
* @param written - 本次写下的墓碑。
|
|
59
|
+
* @returns 更新后的状态。
|
|
60
|
+
*/
|
|
61
|
+
export function dropTombstoned(state, written) {
|
|
62
|
+
if (written.length === 0)
|
|
63
|
+
return state;
|
|
64
|
+
const globalGone = new Set(written.filter(t => t.scope === 'global').map(t => t.id));
|
|
65
|
+
const projectGone = new Map();
|
|
66
|
+
for (const tombstone of written) {
|
|
67
|
+
if (tombstone.scope !== 'project' || tombstone.projectName === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
const key = tombstone.projectName;
|
|
70
|
+
const set = projectGone.get(key) ?? new Set();
|
|
71
|
+
set.add(tombstone.id);
|
|
72
|
+
projectGone.set(key, set);
|
|
73
|
+
}
|
|
74
|
+
const project = {};
|
|
75
|
+
for (const [name, ids] of Object.entries(state.project)) {
|
|
76
|
+
const gone = projectGone.get(name);
|
|
77
|
+
project[name] = gone === undefined ? ids : ids.filter(id => !gone.has(id));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
global: state.global.filter(id => !globalGone.has(id)),
|
|
81
|
+
project,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步写盘层:把 {@link exportRecords} 的输出落到磁盘上的镜像目录。
|
|
3
|
+
*
|
|
4
|
+
* **两条硬要求**(见设计文档 §9.2 第 1 步):
|
|
5
|
+
*
|
|
6
|
+
* 1. **增量**:内容相同的文件不重写。不做这件事的后果不是"慢",是每次写盘都产生
|
|
7
|
+
* 一批 mtime 变动——git 看起来像全量改动,`git log` 与 `git diff` 随即失去意义。
|
|
8
|
+
* 2. **删除先算差集**:现有文件里不在待写列表的才算该删。不能边扫边删——边扫边删
|
|
9
|
+
* 在目录枚举顺序变化时会漏删或错删。
|
|
10
|
+
*
|
|
11
|
+
* **只管 `records/`**:`conflicts/`(§3.3 的留证)与 `index.json`(§3.2 的派生品)
|
|
12
|
+
* 都不归这里管。写盘层负责的是"把记忆库投影成一条一文件",不是"维护整个同步仓库"。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-memory/sync-writer
|
|
15
|
+
*/
|
|
16
|
+
import type { SyncRecordFile } from './sync-export.ts';
|
|
17
|
+
/** {@link writeMirror} 的入参。 */
|
|
18
|
+
export interface MirrorWriteOptions {
|
|
19
|
+
/** 镜像仓库的根(`records/` 就在它下面)。 */
|
|
20
|
+
readonly targetDir: string;
|
|
21
|
+
/** 待写入的记录文件;路径相对 `targetDir`。 */
|
|
22
|
+
readonly files: readonly SyncRecordFile[];
|
|
23
|
+
}
|
|
24
|
+
/** 一次写盘的结果。 */
|
|
25
|
+
export interface MirrorWriteRun {
|
|
26
|
+
/** 真正写了的文件数(内容有变化的)。 */
|
|
27
|
+
readonly written: number;
|
|
28
|
+
/** 内容与磁盘一致的、跳过的文件数。 */
|
|
29
|
+
readonly unchanged: number;
|
|
30
|
+
/** 被删掉的文件数。 */
|
|
31
|
+
readonly removed: number;
|
|
32
|
+
/** 被删掉的相对路径(便于日志与测试断言)。 */
|
|
33
|
+
readonly removedPaths: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 把记录文件镜像到磁盘。**纯 IO,不读写任何记忆状态**。
|
|
37
|
+
*
|
|
38
|
+
* 三个步骤按固定顺序:先算差集(该删哪些)、再写(内容有变的)、最后删。
|
|
39
|
+
* 顺序不能调换——先删会让"写失败 + 删成功"变成净损失。
|
|
40
|
+
*
|
|
41
|
+
* 单文件失败不会中断整批:记忆镜像少一份总比整次同步失败好,缺的那份下次写盘会补上。
|
|
42
|
+
*
|
|
43
|
+
* @param options - 目标目录与待写文件。
|
|
44
|
+
* @returns 写入 / 跳过 / 删除的统计。
|
|
45
|
+
*/
|
|
46
|
+
export declare function writeMirror(options: MirrorWriteOptions): MirrorWriteRun;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步写盘层:把 {@link exportRecords} 的输出落到磁盘上的镜像目录。
|
|
3
|
+
*
|
|
4
|
+
* **两条硬要求**(见设计文档 §9.2 第 1 步):
|
|
5
|
+
*
|
|
6
|
+
* 1. **增量**:内容相同的文件不重写。不做这件事的后果不是"慢",是每次写盘都产生
|
|
7
|
+
* 一批 mtime 变动——git 看起来像全量改动,`git log` 与 `git diff` 随即失去意义。
|
|
8
|
+
* 2. **删除先算差集**:现有文件里不在待写列表的才算该删。不能边扫边删——边扫边删
|
|
9
|
+
* 在目录枚举顺序变化时会漏删或错删。
|
|
10
|
+
*
|
|
11
|
+
* **只管 `records/`**:`conflicts/`(§3.3 的留证)与 `index.json`(§3.2 的派生品)
|
|
12
|
+
* 都不归这里管。写盘层负责的是"把记忆库投影成一条一文件",不是"维护整个同步仓库"。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-memory/sync-writer
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
/** 记录文件所在的顶层目录名。 */
|
|
19
|
+
const RECORDS_DIR = 'records';
|
|
20
|
+
/** 路径规范成 `/` 分隔——`exportRecords` 用 `join()` 产出,在 Windows 上是反斜杠。 */
|
|
21
|
+
function normalize(path) {
|
|
22
|
+
return path.replace(/\\/gu, '/');
|
|
23
|
+
}
|
|
24
|
+
/** 递归列出 `root` 下的文件,返回相对 `relBase` 的路径(`/` 分隔)。 */
|
|
25
|
+
function scan(root, relBase) {
|
|
26
|
+
if (!existsSync(root))
|
|
27
|
+
return [];
|
|
28
|
+
const found = [];
|
|
29
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
30
|
+
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`;
|
|
31
|
+
if (entry.isDirectory())
|
|
32
|
+
found.push(...scan(join(root, entry.name), rel));
|
|
33
|
+
else
|
|
34
|
+
found.push(rel);
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 把记录文件镜像到磁盘。**纯 IO,不读写任何记忆状态**。
|
|
40
|
+
*
|
|
41
|
+
* 三个步骤按固定顺序:先算差集(该删哪些)、再写(内容有变的)、最后删。
|
|
42
|
+
* 顺序不能调换——先删会让"写失败 + 删成功"变成净损失。
|
|
43
|
+
*
|
|
44
|
+
* 单文件失败不会中断整批:记忆镜像少一份总比整次同步失败好,缺的那份下次写盘会补上。
|
|
45
|
+
*
|
|
46
|
+
* @param options - 目标目录与待写文件。
|
|
47
|
+
* @returns 写入 / 跳过 / 删除的统计。
|
|
48
|
+
*/
|
|
49
|
+
export function writeMirror(options) {
|
|
50
|
+
const recordsRoot = join(options.targetDir, RECORDS_DIR);
|
|
51
|
+
// 待写集合:normalize 之后的相对路径 → 内容
|
|
52
|
+
const wanted = new Map();
|
|
53
|
+
for (const file of options.files)
|
|
54
|
+
wanted.set(normalize(file.path), file.content);
|
|
55
|
+
// 差集先算:磁盘上有、但这次不该存在的,才是要删的
|
|
56
|
+
const stale = scan(recordsRoot, RECORDS_DIR).filter(path => !wanted.has(path));
|
|
57
|
+
let written = 0;
|
|
58
|
+
let unchanged = 0;
|
|
59
|
+
for (const [relativePath, content] of wanted) {
|
|
60
|
+
const absolute = join(options.targetDir, relativePath);
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(absolute) && readFileSync(absolute, 'utf8') === content) {
|
|
63
|
+
unchanged += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
67
|
+
writeFileSync(absolute, content, 'utf8');
|
|
68
|
+
written += 1;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// 单个文件写失败不中断整批;缺的那份下次写盘会补。
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const removedPaths = [];
|
|
75
|
+
for (const relativePath of stale) {
|
|
76
|
+
try {
|
|
77
|
+
rmSync(join(options.targetDir, relativePath), { force: true });
|
|
78
|
+
removedPaths.push(relativePath);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// 同上:删除失败留到下次。
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { written, unchanged, removed: removedPaths.length, removedPaths };
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@max-null/dsh-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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": {
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
68
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
69
|
+
"isomorphic-git": "^1.44.11",
|
|
69
70
|
"zod": "^4.4.3"
|
|
70
71
|
},
|
|
71
72
|
"peerDependencies": {
|