dsh-lite-memory 0.17.0 → 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 +15 -15
- package/TRIGGERS.md +1 -1
- package/client.js +343 -44
- package/docs/BENCH.md +10 -8
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- 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 +403 -131
- package/learning.js +65 -6
- package/package.json +7 -4
- package/state.js +56 -8
- package/store.js +361 -33
- package/test/client.test.mjs +79 -17
- package/test/contract.test.mjs +231 -93
- package/test/control.test.mjs +2 -2
- package/test/crossing.test.mjs +22 -1
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +33 -10
- package/test/ha.test.mjs +186 -0
- 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 +80 -9
- package/test/record.test.mjs +130 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +97 -13
|
@@ -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');
|
package/test/learning.test.mjs
CHANGED
|
@@ -962,6 +962,86 @@ test('L03b 明确长期要求整理为空时也留下规则', () => {
|
|
|
962
962
|
assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
|
|
963
963
|
});
|
|
964
964
|
|
|
965
|
+
test('L03c 兜底不把没说范围的要求抬成全项目规则', () => {
|
|
966
|
+
const vague = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true } });
|
|
967
|
+
assert.equal(vague.applicability, 'unresolved');
|
|
968
|
+
const allLines = learning.pendingChangeFrom({ text: '以后所有口播都先给结论。', cue: { explicitLongTerm: true } });
|
|
969
|
+
assert.equal(allLines.applicability, 'all_in_scope', '「所有口播」按项目层全范围收集');
|
|
970
|
+
const always = learning.pendingChangeFrom({ text: '以后一律先给结论。', cue: { explicitLongTerm: true } });
|
|
971
|
+
assert.equal(always.applicability, 'all_in_scope', '「一律」按项目层全范围收集');
|
|
972
|
+
assert.deepEqual(vague.conditions, []);
|
|
973
|
+
const scoped = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true, taskKind: 'video' } });
|
|
974
|
+
assert.equal(scoped.applicability, 'conditional');
|
|
975
|
+
assert.deepEqual(scoped.conditions, [{ key: 'taskKind', value: 'video' }]);
|
|
976
|
+
const global = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
|
|
977
|
+
assert.equal(global.applicability, 'all_in_scope');
|
|
978
|
+
assert.deepEqual(global.conditions, []);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
|
|
982
|
+
const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
983
|
+
const anchor = { episodeId: 'ep_x', status: 'unbound', updatedAt: '2026-01-01T00:00:00.000Z' };
|
|
984
|
+
const narrow = learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor, projectId: 'p' });
|
|
985
|
+
assert.equal(narrow.expectedTarget.applicability, 'unresolved');
|
|
986
|
+
const wide = learning.candidateFrom({ messages: [human('m2', '以后这个项目默认用 pnpm')], anchor, projectId: 'p' });
|
|
987
|
+
assert.equal(wide.expectedTarget.applicability, 'all_in_scope');
|
|
988
|
+
|
|
989
|
+
const refs = { feedback: { u0: 'm1' }, rules: {} };
|
|
990
|
+
const base = { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论', evidence: ['u0'] };
|
|
991
|
+
const needsScope = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget });
|
|
992
|
+
assert.equal(needsScope.ok, false);
|
|
993
|
+
assert.match(needsScope.reason, /不能.*全项目/);
|
|
994
|
+
assert.equal(learning.validateChange({ ...base, applicability: 'unresolved' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget }).ok, true);
|
|
995
|
+
assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
|
|
996
|
+
|
|
997
|
+
const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
|
|
998
|
+
const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
999
|
+
assert.equal(episodeWide.ok, false, '作品规则不能抬成全项目');
|
|
1000
|
+
const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1001
|
+
assert.equal(droppedKind.ok, false, '不能删掉收集时的任务范围');
|
|
1002
|
+
const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1003
|
+
assert.equal(keptKind.ok, true, keptKind.reason);
|
|
1004
|
+
|
|
1005
|
+
const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
|
|
1006
|
+
assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
|
|
1007
|
+
assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
|
|
1011
|
+
const rules = [];
|
|
1012
|
+
for (let index = 0; index < 32; index += 1) {
|
|
1013
|
+
rules.push({
|
|
1014
|
+
id: `mem_${String(index).padStart(2, '0')}`,
|
|
1015
|
+
state: 'active',
|
|
1016
|
+
updated: `2026-01-${String((index % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
1017
|
+
title: index === 31 ? '字幕字号' : `无关规则${index}`,
|
|
1018
|
+
summary: '',
|
|
1019
|
+
body: index === 31 ? '字幕固定 32 号' : '',
|
|
1020
|
+
conditions: [],
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const selected = learning.selectRules(rules, { query: '字幕太小了,改大一点', dimensions: ['字幕'], limit: 6 });
|
|
1024
|
+
assert.equal(selected.length, 6);
|
|
1025
|
+
assert.ok(selected.some((row) => row.id === 'mem_31'), '相关但排在最后的规则要进候选');
|
|
1026
|
+
assert.equal(learning.selectRules(rules, { limit: 32 }).length, 32, '候选池上限是 32 条,不是先砍到 6 条');
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test('L03f 整理器规则先按状态和最近使用排序再取 8 条', () => {
|
|
1030
|
+
const rules = [];
|
|
1031
|
+
for (let index = 0; index < 12; index += 1) {
|
|
1032
|
+
rules.push({
|
|
1033
|
+
id: `mem_${String(index).padStart(2, '0')}`,
|
|
1034
|
+
state: index === 11 ? 'active' : index === 10 ? 'trial' : 'paused',
|
|
1035
|
+
updatedAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
1036
|
+
title: `规则${index}`,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const ordered = learning.orderOrganizerRules(rules);
|
|
1040
|
+
assert.equal(ordered.length, learning.LIMITS.organizerRules);
|
|
1041
|
+
assert.equal(ordered[0].id, 'mem_11', '生效规则优先于暂停规则');
|
|
1042
|
+
assert.equal(ordered[1].id, 'mem_10', '试用规则排在暂停之前');
|
|
1043
|
+
});
|
|
1044
|
+
|
|
965
1045
|
test('L04 先算目标状态再查配额,过期候选可恢复', () => {
|
|
966
1046
|
const base = { existing: null, evidence: { episodeId: 'e1', messageIds: ['m1'], sourceExcerpt: '以后都用参数化查询' }, pending: 12, rules: 0, now: 1000 };
|
|
967
1047
|
const active = learning.admitChange({ ...base, change: NEW_CHANGE, explicitLongTerm: true });
|
package/test/palace.test.mjs
CHANGED
|
@@ -90,8 +90,10 @@ const walk = (node, out = [], expand = false) => {
|
|
|
90
90
|
};
|
|
91
91
|
const exhibit = walk(tree, [], true).find((node) => node.type === 'button' && node.props.title === 'A');
|
|
92
92
|
assert.ok(exhibit, '展品应是原生按钮');
|
|
93
|
+
const beforeClick = JSON.stringify(tree);
|
|
93
94
|
exhibit.props.onClick();
|
|
94
95
|
assert.equal(picked, 'mem_a');
|
|
96
|
+
assert.equal(JSON.stringify(tree), beforeClick, '点开展品只换详情,不改宫殿里的记录');
|
|
95
97
|
|
|
96
98
|
// 首帧列表视图不挂载宫殿;空数据时给下一步提示。
|
|
97
99
|
const firstPaint = MemoryPanel({ sessionId: 'demo', onClose: () => {} });
|
package/test/recall.test.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
@@ -8,8 +9,9 @@ const { apply } = await import('../index.js');
|
|
|
8
9
|
const store = await import('../store.js');
|
|
9
10
|
|
|
10
11
|
const HEADER = '已有记忆可能相关';
|
|
11
|
-
const CARD_LINE = /^- \[[^\]]+\]
|
|
12
|
+
const CARD_LINE = /^- \[[^\]]+\] 「.+?」(?:|.*)?$/;
|
|
12
13
|
const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
|
|
14
|
+
const INTERNAL_ID = /mem_[a-z0-9]{6,}/;
|
|
13
15
|
|
|
14
16
|
const effect = (fn) => {
|
|
15
17
|
const dispose = fn();
|
|
@@ -84,6 +86,17 @@ const save = (scope, fields) => {
|
|
|
84
86
|
return { ...result, id: result.record?.id ?? result.id };
|
|
85
87
|
};
|
|
86
88
|
|
|
89
|
+
/** 有效期这类时间不能靠等待制造:直接改热状态,并同步重算校验和。 */
|
|
90
|
+
const patchState = (scope, id, patch) => {
|
|
91
|
+
const file = path.join(scope.dir, 'state.json');
|
|
92
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
93
|
+
const entry = raw.payload.entries.find((row) => row.id === id);
|
|
94
|
+
assert.ok(entry, `state 里没有 ${id}`);
|
|
95
|
+
Object.assign(entry, patch);
|
|
96
|
+
raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
|
|
97
|
+
fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
98
|
+
};
|
|
99
|
+
|
|
87
100
|
const cwdA = mkdir('a');
|
|
88
101
|
const scopeA = store.scopeOf(cwdA);
|
|
89
102
|
const global = store.globalScope();
|
|
@@ -277,8 +290,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
277
290
|
const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
|
|
278
291
|
assert.equal(fourth.status, 'budget_exhausted');
|
|
279
292
|
assert.equal(fourth.body || '', '');
|
|
280
|
-
assert.equal(
|
|
281
|
-
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
|
|
293
|
+
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 这条消息已停止读记忆,下一条消息再读');
|
|
282
294
|
const win = bootA.debug.windowOf(agent);
|
|
283
295
|
assert.ok(win.bytes <= 8192);
|
|
284
296
|
assert.equal(win.bodies, 3);
|
|
@@ -332,10 +344,11 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
332
344
|
const agent = makeAgent('agent-purge', cwdD);
|
|
333
345
|
const sig = signal();
|
|
334
346
|
await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
|
|
335
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
336
|
-
const purged = await bootA.exec('memory_forget', {
|
|
347
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/);
|
|
348
|
+
const purged = await bootA.exec('memory_forget', { title: '插件改完要重启应用', mode: 'purge' }, agent);
|
|
337
349
|
assert.equal(purged.mode, 'purged');
|
|
338
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
350
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/, '已提交的旧快照不回头改写');
|
|
351
|
+
assert.doesNotMatch(bootA.provider.text({ agent, signal: sig }), /mem_[a-z0-9]{6,}/, '召回卡从不带内部编号');
|
|
339
352
|
const sig2 = signal();
|
|
340
353
|
await bootA.preStep({ agent, signal: sig2, turn: 't2' }, enter(human('插件改完怎么让它生效?')));
|
|
341
354
|
const after = bootA.provider.text({ agent, signal: sig2 });
|
|
@@ -359,7 +372,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
359
372
|
const agent = makeAgent('agent-global-purge', cwdA);
|
|
360
373
|
const sig = signal();
|
|
361
374
|
await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('发布前跑文案检查')));
|
|
362
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
375
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /全局发布前文案核对/);
|
|
363
376
|
const purged = store.forget(global, globalNote.id, { mode: 'purge' });
|
|
364
377
|
assert.equal(purged.mode, 'purged');
|
|
365
378
|
/** 同一 turn 的续步没有新真人消息:不重新问人,但盘上变了必须换掉快照。 */
|
|
@@ -387,12 +400,25 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
387
400
|
if (row.status === 'budget_exhausted') exhausted = row;
|
|
388
401
|
}
|
|
389
402
|
assert.ok(exhausted, '超过额度后拒绝读正文');
|
|
390
|
-
assert.equal(exhausted.id,
|
|
403
|
+
assert.equal(exhausted.id, undefined);
|
|
391
404
|
assert.ok(win.bytes <= 8192);
|
|
392
405
|
assert.ok(win.bodies <= 3);
|
|
393
406
|
assert.ok(win.reads <= 8);
|
|
394
407
|
}
|
|
395
408
|
|
|
409
|
+
// A21:自动注入把这一轮用得差不多,模型按标题来读正文不该被连带挡掉。
|
|
410
|
+
{
|
|
411
|
+
const agent = makeAgent('agent-read-after-auto', cwdA);
|
|
412
|
+
await prepare(bootA, agent, '大正文样本怎么用');
|
|
413
|
+
const win = bootA.debug.windowOf(agent);
|
|
414
|
+
assert.ok(win, '自动召回开了额度窗口');
|
|
415
|
+
/** 模拟自动注入已经把这轮的 8192 B 用满。 */
|
|
416
|
+
win.bytes = 8192;
|
|
417
|
+
const row = await bootA.exec('memory_read', { title: '大正文样本1' }, agent);
|
|
418
|
+
assert.equal(row.status, 'ok', '自动注入用满之后仍能读正文');
|
|
419
|
+
assert.ok(Buffer.byteLength(row.body) > 900);
|
|
420
|
+
}
|
|
421
|
+
|
|
396
422
|
// A18:归档/旧正文不进正常检索;只有明确查历史才列归档。
|
|
397
423
|
{
|
|
398
424
|
const cwdE = mkdir('e');
|
|
@@ -410,7 +436,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
410
436
|
const oldBody = save(scopeE, { title: '口端口令', tags: ['端口'], summary: '端口以最新为准', body: '第一版:端口是 5000' });
|
|
411
437
|
store.upsert(scopeE, { id: oldBody.id, title: '口端口令', summary: '端口以最新为准', body: '第二版:端口是 49661' });
|
|
412
438
|
const current = await bootA.exec('memory_search', { query: '口端口令', history: true }, agent);
|
|
413
|
-
assert.ok(current.refs.some((ref) => ref.
|
|
439
|
+
assert.ok(current.refs.some((ref) => ref.title === '口端口令' && ref.revision === 2), '检索只给当前版本');
|
|
414
440
|
const latest = await bootA.exec('memory_read', { id: oldBody.id }, agent);
|
|
415
441
|
assert.match(latest.body, /第二版/);
|
|
416
442
|
assert.doesNotMatch(latest.body, /第一版/);
|
|
@@ -436,6 +462,38 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
436
462
|
assert.ok(win.bytes <= 8192);
|
|
437
463
|
}
|
|
438
464
|
|
|
465
|
+
// 生命周期退出默认召回:被取代、过期的条目不再自动注入,也不再进手动检索。
|
|
466
|
+
{
|
|
467
|
+
const runtime = boot();
|
|
468
|
+
const cwdLife = mkdir('lifecycle');
|
|
469
|
+
const scopeLife = store.scopeOf(cwdLife);
|
|
470
|
+
const oldDeploy = save(scopeLife, {
|
|
471
|
+
title: '旧部署目标', summary: '部署到 staging,旧流程。', body: '旧流程只发 staging。', resolution: 'distinct',
|
|
472
|
+
});
|
|
473
|
+
const newDeploy = save(scopeLife, {
|
|
474
|
+
title: '新部署目标', summary: '部署到 production,先跑测试。', body: '新流程先跑测试再发 production。', resolution: 'distinct',
|
|
475
|
+
supersedes: oldDeploy.id,
|
|
476
|
+
});
|
|
477
|
+
const stalePort = save(scopeLife, {
|
|
478
|
+
title: '临时调试端口', summary: '本地服务跑在 18080。', body: '旧端口已经不用了。', resolution: 'distinct',
|
|
479
|
+
});
|
|
480
|
+
patchState(scopeLife, stalePort.id, {
|
|
481
|
+
validFrom: new Date(Date.now() - 7200_000).toISOString(),
|
|
482
|
+
validUntil: new Date(Date.now() - 3600_000).toISOString(),
|
|
483
|
+
});
|
|
484
|
+
const agentLife = makeAgent('agent-lifecycle', cwdLife);
|
|
485
|
+
const lifeSignal = await prepare(runtime, agentLife, '部署目标怎么走,临时调试端口是多少');
|
|
486
|
+
const text = runtime.provider.text({ agent: agentLife, signal: lifeSignal });
|
|
487
|
+
assert.match(text, /新部署目标/);
|
|
488
|
+
assert.doesNotMatch(text, /旧部署目标|旧流程只发 staging/, '被取代的条目退出自动召回');
|
|
489
|
+
assert.doesNotMatch(text, /临时调试端口|旧端口已经不用了/, '过期的条目退出自动召回');
|
|
490
|
+
const found = await runtime.exec('memory_search', { query: '部署目标' }, agentLife);
|
|
491
|
+
assert.equal(found.refs.some((ref) => ref.title === '旧部署目标'), false);
|
|
492
|
+
assert.equal(found.refs.some((ref) => ref.title === '新部署目标'), true);
|
|
493
|
+
const port = await runtime.exec('memory_search', { query: '临时调试端口' }, agentLife);
|
|
494
|
+
assert.equal(port.refs.some((ref) => ref.title === '临时调试端口'), false, '过期条目也不进默认检索');
|
|
495
|
+
}
|
|
496
|
+
|
|
439
497
|
// A20:没有命中是正常空结果,不是额度用尽。
|
|
440
498
|
{
|
|
441
499
|
const agent = makeAgent('agent-empty-search', cwdA);
|
|
@@ -461,3 +519,16 @@ console.log('recall.test.mjs: ok');
|
|
|
461
519
|
assert.equal(links.status, 'needs_context');
|
|
462
520
|
assert.doesNotMatch(JSON.stringify(links), /发布前要检查什么/);
|
|
463
521
|
}
|
|
522
|
+
|
|
523
|
+
// A24:纯英文查询按整词判断,自动注入和手动搜索必须给同一个结论。
|
|
524
|
+
{
|
|
525
|
+
const cwdEn = mkdir('en');
|
|
526
|
+
const scopeEn = store.scopeOf(cwdEn);
|
|
527
|
+
save(scopeEn, { title: 'Digital asset catalog', summary: '图形素材目录' });
|
|
528
|
+
const agent = makeAgent('agent-en', cwdEn);
|
|
529
|
+
const runtime = boot();
|
|
530
|
+
const sig = signal();
|
|
531
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('git')));
|
|
532
|
+
assert.doesNotMatch(runtime.provider.text({ agent, signal: sig }), /Digital asset catalog/);
|
|
533
|
+
assert.equal(store.search(scopeEn, 'git', 5).length, 0, '手动搜索也不给这条');
|
|
534
|
+
}
|