dsh-lite-memory 0.18.1 → 0.18.19
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 +13 -13
- package/TRIGGERS.md +1 -1
- package/client.js +184 -34
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +347 -123
- package/learning.js +65 -6
- package/package.json +3 -3
- package/state.js +47 -6
- package/store.js +180 -18
- package/test/client.test.mjs +47 -25
- package/test/contract.test.mjs +221 -86
- package/test/control.test.mjs +2 -2
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +10 -10
- package/test/ha.test.mjs +55 -5
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +67 -9
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +85 -12
package/test/control.test.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const seedRule = (scope, title) => {
|
|
|
57
57
|
const row = draft.entries.find((entry) => entry.id === id);
|
|
58
58
|
row.origin = 'project_evolution';
|
|
59
59
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
60
|
-
row.maintenance.learning = { state: 'active' };
|
|
60
|
+
row.maintenance.learning = { state: 'active', specDigest: 'seeded' };
|
|
61
61
|
return { changed: true };
|
|
62
62
|
});
|
|
63
63
|
return id;
|
|
@@ -95,7 +95,7 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
|
|
|
95
95
|
const saved = await runtime.exec('memory_save', { title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' }, agent);
|
|
96
96
|
assert.equal(saved.changed, false);
|
|
97
97
|
assert.equal(saved.reason, 'memory_disabled');
|
|
98
|
-
assert.equal(saved.id,
|
|
98
|
+
assert.equal(saved.id, undefined);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/* C02:关闭期间所有写入入口都拒绝,自进化不能绕过总开关打开。 */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 30 条固定样本的召回基线:10 该召回、10 该拒绝、10 该等条件确认。
|
|
3
|
+
* 只断言行为(召回率 / 误触发 / 边界外泄),措辞取用户原话视角。
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-eval-'));
|
|
11
|
+
const view = await import('../view.js');
|
|
12
|
+
|
|
13
|
+
const base = (extra) => ({
|
|
14
|
+
status: 'active', applicability: 'all_in_scope', conditions: [], selection: null,
|
|
15
|
+
aliases: [], tags: [], triggers: [], when: '', lastUsed: '', ...extra,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MEM = [
|
|
19
|
+
base({ id: 'mem_100000000001', title: '插件改完先试热刷新', summary: '热刷新不行再重启宿主', triggers: ['插件改完'] }),
|
|
20
|
+
base({ id: 'mem_100000000002', title: '发版前跑四条 lint', summary: '文案、中文排印、风格、检测器都要清零', triggers: ['发版'] }),
|
|
21
|
+
base({ id: 'mem_100000000003', title: '外链一律用绝对路径', summary: '聊天里的文件链接必须绝对路径,否则点开报 ENOENT' }),
|
|
22
|
+
base({ id: 'mem_100000000004', title: '截图只在看外观时用', summary: '判断页面对不对读文本树' }),
|
|
23
|
+
base({ id: 'mem_100000000005', title: '写盘先写临时文件再改名', summary: 'rename 之后 fsync 目录,避免半截文件' }),
|
|
24
|
+
base({ id: 'mem_100000000006', title: '客户端包上限四十 KiB', summary: 'gzip 之后超过就不许合入' }),
|
|
25
|
+
base({ id: 'mem_100000000007', title: '测试日志写到临时目录', summary: '长跑放后台,用 EXIT= 判断结果' }),
|
|
26
|
+
base({ id: 'mem_100000000008', title: '推送前先跑全量测试', summary: '绿了再 commit' }),
|
|
27
|
+
base({ id: 'mem_100000000009', title: '一次性脚本用完就删', summary: '这个脚本只跑一次,别留仓库里' }),
|
|
28
|
+
base({ id: 'mem_100000000010', title: '中文文案不用省略号收尾', summary: '能写完就写完' }),
|
|
29
|
+
base({
|
|
30
|
+
id: 'mem_100000000011', title: '部署前确认目标机器', summary: '只在目标机器上执行',
|
|
31
|
+
applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'deploy' }],
|
|
32
|
+
}),
|
|
33
|
+
base({ id: 'mem_100000000012', title: '配色只沿用现有 token', summary: '不新造颜色', applicability: 'unresolved' }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** 10 条该召回:查询是真人会说的话,答案就在标题/关键词里。 */
|
|
37
|
+
const RECALL = [
|
|
38
|
+
['插件改完怎么让它生效?', '插件改完先试热刷新'],
|
|
39
|
+
['发版之前要跑哪些检查?', '发版前跑四条 lint'],
|
|
40
|
+
['聊天里给的文件链接怎么写?', '外链一律用绝对路径'],
|
|
41
|
+
['判断页面报错要截图吗?', '截图只在看外观时用'],
|
|
42
|
+
['写文件怎么保证不会写出半截?', '写盘先写临时文件再改名'],
|
|
43
|
+
['客户端包大小有上限吗?', '客户端包上限四十 KiB'],
|
|
44
|
+
['测试日志放哪里?', '测试日志写到临时目录'],
|
|
45
|
+
['推送代码前要做什么?', '推送前先跑全量测试'],
|
|
46
|
+
['临时脚本要留在仓库里吗?', '一次性脚本用完就删'],
|
|
47
|
+
['中文文案末尾要加省略号吗?', '中文文案不用省略号收尾'],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** 10 条该拒绝:字面上有常见词,但跟任何一条记忆都不相关。 */
|
|
51
|
+
const REJECT = [
|
|
52
|
+
'把这段视频的字幕导出来',
|
|
53
|
+
'明早路过菜市场买点香菜',
|
|
54
|
+
'这个季节樱桃多少钱一斤',
|
|
55
|
+
'帮我把上周的照片按日期归类',
|
|
56
|
+
'厨房水槽下面有点漏水',
|
|
57
|
+
'地铁六号线末班车几点',
|
|
58
|
+
'明天杭州会下雨吗',
|
|
59
|
+
'给猫换一个更安静的猫砂盆',
|
|
60
|
+
'这本书的第二章讲什么',
|
|
61
|
+
'楼下咖啡店的营业时间',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** 10 条该等条件:找到了,但适用条件还没确认,不许当已适用塞进上下文。 */
|
|
65
|
+
const AWAIT = [
|
|
66
|
+
['这台机器部署前要确认什么?', '部署前确认目标机器'],
|
|
67
|
+
['部署脚本怎么跑?', '部署前确认目标机器'],
|
|
68
|
+
['机器对不上能直接部署吗?', '部署前确认目标机器'],
|
|
69
|
+
['新页面的配色怎么定?', '配色只沿用现有 token'],
|
|
70
|
+
['这个组件能新造颜色吗?', '配色只沿用现有 token'],
|
|
71
|
+
['界面上可以加渐变吗?', '配色只沿用现有 token'],
|
|
72
|
+
['主题色改成什么了?', '配色只沿用现有 token'],
|
|
73
|
+
['图标颜色跟着谁走?', '配色只沿用现有 token'],
|
|
74
|
+
['按钮圆角用新的吗?', '配色只沿用现有 token'],
|
|
75
|
+
['换肤要动哪些变量?', '配色只沿用现有 token'],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
const titleOf = (row) => row.record.title;
|
|
79
|
+
const rank = (query, context = {}) => view.rank(MEM, query, { limit: 3, requireApplicable: true, context });
|
|
80
|
+
|
|
81
|
+
let passed = 0;
|
|
82
|
+
const test = (name, fn) => {
|
|
83
|
+
fn();
|
|
84
|
+
passed += 1;
|
|
85
|
+
console.log(`eval: ${name}`);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
test('该召回的 10 条都进前三', () => {
|
|
89
|
+
const missed = RECALL.filter(([query, want]) => !rank(query).slice(0, 3).some((row) => titleOf(row) === want));
|
|
90
|
+
const recallAt3 = (RECALL.length - missed.length) / RECALL.length;
|
|
91
|
+
assert.ok(recallAt3 >= 0.9, `Recall@3 ${recallAt3};漏了:${missed.map((row) => row[0]).join('、')}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('该拒绝的 10 条一条都不出现', () => {
|
|
95
|
+
const leaked = REJECT.filter((query) => rank(query).length > 0);
|
|
96
|
+
assert.equal(leaked.length, 0, `误触发:${leaked.join('、')}`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('条件未确认的 10 条不会当作已适用出现', () => {
|
|
100
|
+
const exposed = AWAIT.filter(([query, want]) => rank(query).some((row) => titleOf(row) === want));
|
|
101
|
+
assert.equal(exposed.length, 0, `条件未知却进了上下文:${exposed.map((row) => row[0]).join('、')}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('条件确认之后同一条就能用', () => {
|
|
105
|
+
const rows = rank('这台机器部署前要确认什么?', { taskKind: 'deploy' });
|
|
106
|
+
assert.ok(rows.some((row) => titleOf(row) === '部署前确认目标机器'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('措辞不同也能召回(同义说法不依赖逐字重合)', () => {
|
|
110
|
+
assert.ok(rank('热刷新不生效是不是要重启').some((row) => titleOf(row) === '插件改完先试热刷新'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
console.log(`eval: ${passed} passed`);
|
|
@@ -63,7 +63,7 @@ const save = (scope, fields) => {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
/** 直接种一条实验来源条目,绕开 PR4 的整理入口。 */
|
|
66
|
-
function seedRule(scope, { title, body, summary, conditions = [], learning = { state: 'active' }, topicKey, tags = [] }) {
|
|
66
|
+
function seedRule(scope, { title, body, summary, conditions = [], learning = { state: 'active', specDigest: 'seeded' }, topicKey, tags = [] }) {
|
|
67
67
|
const saved = save(scope, { title, body, summary, tags });
|
|
68
68
|
state.transact(scope, {}, (draft) => {
|
|
69
69
|
const row = draft.entries.find((entry) => entry.id === saved.id);
|
|
@@ -169,7 +169,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
169
169
|
const text = runtime.text(agent, sig);
|
|
170
170
|
assert.match(text, /项目实验规则/);
|
|
171
171
|
assert.match(text, /先给结论再解释/);
|
|
172
|
-
assert.ok(text.includes(id));
|
|
172
|
+
assert.ok(!text.includes(id), '实验规则也只给标题,不给内部编号');
|
|
173
173
|
assert.doesNotMatch(text, /第 .* 版/);
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -185,7 +185,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
185
185
|
const sig = signal();
|
|
186
186
|
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
187
187
|
const text = runtime.text(agent, sig);
|
|
188
|
-
assert.ok(text.includes(id));
|
|
188
|
+
assert.ok(!text.includes(id), '实验规则也只给标题,不给内部编号');
|
|
189
189
|
assert.match(text, /memory_read/);
|
|
190
190
|
assert.equal(text.includes('先做这一步再检查'), false);
|
|
191
191
|
assert.equal(Buffer.byteLength(text) <= 1200 + 200, true);
|
|
@@ -388,7 +388,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
388
388
|
title: '暂停口播规则',
|
|
389
389
|
body: '这条已经暂停,不该给正文。',
|
|
390
390
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
391
|
-
learning: { state: 'paused', blockedReason: 'needs_revalidation' },
|
|
391
|
+
learning: { state: 'paused', blockedReason: 'needs_revalidation', specDigest: 'seeded' },
|
|
392
392
|
});
|
|
393
393
|
enable(scope, { seriesId: '口播' });
|
|
394
394
|
const runtime = boot();
|
|
@@ -424,7 +424,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
424
424
|
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
|
|
425
425
|
const value = await runtime.exec('memory_search', { query: '口播开头', bindForRequest: true }, agent);
|
|
426
426
|
assert.equal(value.status, 'ok');
|
|
427
|
-
assert.equal((value.refs || []).some((ref) => ref.
|
|
427
|
+
assert.equal((value.refs || []).some((ref) => ref.title === '口播开头'), true);
|
|
428
428
|
const episodes = Object.values(store.taskState(scope).episodes);
|
|
429
429
|
assert.equal(episodes.length, 1);
|
|
430
430
|
assert.equal(episodes[0].status, 'proposed');
|
|
@@ -435,7 +435,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
435
435
|
const dir = mkdir('fixed');
|
|
436
436
|
const scope = scopeOf(dir);
|
|
437
437
|
save(scope, { title: '固定发布检查', body: '发版前跑一遍检查。', tags: ['发布'] });
|
|
438
|
-
seedRule(scope, { title: '实验发布检查', body: '实验规则:发版前先备份。', tags: ['发布'], learning: { state: 'active' } });
|
|
438
|
+
seedRule(scope, { title: '实验发布检查', body: '实验规则:发版前先备份。', tags: ['发布'], learning: { state: 'active', specDigest: 'seeded' } });
|
|
439
439
|
enable(scope);
|
|
440
440
|
const runtime = boot();
|
|
441
441
|
const agent = makeAgent('a-fixed', dir);
|
|
@@ -473,7 +473,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
473
473
|
title: '过期口播规则',
|
|
474
474
|
body: '这条试用已经到期,不该自动出现。',
|
|
475
475
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
476
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() - 60_000).toISOString() },
|
|
476
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() - 60_000).toISOString() },
|
|
477
477
|
});
|
|
478
478
|
enable(scope, { seriesId: '口播' });
|
|
479
479
|
const runtime = boot();
|
|
@@ -494,7 +494,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
494
494
|
title: '快到期口播规则',
|
|
495
495
|
body: '这条试用马上到期,过期后同一请求也不能再出现。',
|
|
496
496
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
497
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
497
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
498
498
|
});
|
|
499
499
|
enable(scope, { seriesId: '口播' });
|
|
500
500
|
const runtime = boot();
|
|
@@ -514,14 +514,14 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
514
514
|
title: '快到期的主题规则',
|
|
515
515
|
body: '这条试用马上到期。',
|
|
516
516
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
517
|
-
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
517
|
+
learning: { state: 'trial', specDigest: 'seeded', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
518
518
|
topicKey: 'theme',
|
|
519
519
|
});
|
|
520
520
|
seedRule(scope, {
|
|
521
521
|
title: '长期口播开头',
|
|
522
522
|
body: '开场三句内给结论。',
|
|
523
523
|
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
524
|
-
learning: { state: 'active' },
|
|
524
|
+
learning: { state: 'active', specDigest: 'seeded' },
|
|
525
525
|
topicKey: 'opening',
|
|
526
526
|
});
|
|
527
527
|
enable(scope, { seriesId: '口播' });
|
package/test/ha.test.mjs
CHANGED
|
@@ -55,6 +55,26 @@ test('P1-C: a lock held by a live pid still reports busy and is never deleted',
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
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
|
+
|
|
58
78
|
test('P0-B: a corrupt state.json falls back to state.prev.json and says so', () => {
|
|
59
79
|
const scope = fresh('recover');
|
|
60
80
|
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
@@ -67,6 +87,23 @@ test('P0-B: a corrupt state.json falls back to state.prev.json and says so', ()
|
|
|
67
87
|
assert.match(loaded.recovery.message, /已从上一版恢复/);
|
|
68
88
|
});
|
|
69
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
|
+
|
|
70
107
|
test('P0-B: both files unreadable gives a recovery step, not just a path', () => {
|
|
71
108
|
const scope = fresh('both-bad');
|
|
72
109
|
store.upsert(scope, { title: '第一条', summary: '先写一条', body: '正文一' });
|
|
@@ -103,14 +140,27 @@ test('P0-A: a failing write surfaces as unavailable and does not clobber the goo
|
|
|
103
140
|
state.writeDurable(file, 'good');
|
|
104
141
|
const frozen = path.join(dir, 'frozen');
|
|
105
142
|
fs.mkdirSync(frozen);
|
|
106
|
-
|
|
107
|
-
|
|
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);
|
|
108
150
|
assert.throws(
|
|
109
|
-
() => state.writeDurable(
|
|
151
|
+
() => state.writeDurable(blocked, 'nope'),
|
|
110
152
|
(error) => error.code === 'unavailable' && /写不进去/.test(error.message),
|
|
111
153
|
);
|
|
112
|
-
}
|
|
113
|
-
fs.chmodSync(frozen,
|
|
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
|
+
}
|
|
114
164
|
}
|
|
115
165
|
assert.equal(fs.readFileSync(file, 'utf8'), 'good');
|
|
116
166
|
});
|
|
@@ -0,0 +1,293 @@
|
|
|
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',
|
|
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');
|