dsh-lite-memory 0.18.43 → 0.18.45
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/client.js +5 -3
- package/docs/BENCH.md +14 -0
- package/docs/RELEASE-0.18.44.md +21 -0
- package/docs/RELEASE-0.18.45.md +16 -0
- package/package.json +3 -3
- package/test/anchor.test.mjs +0 -795
- package/test/client.test.mjs +0 -400
- package/test/contract.test.mjs +0 -1342
- package/test/control.test.mjs +0 -324
- package/test/crossing.test.mjs +0 -471
- package/test/eval-seed.test.mjs +0 -113
- package/test/evolution-read.test.mjs +0 -658
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +0 -18
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +0 -2
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +0 -1
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +0 -1
- package/test/fixtures/legacy-v1/index.json +0 -63
- package/test/ha.test.mjs +0 -186
- package/test/layer-read.test.mjs +0 -293
- package/test/learning.test.mjs +0 -1450
- package/test/lexical.test.mjs +0 -68
- package/test/palace.test.mjs +0 -104
- package/test/recall.test.mjs +0 -1039
- package/test/record.test.mjs +0 -130
- package/test/resident.test.mjs +0 -46
- package/test/runtime-context.test.mjs +0 -192
- package/test/safety.test.mjs +0 -119
- package/test/scenario.test.mjs +0 -184
- package/test/state.test.mjs +0 -663
- package/test/store.test.mjs +0 -1487
- package/test/view.test.mjs +0 -264
|
@@ -1 +0,0 @@
|
|
|
1
|
-
用中文回复。
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
node test/store.test.mjs
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"scope": "fixture",
|
|
4
|
-
"cwd": "CWD_PLACEHOLDER",
|
|
5
|
-
"entries": [
|
|
6
|
-
{
|
|
7
|
-
"id": "mem_111111111111",
|
|
8
|
-
"title": "发布顺序",
|
|
9
|
-
"summary": "发版前要按顺序做的事",
|
|
10
|
-
"when": "所有发布",
|
|
11
|
-
"kind": "note",
|
|
12
|
-
"tags": [],
|
|
13
|
-
"created": "2026-01-01T00:00:00.000Z",
|
|
14
|
-
"updated": "2026-01-01T00:00:00.000Z",
|
|
15
|
-
"lastUsed": "2026-01-01T00:00:00.000Z",
|
|
16
|
-
"revision": 1,
|
|
17
|
-
"status": "active",
|
|
18
|
-
"source": "user_explicit",
|
|
19
|
-
"hits": 0,
|
|
20
|
-
"merges": 0,
|
|
21
|
-
"versions": [],
|
|
22
|
-
"bytes": 100
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"id": "mem_222222222222",
|
|
26
|
-
"title": "回复用中文",
|
|
27
|
-
"summary": "用户要求回复用中文",
|
|
28
|
-
"when": "所有对话",
|
|
29
|
-
"kind": "preference",
|
|
30
|
-
"tags": [],
|
|
31
|
-
"created": "2026-01-02T00:00:00.000Z",
|
|
32
|
-
"updated": "2026-01-02T00:00:00.000Z",
|
|
33
|
-
"lastUsed": "2026-01-02T00:00:00.000Z",
|
|
34
|
-
"revision": 1,
|
|
35
|
-
"status": "active",
|
|
36
|
-
"source": "user_explicit",
|
|
37
|
-
"hits": 0,
|
|
38
|
-
"merges": 0,
|
|
39
|
-
"versions": [],
|
|
40
|
-
"bytes": 100
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"id": "mem_333333333333",
|
|
44
|
-
"title": "跑测试",
|
|
45
|
-
"summary": "提交前跑哪条命令",
|
|
46
|
-
"when": "本地",
|
|
47
|
-
"kind": "note",
|
|
48
|
-
"tags": [],
|
|
49
|
-
"created": "2026-01-03T00:00:00.000Z",
|
|
50
|
-
"updated": "2026-01-03T00:00:00.000Z",
|
|
51
|
-
"lastUsed": "2026-01-03T00:00:00.000Z",
|
|
52
|
-
"revision": 1,
|
|
53
|
-
"status": "active",
|
|
54
|
-
"source": "user_explicit",
|
|
55
|
-
"hits": 0,
|
|
56
|
-
"merges": 0,
|
|
57
|
-
"versions": [],
|
|
58
|
-
"bytes": 100
|
|
59
|
-
}
|
|
60
|
-
],
|
|
61
|
-
"suggestions": [],
|
|
62
|
-
"receipts": []
|
|
63
|
-
}
|
package/test/ha.test.mjs
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-ha-'));
|
|
7
|
-
const store = await import('../store.js');
|
|
8
|
-
const state = await import('../state.js');
|
|
9
|
-
|
|
10
|
-
let passed = 0;
|
|
11
|
-
const test = (name, fn) => {
|
|
12
|
-
try {
|
|
13
|
-
fn();
|
|
14
|
-
passed += 1;
|
|
15
|
-
} catch (error) {
|
|
16
|
-
console.error(`FAIL ${name}`);
|
|
17
|
-
throw error;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const dirs = [];
|
|
22
|
-
const fresh = (label) => {
|
|
23
|
-
const dir = path.join(os.tmpdir(), `lite-memory-ha-${label}-${Math.random().toString(16).slice(2, 8)}`);
|
|
24
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
-
dirs.push(dir);
|
|
26
|
-
return store.scopeOf(dir);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const lockOf = (scope) => path.join(scope.dir, '.lock');
|
|
30
|
-
const currentOf = (scope) => path.join(scope.dir, 'state.json');
|
|
31
|
-
const prevOf = (scope) => path.join(scope.dir, 'state.prev.json');
|
|
32
|
-
|
|
33
|
-
test('P1-C: a lock left by a dead pid is reclaimed instead of reported busy', () => {
|
|
34
|
-
const scope = fresh('stale-lock');
|
|
35
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
36
|
-
fs.writeFileSync(lockOf(scope), '999999:deadbeef');
|
|
37
|
-
const saved = store.upsert(scope, { title: '锁回收', summary: '崩溃进程留下的锁不该卡住写入', body: '正文' });
|
|
38
|
-
assert.ok(saved.record.id.startsWith('mem_'));
|
|
39
|
-
assert.equal(fs.existsSync(lockOf(scope)), false);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('P1-C: a lock held by a live pid still reports busy and is never deleted', () => {
|
|
43
|
-
const scope = fresh('live-lock');
|
|
44
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
45
|
-
const file = lockOf(scope);
|
|
46
|
-
fs.writeFileSync(file, `${process.pid}:live`);
|
|
47
|
-
try {
|
|
48
|
-
assert.throws(
|
|
49
|
-
() => store.upsert(scope, { title: '忙', summary: '另一个进程正在写', body: '正文' }),
|
|
50
|
-
(error) => error.code === 'busy' && /另一个进程/.test(error.message),
|
|
51
|
-
);
|
|
52
|
-
assert.equal(fs.readFileSync(file, 'utf8'), `${process.pid}:live`);
|
|
53
|
-
} finally {
|
|
54
|
-
fs.rmSync(file, { force: true });
|
|
55
|
-
}
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('P0-B: 别的项目占着全局锁,本项目保存自己的条目照样能写', () => {
|
|
59
|
-
const project = fresh('other-project-save');
|
|
60
|
-
const global = store.globalScope();
|
|
61
|
-
fs.mkdirSync(global.dir, { recursive: true });
|
|
62
|
-
const file = lockOf(global);
|
|
63
|
-
fs.writeFileSync(file, `${process.pid}:busy-global`);
|
|
64
|
-
try {
|
|
65
|
-
const saved = store.saveEntry(project, {
|
|
66
|
-
title: '本项目照常保存',
|
|
67
|
-
summary: '别的项目或全局在写,不影响本项目的记忆',
|
|
68
|
-
body: '本项目照常保存正文。',
|
|
69
|
-
memoryClass: 'project_fact',
|
|
70
|
-
});
|
|
71
|
-
assert.equal(saved.chosenScope, 'project');
|
|
72
|
-
assert.ok(saved.record && saved.record.id.startsWith('mem_'));
|
|
73
|
-
} finally {
|
|
74
|
-
fs.rmSync(file, { force: true });
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('P0-B: a corrupt state.json falls back to state.prev.json and says so', () => {
|
|
79
|
-
const scope = fresh('recover');
|
|
80
|
-
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
81
|
-
store.upsert(scope, { title: '第二条', summary: '再写一条,prev 才有货', body: '正文二' });
|
|
82
|
-
assert.ok(fs.existsSync(prevOf(scope)), 'second publish should have produced state.prev.json');
|
|
83
|
-
fs.writeFileSync(currentOf(scope), '{"format":"broken"');
|
|
84
|
-
const loaded = state.load(scope);
|
|
85
|
-
assert.equal(loaded.recovered, true);
|
|
86
|
-
assert.ok(loaded.state.entries.length >= 1);
|
|
87
|
-
assert.match(loaded.recovery.message, /已从上一版恢复/);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test('P0-B: recovery does not bring a closed memory switch back on its own', () => {
|
|
91
|
-
const scope = fresh('recover-auth');
|
|
92
|
-
store.upsert(scope, { title: '关闭前的一条', summary: '先有数据', body: '正文' });
|
|
93
|
-
store.setMemoryEnabled(scope, false, { operationId: 'op-close' });
|
|
94
|
-
// 关闭这一版是 current;prev 还是开启状态。current 坏掉后恢复的是 prev。
|
|
95
|
-
assert.equal(store.controlStatus(scope).enabled, false);
|
|
96
|
-
fs.writeFileSync(currentOf(scope), '{"format":"broken"');
|
|
97
|
-
const loaded = state.load(scope);
|
|
98
|
-
assert.equal(loaded.recovered, true);
|
|
99
|
-
assert.equal(loaded.state.control.recoveryPending, true);
|
|
100
|
-
assert.equal(store.controlStatus(scope).enabled, false, '恢复出来仍是关闭,不能自己恢复授权');
|
|
101
|
-
const reopened = store.setMemoryEnabled(scope, true, { operationId: 'op-reopen' });
|
|
102
|
-
assert.equal(reopened.ok, true);
|
|
103
|
-
assert.equal(store.controlStatus(scope).enabled, true);
|
|
104
|
-
assert.equal(store.controlStatus(scope).recoveryPending, false, '用户明确开启后不再待确认');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('P0-B: both files unreadable gives a recovery step, not just a path', () => {
|
|
108
|
-
const scope = fresh('both-bad');
|
|
109
|
-
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
110
|
-
store.upsert(scope, { title: '第二条', summary: '再写一条', body: '正文二' });
|
|
111
|
-
fs.writeFileSync(currentOf(scope), 'broken');
|
|
112
|
-
fs.writeFileSync(prevOf(scope), 'broken too');
|
|
113
|
-
try {
|
|
114
|
-
state.load(scope);
|
|
115
|
-
assert.fail('expected a corrupt error');
|
|
116
|
-
} catch (error) {
|
|
117
|
-
assert.equal(error.code, 'corrupt');
|
|
118
|
-
assert.match(error.message, /恢复/);
|
|
119
|
-
assert.match(error.message, /state\.prev\.json/);
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test('P0-A: writeDurable publishes atomically and leaves no temp file behind', () => {
|
|
124
|
-
const dir = path.join(os.tmpdir(), `lite-memory-durable-${Math.random().toString(16).slice(2, 8)}`);
|
|
125
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
126
|
-
dirs.push(dir);
|
|
127
|
-
const file = path.join(dir, 'x.json');
|
|
128
|
-
state.writeDurable(file, '{"ok":true}');
|
|
129
|
-
assert.equal(fs.readFileSync(file, 'utf8'), '{"ok":true}');
|
|
130
|
-
assert.deepEqual(fs.readdirSync(dir), ['x.json']);
|
|
131
|
-
state.writeDurable(file, 'second');
|
|
132
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'second');
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test('P0-A: a failing write surfaces as unavailable and does not clobber the good file', () => {
|
|
136
|
-
const dir = path.join(os.tmpdir(), `lite-memory-durable-bad-${Math.random().toString(16).slice(2, 8)}`);
|
|
137
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
138
|
-
dirs.push(dir);
|
|
139
|
-
const file = path.join(dir, 'x.json');
|
|
140
|
-
state.writeDurable(file, 'good');
|
|
141
|
-
const frozen = path.join(dir, 'frozen');
|
|
142
|
-
fs.mkdirSync(frozen);
|
|
143
|
-
if (process.platform === 'win32') {
|
|
144
|
-
// Windows has no directory write bit: chmod on a folder changes nothing, so
|
|
145
|
-
// the injection has to be a failure Windows actually raises. A directory in
|
|
146
|
-
// the way of the final rename is EPERM here and EISDIR on POSIX — both must
|
|
147
|
-
// surface as `unavailable`, and neither may touch the good file beside it.
|
|
148
|
-
const blocked = path.join(dir, 'blocked.json');
|
|
149
|
-
fs.mkdirSync(blocked);
|
|
150
|
-
assert.throws(
|
|
151
|
-
() => state.writeDurable(blocked, 'nope'),
|
|
152
|
-
(error) => error.code === 'unavailable' && /写不进去/.test(error.message),
|
|
153
|
-
);
|
|
154
|
-
} else {
|
|
155
|
-
fs.chmodSync(frozen, 0o500);
|
|
156
|
-
try {
|
|
157
|
-
assert.throws(
|
|
158
|
-
() => state.writeDurable(path.join(frozen, 'y.json'), 'nope'),
|
|
159
|
-
(error) => error.code === 'unavailable' && /写不进去/.test(error.message),
|
|
160
|
-
);
|
|
161
|
-
} finally {
|
|
162
|
-
fs.chmodSync(frozen, 0o700);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
assert.equal(fs.readFileSync(file, 'utf8'), 'good');
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('P1-D: archived body round-trips through cold storage, and corrupt cold fails loudly', () => {
|
|
169
|
-
const scope = fresh('cold');
|
|
170
|
-
const saved = store.upsert(scope, { title: '冷存', summary: '归档正文放冷存', body: '归档正文内容' });
|
|
171
|
-
const archived = store.forget(scope, saved.record.id, { mode: 'archive' });
|
|
172
|
-
assert.ok(['archive', 'archived'].includes(archived.mode));
|
|
173
|
-
const cold = path.join(scope.dir, 'cold', `${saved.record.id}.json`);
|
|
174
|
-
assert.ok(fs.existsSync(cold), 'cold file should exist for an archived record');
|
|
175
|
-
assert.equal(JSON.parse(fs.readFileSync(cold, 'utf8')).body, '归档正文内容');
|
|
176
|
-
assert.equal(store.readEntry(scope, saved.record.id).body, '归档正文内容');
|
|
177
|
-
fs.writeFileSync(cold, '{broken');
|
|
178
|
-
assert.throws(
|
|
179
|
-
() => store.readEntry(scope, saved.record.id),
|
|
180
|
-
(error) => error.code === 'cold_corrupt' && /冷存/.test(error.message),
|
|
181
|
-
'a broken cold file must never read back as an empty body',
|
|
182
|
-
);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
186
|
-
console.log(`test/ha.test.mjs: ${passed} passed`);
|
package/test/layer-read.test.mjs
DELETED
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { fileURLToPath } from 'node:url'
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import { spawn } from 'node:child_process';
|
|
5
|
-
import os from 'node:os';
|
|
6
|
-
import path from 'node:path';
|
|
7
|
-
|
|
8
|
-
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-layer-'));
|
|
9
|
-
const { apply } = await import('../index.js');
|
|
10
|
-
const store = await import('../store.js');
|
|
11
|
-
|
|
12
|
-
const effect = (fn) => {
|
|
13
|
-
const dispose = fn();
|
|
14
|
-
return typeof dispose === 'function' ? dispose : () => {};
|
|
15
|
-
};
|
|
16
|
-
const defs = new Map();
|
|
17
|
-
const ctx = {
|
|
18
|
-
tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
|
|
19
|
-
effect,
|
|
20
|
-
inject: () => () => {},
|
|
21
|
-
get: () => undefined,
|
|
22
|
-
on: () => () => {},
|
|
23
|
-
};
|
|
24
|
-
apply(ctx, { mode: 'adaptive' });
|
|
25
|
-
const exec = (name, args, cwd) => defs.get(name).execute(args, { agent: { cwd } });
|
|
26
|
-
const render = (name, args, value) => defs.get(name).output.render(args, value)[0].text;
|
|
27
|
-
|
|
28
|
-
const dirOf = (label) => {
|
|
29
|
-
const dir = path.join(os.tmpdir(), `lite-memory-layer-${label}-${Math.random().toString(16).slice(2, 8)}`);
|
|
30
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
31
|
-
return dir;
|
|
32
|
-
};
|
|
33
|
-
const projectA = dirOf('a');
|
|
34
|
-
const projectB = dirOf('b');
|
|
35
|
-
|
|
36
|
-
// A 项目存一条普通记忆;B 的运行时不得通过标题或 id 拿到 A 的正文。
|
|
37
|
-
const saved = store.saveEntry(store.scopeOf(projectA), {
|
|
38
|
-
title: '甲项目发布约定',
|
|
39
|
-
summary: '甲项目部署前先维护独立业务队列',
|
|
40
|
-
body: '甲项目专用:部署前先维护独立业务队列。',
|
|
41
|
-
memoryClass: 'project_fact',
|
|
42
|
-
});
|
|
43
|
-
assert.equal(saved.chosenScope, 'project');
|
|
44
|
-
const idOfA = saved.record.id;
|
|
45
|
-
|
|
46
|
-
const layers = store.runtimeLayers(store.scopeOf(projectB)).map((item) => item.key);
|
|
47
|
-
assert.equal(layers.length, 2, '运行时只有当前项目和全局两层');
|
|
48
|
-
assert.ok(!layers.includes(store.scopeOf(projectA).key), '运行时层不枚举其他项目');
|
|
49
|
-
|
|
50
|
-
await assert.rejects(
|
|
51
|
-
() => exec('memory_read', { title: '甲项目发布约定' }, projectB),
|
|
52
|
-
/没有「甲项目发布约定」这条记忆/,
|
|
53
|
-
'跨项目标题不得解析到别的项目',
|
|
54
|
-
);
|
|
55
|
-
await assert.rejects(
|
|
56
|
-
() => exec('memory_read', { id: idOfA }, projectB),
|
|
57
|
-
/没有「.*」这条记忆/,
|
|
58
|
-
'跨项目 id 也不得读到正文',
|
|
59
|
-
);
|
|
60
|
-
assert.equal(store.locate(store.scopeOf(projectB), idOfA), null, '默认 locate 不跨层');
|
|
61
|
-
|
|
62
|
-
// B 读自己的条目:运行时只锁当前项目和全局,A 的条目不在枚举里。
|
|
63
|
-
const own = store.saveEntry(store.scopeOf(projectB), {
|
|
64
|
-
title: '乙项目发布约定',
|
|
65
|
-
summary: '乙项目部署前先跑迁移',
|
|
66
|
-
body: '乙项目专用:部署前先跑迁移。',
|
|
67
|
-
memoryClass: 'project_fact',
|
|
68
|
-
});
|
|
69
|
-
assert.ok(own.record);
|
|
70
|
-
const readOwn = await exec('memory_read', { title: '乙项目发布约定' }, projectB);
|
|
71
|
-
assert.equal(readOwn.status, 'ok');
|
|
72
|
-
assert.match(readOwn.body, /先跑迁移/);
|
|
73
|
-
assert.equal(readOwn.scope, 'project');
|
|
74
|
-
|
|
75
|
-
// 同名项目/全局条目:指定 global 只解析全局,不串到项目层。
|
|
76
|
-
store.saveEntry(store.globalScope(), {
|
|
77
|
-
title: '同名发布约定',
|
|
78
|
-
summary: '全局:发布前先确认远端已推送',
|
|
79
|
-
body: '全局:发布前先确认远端已推送。',
|
|
80
|
-
memoryClass: 'personal_preference',
|
|
81
|
-
});
|
|
82
|
-
store.saveEntry(store.scopeOf(projectB), {
|
|
83
|
-
title: '同名发布约定',
|
|
84
|
-
summary: '项目:发布前先更新 CHANGELOG',
|
|
85
|
-
body: '项目:发布前先更新 CHANGELOG。',
|
|
86
|
-
memoryClass: 'project_fact',
|
|
87
|
-
resolution: 'distinct',
|
|
88
|
-
});
|
|
89
|
-
const readGlobal = await exec('memory_read', { title: '同名发布约定', scope: 'global' }, projectB);
|
|
90
|
-
assert.equal(readGlobal.status, 'ok');
|
|
91
|
-
assert.match(readGlobal.body, /远端已推送/, 'scope:global 必须读全局那条');
|
|
92
|
-
assert.match(render('memory_read', { title: '同名发布约定', scope: 'global' }, readGlobal), /^memory · \[global\] 「同名发布约定」/, '读到的全局条目要带 [global],不能冒充当前项目');
|
|
93
|
-
const readProject = await exec('memory_read', { title: '同名发布约定' }, projectB);
|
|
94
|
-
assert.match(readProject.body, /CHANGELOG/, '不指定范围时先读当前项目');
|
|
95
|
-
assert.doesNotMatch(render('memory_read', { title: '同名发布约定' }, readProject), /\[global\]/, '项目条目不能被标成全局');
|
|
96
|
-
|
|
97
|
-
// 同名条目跨层更新:不指定范围先报候选,指定 global 只改全局,项目条目原样。
|
|
98
|
-
const ambiguous = await exec('memory_save', {
|
|
99
|
-
updateTitle: '同名发布约定',
|
|
100
|
-
summary: '改了但没说是哪一层',
|
|
101
|
-
body: '改了但没说是哪一层。',
|
|
102
|
-
memoryClass: 'project_fact',
|
|
103
|
-
}, projectB);
|
|
104
|
-
assert.match(ambiguous.error, /各有一条/, '同名跨层不给默认匹配');
|
|
105
|
-
const globalUpdate = await exec('memory_save', {
|
|
106
|
-
scope: 'global',
|
|
107
|
-
updateTitle: '同名发布约定',
|
|
108
|
-
summary: '全局:发布前先确认远端已推送并打 tag',
|
|
109
|
-
body: '全局:发布前先确认远端已推送并打 tag。',
|
|
110
|
-
memoryClass: 'personal_preference',
|
|
111
|
-
}, projectB);
|
|
112
|
-
assert.equal(globalUpdate.error, undefined);
|
|
113
|
-
assert.equal(globalUpdate.scope, 'global');
|
|
114
|
-
assert.match((await exec('memory_read', { title: '同名发布约定', scope: 'global' }, projectB)).body, /打 tag/);
|
|
115
|
-
assert.match((await exec('memory_read', { title: '同名发布约定' }, projectB)).body, /CHANGELOG/, '项目条目没有被全局更新带跑');
|
|
116
|
-
|
|
117
|
-
// 条件未确认:标题只能定位,不能证明适用;不返回正文。
|
|
118
|
-
store.saveEntry(store.scopeOf(projectB), {
|
|
119
|
-
title: '发版当天步骤',
|
|
120
|
-
summary: '发版当天冻结、打标签、公告',
|
|
121
|
-
body: '冻结代码,然后打标签。',
|
|
122
|
-
when: '发版当天',
|
|
123
|
-
memoryClass: 'project_fact',
|
|
124
|
-
});
|
|
125
|
-
const conditional = await exec('memory_read', { title: '发版当天步骤' }, projectB);
|
|
126
|
-
assert.equal(conditional.status, 'needs_context');
|
|
127
|
-
assert.equal(conditional.body, '', '条件未知时标题读取不得先给正文');
|
|
128
|
-
assert.equal(conditional.when, undefined);
|
|
129
|
-
const text = defs.get('memory_read').output.render({}, conditional)[0].text;
|
|
130
|
-
assert.doesNotMatch(text, /冻结代码/, '渲染文本也不得带正文');
|
|
131
|
-
assert.doesNotMatch(text, /mem_[a-z0-9]{6,}/);
|
|
132
|
-
|
|
133
|
-
// 只给 id 的程序化取用不放正文,门槛不变。
|
|
134
|
-
await assert.rejects(
|
|
135
|
-
() => exec('memory_read', { id: 'mem_000000000000' }, projectB),
|
|
136
|
-
/没有「mem_000000000000」这条记忆/,
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
// 归档后仍能按标题彻底删除;归档不重新参与召回。
|
|
140
|
-
const ownRecord = store.locateByTitle(store.scopeOf(projectB), '乙项目发布约定');
|
|
141
|
-
assert.ok(ownRecord);
|
|
142
|
-
store.forget(store.scopeOf(projectB), ownRecord.record.id, { mode: 'archive' });
|
|
143
|
-
await exec('memory_forget', { title: '乙项目发布约定', mode: 'purge' }, projectB);
|
|
144
|
-
assert.equal(store.locateByTitle(store.scopeOf(projectB), '乙项目发布约定'), null, '归档后可按标题 purge');
|
|
145
|
-
assert.equal(store.locateByTitle(store.scopeOf(projectB), '乙项目发布约定', { crossLayer: true }), null, '彻底删除后哪一层都查不到');
|
|
146
|
-
assert.equal(store.readEntry(store.scopeOf(projectB), ownRecord.record.id), null, '正文也不在了');
|
|
147
|
-
|
|
148
|
-
// 显式 scope="project" 只解析项目层:全局唯一条目不能被读,更不能被 purge 掉。
|
|
149
|
-
const globalOnly = store.saveEntry(store.globalScope(), {
|
|
150
|
-
title: '全局唯一凭据入口',
|
|
151
|
-
summary: '全局:凭据只从密码管理器取',
|
|
152
|
-
body: '全局:凭据只从密码管理器取,不写进仓库。',
|
|
153
|
-
memoryClass: 'personal_preference',
|
|
154
|
-
});
|
|
155
|
-
assert.ok(globalOnly.record, '全局条目已写入');
|
|
156
|
-
await assert.rejects(
|
|
157
|
-
() => exec('memory_read', { title: '全局唯一凭据入口', scope: 'project' }, projectB),
|
|
158
|
-
/没有「全局唯一凭据入口」这条记忆/,
|
|
159
|
-
'显式项目层不得读到全局条目',
|
|
160
|
-
);
|
|
161
|
-
await assert.rejects(
|
|
162
|
-
() => exec('memory_forget', { title: '全局唯一凭据入口', scope: 'project', mode: 'purge' }, projectB),
|
|
163
|
-
/没有「全局唯一凭据入口」这条记忆/,
|
|
164
|
-
'显式项目层不得彻底删除全局条目',
|
|
165
|
-
);
|
|
166
|
-
const globalOnlyAfter = store.locate(store.scopeOf(projectB), globalOnly.record.id, { layers: [store.globalScope()] });
|
|
167
|
-
assert.ok(globalOnlyAfter, '全局条目仍在原层');
|
|
168
|
-
assert.match(globalOnlyAfter.record.body || '', /密码管理器/, '正文没有被错层删除');
|
|
169
|
-
|
|
170
|
-
// 相近条目在另一层时,确认提示要给出能落地的动作:更新就落到那一层,否则会另建一条。
|
|
171
|
-
const globalDraft = store.saveEntry(store.scopeOf(projectB), {
|
|
172
|
-
title: 'npm 发布凭据取用与发布后验版',
|
|
173
|
-
summary: 'npm 发布用 account regrevia,发布后轮询 dist-tags 验版',
|
|
174
|
-
body: '发布用 regrevia 身份;发完轮询 dist-tags.latest 判上线。',
|
|
175
|
-
memoryClass: 'personal_preference', scope: 'global', scopeConfirmed: true,
|
|
176
|
-
});
|
|
177
|
-
assert.equal(globalDraft.chosenScope, 'global');
|
|
178
|
-
const near = await exec('memory_save', {
|
|
179
|
-
title: 'npm 发布凭据怎么取用、发完怎么验版',
|
|
180
|
-
summary: 'npm 发布用 regrevia,发完轮询 dist-tags 验证版本',
|
|
181
|
-
body: 'npm publish 之后轮询 dist-tags 确认版本。',
|
|
182
|
-
memoryClass: 'project_fact',
|
|
183
|
-
}, projectB);
|
|
184
|
-
assert.equal(near.needsResolution, true);
|
|
185
|
-
assert.equal(near.crossLayer, true);
|
|
186
|
-
assert.equal(near.crossScope, 'global');
|
|
187
|
-
const nearText = defs.get('memory_save').output.render({}, near)[0].text;
|
|
188
|
-
assert.match(nearText, /scope="global"/);
|
|
189
|
-
assert.doesNotMatch(nearText, /mem_[a-z0-9]{6,}/);
|
|
190
|
-
|
|
191
|
-
// 生命周期:被取代或过期的条目不再自动召回,也不通过标题读交正文。
|
|
192
|
-
const view = await import('../view.js');
|
|
193
|
-
const first = store.saveEntry(store.scopeOf(projectB), {
|
|
194
|
-
title: '旧版发布流程',
|
|
195
|
-
summary: '旧版:手工上传',
|
|
196
|
-
body: '旧版:手工上传到服务器。',
|
|
197
|
-
memoryClass: 'project_fact',
|
|
198
|
-
});
|
|
199
|
-
const next = store.saveEntry(store.scopeOf(projectB), {
|
|
200
|
-
title: '新版发布流程',
|
|
201
|
-
summary: '新版:CI 自动发布',
|
|
202
|
-
body: '新版:CI 自动发布。',
|
|
203
|
-
memoryClass: 'project_fact',
|
|
204
|
-
supersedes: first.record.id,
|
|
205
|
-
resolution: 'distinct',
|
|
206
|
-
});
|
|
207
|
-
assert.equal(next.chosenScope, 'project');
|
|
208
|
-
const stale = view.eligibility({ ...first.record, validUntil: '', supersededBy: next.record.id }, {}, 'auto');
|
|
209
|
-
assert.equal(stale.ok, false, '被取代的条目不得自动召回');
|
|
210
|
-
assert.equal(stale.reason, 'superseded');
|
|
211
|
-
const staleRead = await exec('memory_read', { title: '旧版发布流程' }, projectB);
|
|
212
|
-
assert.equal(staleRead.status, 'gone', '被取代条目按标题读只报已不在');
|
|
213
|
-
assert.equal(staleRead.body, '');
|
|
214
|
-
const staleHits = store.search(store.scopeOf(projectB), '旧版发布流程');
|
|
215
|
-
assert.ok(!staleHits.some((item) => (item.record || item).id === first.record.id || (item.record || item).title === '旧版发布流程'), '默认检索不返回被取代条目');
|
|
216
|
-
assert.equal(view.eligibility({ status: 'active', validUntil: '2000-01-01T00:00:00Z' }, {}, 'auto').ok, false, '过期条目不得召回');
|
|
217
|
-
assert.equal(view.eligibility({ status: 'active', validUntil: '2000-01-01T00:00:00Z' }, {}, 'read').reason, 'expired');
|
|
218
|
-
|
|
219
|
-
// 忘掉同名条目:不带层就报候选,带 global 只归档全局,项目条目还在。
|
|
220
|
-
await assert.rejects(() => exec('memory_forget', { title: '同名发布约定', mode: 'archive' }, projectB), /各有一条/, '同名跨层删除不给默认匹配');
|
|
221
|
-
const globalForget = await exec('memory_forget', { title: '同名发布约定', scope: 'global', mode: 'archive' }, projectB);
|
|
222
|
-
assert.equal(globalForget.mode, 'archive');
|
|
223
|
-
assert.equal(globalForget.scope, 'global');
|
|
224
|
-
assert.equal((await exec('memory_read', { title: '同名发布约定' }, projectB)).status, 'ok', '项目条目没被一起归档');
|
|
225
|
-
const archivedGlobal = await exec('memory_read', { title: '同名发布约定', scope: 'global' }, projectB);
|
|
226
|
-
assert.equal(archivedGlobal.status, 'archived', '归档条目按标题只报已归档');
|
|
227
|
-
assert.equal(archivedGlobal.body, '');
|
|
228
|
-
|
|
229
|
-
// 别的项目正在写,不影响本项目的读和写:运行时锁只覆盖当前项目和全局。
|
|
230
|
-
store.saveEntry(store.scopeOf(projectB), {
|
|
231
|
-
title: '锁隔离验证',
|
|
232
|
-
summary: '锁隔离验证条目',
|
|
233
|
-
body: '锁隔离验证条目正文。',
|
|
234
|
-
memoryClass: 'project_fact',
|
|
235
|
-
});
|
|
236
|
-
const marker = path.join(os.tmpdir(), `lite-memory-hold-${Math.random().toString(16).slice(2, 8)}`);
|
|
237
|
-
const repo = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
238
|
-
const holder = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
239
|
-
import fs from 'node:fs';
|
|
240
|
-
const { scopeOf } = await import('./store.js');
|
|
241
|
-
const state = await import('./state.js');
|
|
242
|
-
const scope = scopeOf(process.argv[1]);
|
|
243
|
-
state.withScopeLock(scope, () => {
|
|
244
|
-
fs.writeFileSync(process.argv[2], '1');
|
|
245
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 900);
|
|
246
|
-
});
|
|
247
|
-
`, projectA, marker], { cwd: repo, stdio: 'ignore', env: process.env });
|
|
248
|
-
for (let i = 0; i < 200 && !fs.existsSync(marker); i += 1) {
|
|
249
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
250
|
-
}
|
|
251
|
-
assert.ok(fs.existsSync(marker), '子进程已拿到 A 项目锁');
|
|
252
|
-
const started = Date.now();
|
|
253
|
-
const lockedRead = await exec('memory_read', { title: '锁隔离验证' }, projectB);
|
|
254
|
-
const elapsed = Date.now() - started;
|
|
255
|
-
assert.equal(lockedRead.status, 'ok');
|
|
256
|
-
assert.match(lockedRead.body, /锁隔离验证条目正文/);
|
|
257
|
-
assert.ok(elapsed < 200, `A 项目持锁时 B 的读取不得等锁(实际 ${elapsed}ms)`);
|
|
258
|
-
const lockedSave = await exec('memory_save', { title: '锁隔离验证二', summary: '第二条', body: '第二条正文。', memoryClass: 'project_fact' }, projectB);
|
|
259
|
-
assert.equal(lockedSave.error, undefined);
|
|
260
|
-
assert.equal(lockedSave.scope, 'project');
|
|
261
|
-
holder.kill();
|
|
262
|
-
|
|
263
|
-
// 关联清单也要标层:项目邻居与全局邻居一眼分得清,不靠标题猜。
|
|
264
|
-
const globalLinkTarget = store.saveEntry(store.globalScope(), {
|
|
265
|
-
title: '全局关联目标',
|
|
266
|
-
summary: '全局层的邻居',
|
|
267
|
-
body: '全局层的邻居正文。',
|
|
268
|
-
memoryClass: 'personal_preference',
|
|
269
|
-
});
|
|
270
|
-
assert.ok(globalLinkTarget.record);
|
|
271
|
-
const projectLinkTarget = store.saveEntry(store.scopeOf(projectB), {
|
|
272
|
-
title: '项目关联目标',
|
|
273
|
-
summary: '项目层的邻居',
|
|
274
|
-
body: '项目层的邻居正文。',
|
|
275
|
-
memoryClass: 'project_fact',
|
|
276
|
-
resolution: 'distinct',
|
|
277
|
-
});
|
|
278
|
-
assert.ok(projectLinkTarget.record);
|
|
279
|
-
const linked = await exec('memory_save', {
|
|
280
|
-
title: '带两个邻居的条目',
|
|
281
|
-
summary: '同时关联项目与全局',
|
|
282
|
-
body: '同时关联项目与全局。',
|
|
283
|
-
memoryClass: 'project_fact',
|
|
284
|
-
links: [{ title: '项目关联目标' }, { title: '全局关联目标' }],
|
|
285
|
-
}, projectB);
|
|
286
|
-
assert.equal(linked.error, undefined);
|
|
287
|
-
const linkView = await exec('memory_read', { title: '带两个邻居的条目', view: 'links' }, projectB);
|
|
288
|
-
const linkText = render('memory_read', { title: '带两个邻居的条目', view: 'links' }, linkView);
|
|
289
|
-
assert.match(linkText, /「项目关联目标」/);
|
|
290
|
-
assert.match(linkText, /\[global\] 「全局关联目标」/, '全局邻居要带 [global]');
|
|
291
|
-
assert.doesNotMatch(linkText, /\[global\] 「项目关联目标」/, '项目邻居不能被标成全局');
|
|
292
|
-
|
|
293
|
-
console.log('layer-read ok');
|