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
package/test/contract.test.mjs
DELETED
|
@@ -1,1342 +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
|
-
import { EventEmitter } from 'node:events';
|
|
6
|
-
|
|
7
|
-
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-'));
|
|
8
|
-
const { apply } = await import('../index.js');
|
|
9
|
-
const store = await import('../store.js');
|
|
10
|
-
const state = await import('../state.js');
|
|
11
|
-
|
|
12
|
-
const ORIGIN = 'http://127.0.0.1:60402';
|
|
13
|
-
const defs = new Map();
|
|
14
|
-
let route = null;
|
|
15
|
-
|
|
16
|
-
const webServer = { getOrigin: () => ORIGIN, register: (entry) => { route = entry; return () => {}; } };
|
|
17
|
-
const effect = (fn) => { const dispose = fn(); return typeof dispose === 'function' ? dispose : () => {}; };
|
|
18
|
-
let currentAgent = null;
|
|
19
|
-
let llmImpl = null;
|
|
20
|
-
const sessionCwds = new Map();
|
|
21
|
-
const subscriptions = new Map();
|
|
22
|
-
const promptContexts = [];
|
|
23
|
-
const ctx = {
|
|
24
|
-
tools: { register: (def) => { defs.set(def.name, def); return () => {}; } },
|
|
25
|
-
effect,
|
|
26
|
-
inject: (deps, callback) => { if (deps.includes('webServer')) callback({ webServer, effect }); return () => {}; },
|
|
27
|
-
get: (name) => {
|
|
28
|
-
if (name === 'systemPrompt') return { context: (entry) => { promptContexts.push(entry); return () => {}; } };
|
|
29
|
-
if (name === 'agents') return { currentInitiator: () => currentAgent };
|
|
30
|
-
if (name === 'sessions') return { get: (id) => (sessionCwds.has(id) ? { header: { cwd: sessionCwds.get(id) } } : undefined) };
|
|
31
|
-
if (name === 'llm') return llmImpl || undefined;
|
|
32
|
-
return undefined;
|
|
33
|
-
},
|
|
34
|
-
on: (event, handler) => { subscriptions.set(event, handler); return () => {}; },
|
|
35
|
-
};
|
|
36
|
-
apply(ctx);
|
|
37
|
-
|
|
38
|
-
const cwd = path.join(os.tmpdir(), 'lite-memory-contract-project');
|
|
39
|
-
fs.mkdirSync(cwd, { recursive: true });
|
|
40
|
-
|
|
41
|
-
const exec = (name, args) => defs.get(name).execute(args, { agent: { cwd, session: { id: 'session-contract', header: { cwd } } } });
|
|
42
|
-
const render = (name, args, value) => defs.get(name).output.render(args, value)[0].text;
|
|
43
|
-
|
|
44
|
-
/** 按已注册的 output.schema 校验真实 execute 结果:宿主校验失败会整条工具报错。 */
|
|
45
|
-
function assertSchema(name, value) {
|
|
46
|
-
const schema = defs.get(name).output.schema;
|
|
47
|
-
const walk = (node, data, where) => {
|
|
48
|
-
if (node.type === 'object') {
|
|
49
|
-
assert.ok(data && typeof data === 'object' && !Array.isArray(data), `${where} 应为对象`);
|
|
50
|
-
for (const key of node.required || []) assert.ok(key in data, `${where} 缺少 ${key}`);
|
|
51
|
-
for (const [key, item] of Object.entries(data)) {
|
|
52
|
-
if (node.properties && node.properties[key]) walk(node.properties[key], item, `${where}.${key}`);
|
|
53
|
-
else assert.equal(node.additionalProperties, true, `${where} 多出 ${key}`);
|
|
54
|
-
}
|
|
55
|
-
} else if (node.type === 'array') {
|
|
56
|
-
assert.ok(Array.isArray(data), `${where} 应为数组`);
|
|
57
|
-
if (node.items) data.forEach((item, index) => walk(node.items, item, `${where}[${index}]`));
|
|
58
|
-
} else if (node.type === 'string') assert.equal(typeof data, 'string', `${where} 应为字符串`);
|
|
59
|
-
else if (node.type === 'boolean') assert.equal(typeof data, 'boolean', `${where} 应为布尔值`);
|
|
60
|
-
else if (node.type === 'integer') assert.ok(Number.isInteger(data), `${where} 应为整数`);
|
|
61
|
-
else if (node.type === 'number') assert.equal(typeof data, 'number', `${where} 应为数字`);
|
|
62
|
-
if (node.enum) assert.ok(node.enum.includes(data), `${where} 不在枚举里`);
|
|
63
|
-
};
|
|
64
|
-
walk(schema, value, name);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function call(pathname, payload, options = {}) {
|
|
68
|
-
const req = new EventEmitter();
|
|
69
|
-
req.method = options.method ?? 'POST';
|
|
70
|
-
req.url = pathname;
|
|
71
|
-
req.headers = { origin: options.origin ?? ORIGIN };
|
|
72
|
-
req.destroy = () => {};
|
|
73
|
-
const res = {
|
|
74
|
-
status: 0,
|
|
75
|
-
text: '',
|
|
76
|
-
writeHead(status) { this.status = status; },
|
|
77
|
-
end(body) { this.text = body; this.finish(); },
|
|
78
|
-
};
|
|
79
|
-
const done = new Promise((resolve) => { res.finish = () => resolve(res); });
|
|
80
|
-
route.handler(req, res);
|
|
81
|
-
if (req.method === 'POST') {
|
|
82
|
-
req.emit('data', Buffer.from(JSON.stringify(payload)));
|
|
83
|
-
req.emit('end');
|
|
84
|
-
}
|
|
85
|
-
return done.then((value) => ({ status: value.status, body: JSON.parse(value.text || '{}') }));
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const json = (body) => JSON.parse(body);
|
|
89
|
-
|
|
90
|
-
// ── tool contract ────────────────────────────────────────────────────────────
|
|
91
|
-
const names = ['memory_save', 'memory_suggest', 'memory_search', 'memory_read', 'memory_forget'];
|
|
92
|
-
assert.deepEqual([...defs.keys()].sort(), [...names].sort());
|
|
93
|
-
const TITLE_ADDRESSED = ['memory_read', 'memory_forget'];
|
|
94
|
-
for (const name of names) {
|
|
95
|
-
const def = defs.get(name);
|
|
96
|
-
assert.equal(def.parameters.type, 'object');
|
|
97
|
-
assert.equal(def.parameters.additionalProperties, false);
|
|
98
|
-
assert.equal(typeof def.output.render, 'function');
|
|
99
|
-
assert.equal(typeof def.output.schema, 'object');
|
|
100
|
-
assert.ok(Object.keys(def.parameters.properties).length >= 1);
|
|
101
|
-
if (TITLE_ADDRESSED.includes(name)) assert.equal('title' in def.parameters.properties, true, `${name} 按标题定位`);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const saveParams = defs.get('memory_save').parameters.properties;
|
|
105
|
-
/** 来源只能由服务端判定:参数里不存在可伪造成「用户明确要求」的字段。 */
|
|
106
|
-
for (const key of ['source', 'origin', 'confirmed', 'force', 'autoRecall']) {
|
|
107
|
-
assert.equal(key in saveParams, false, `memory_save 不该接受 ${key}`);
|
|
108
|
-
}
|
|
109
|
-
assert.equal(saveParams.mode.enum.includes('replace_current'), true);
|
|
110
|
-
/** 关联目标也按标题给,参数里不该出现 mem_ 开头的 id 字段。 */
|
|
111
|
-
assert.equal(saveParams.links.items.properties.id, undefined);
|
|
112
|
-
assert.equal(saveParams.links.items.properties.title.type, 'string');
|
|
113
|
-
assert.equal(JSON.stringify(saveParams.applicability.enum), JSON.stringify(['all_in_scope', 'conditional', 'unresolved']));
|
|
114
|
-
|
|
115
|
-
assert.deepEqual(saveParams.resolution.enum, ['distinct']);
|
|
116
|
-
/** 一次检索最多回 4 条:列表是给下一步取正文用的,不是索引。 */
|
|
117
|
-
assert.equal(defs.get('memory_search').parameters.properties.limit.maximum, 4);
|
|
118
|
-
/** 空检索结果会带 noTriggerReason,输出 schema 必须声明它,否则宿主校验直接报错。 */
|
|
119
|
-
assert.equal(defs.get('memory_search').output.schema.properties.noTriggerReason.type, 'string');
|
|
120
|
-
/** 更新句柄只暴露标题:id 是底层实现,参数表里不该出现。 */
|
|
121
|
-
assert.equal(saveParams.updateTitle.type, 'string');
|
|
122
|
-
assert.equal(saveParams.id, undefined);
|
|
123
|
-
assert.deepEqual(saveParams.scope.enum, ['auto', 'project', 'global']);
|
|
124
|
-
assert.equal(saveParams.memoryClass.type, 'string');
|
|
125
|
-
assert.deepEqual(saveParams.memoryClass.enum, ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure']);
|
|
126
|
-
assert.deepEqual(saveParams.durability.enum, ['durable', 'task']);
|
|
127
|
-
assert.deepEqual(saveParams.sameTopicAs.required, ['scope', 'title']);
|
|
128
|
-
assert.equal(defs.get('memory_suggest').parameters.properties.scope, undefined);
|
|
129
|
-
assert.equal(defs.get('memory_suggest').parameters.properties.resolution, undefined);
|
|
130
|
-
|
|
131
|
-
// ── save → render, then update ───────────────────────────────────────────────
|
|
132
|
-
const saved = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步' });
|
|
133
|
-
assert.equal(saved.needsResolution, false);
|
|
134
|
-
assert.equal(saved.merged, false);
|
|
135
|
-
assert.equal(saved.changed, true);
|
|
136
|
-
assert.equal(saved.scope, 'project');
|
|
137
|
-
// 「以后都用」这种措辞不带 scope 时落在项目层,不会自动升级成全局偏好
|
|
138
|
-
const phrased = await exec('memory_save', { title: '以后都用这个一次性脚本', summary: '这个脚本只跑一次', body: 'bash run.sh' });
|
|
139
|
-
assert.equal(phrased.scope, 'project');
|
|
140
|
-
assert.equal(store.loadIndex(store.globalScope()).entries.some((item) => item.title === '以后都用这个一次性脚本'), false);
|
|
141
|
-
|
|
142
|
-
assert.equal(typeof saved.title, 'string');
|
|
143
|
-
assert.equal(saved.id, undefined);
|
|
144
|
-
assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序"$/);
|
|
145
|
-
|
|
146
|
-
const merged = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n第二步' });
|
|
147
|
-
assert.equal(merged.merged, true);
|
|
148
|
-
assert.equal(merged.title, saved.title);
|
|
149
|
-
assert.equal(merged.revision, 2);
|
|
150
|
-
assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序"$/);
|
|
151
|
-
|
|
152
|
-
const patch = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
153
|
-
assert.equal(patch.when, '发版当天');
|
|
154
|
-
assert.match(render('memory_save', {}, patch), /(发版当天)/);
|
|
155
|
-
|
|
156
|
-
/** 同样的字段再存一次不算修改:不改版次,也不该被读成「存过一次」。 */
|
|
157
|
-
const again = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
158
|
-
assert.equal(again.changed, false);
|
|
159
|
-
assert.equal(again.title, saved.title);
|
|
160
|
-
assert.equal(again.revision, 3);
|
|
161
|
-
assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序"$/);
|
|
162
|
-
|
|
163
|
-
// ── similar title asks before overwriting ────────────────────────────────────
|
|
164
|
-
const probe = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条' });
|
|
165
|
-
assert.equal(probe.needsResolution, true);
|
|
166
|
-
assert.equal(probe.id, undefined);
|
|
167
|
-
assert.equal(probe.revision, 0);
|
|
168
|
-
assert.ok(probe.similarTitles.length > 0);
|
|
169
|
-
assert.doesNotMatch(probe.similarTitles, /mem_[a-z0-9]{6,}/, '相近候选按标题点名');
|
|
170
|
-
const probeText = render('memory_save', {}, probe);
|
|
171
|
-
assert.match(probeText.split('\n')[0], /^memory · 需要确认:「发布顺序是啥」与已有条目相近$/);
|
|
172
|
-
assert.match(probeText, /要更新它:用同一个标题再存一次/);
|
|
173
|
-
assert.match(probeText, /「发布顺序」/);
|
|
174
|
-
assert.doesNotMatch(probeText, /mem_[a-z0-9]{6,}/, '相近提示只给标题');
|
|
175
|
-
const distinct = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条', resolution: 'distinct' });
|
|
176
|
-
assert.equal(distinct.needsResolution, false);
|
|
177
|
-
assert.equal(distinct.merged, false);
|
|
178
|
-
assert.equal(distinct.id, undefined);
|
|
179
|
-
assert.notEqual(distinct.title, saved.title);
|
|
180
|
-
/** 刚存完就能查回来:不是只统计条数,命中的必须是刚存的那一条。 */
|
|
181
|
-
const justSaved = await exec('memory_search', { query: '发布顺序是啥' });
|
|
182
|
-
assert.ok(justSaved.count >= 1);
|
|
183
|
-
assert.match(render('memory_search', {}, justSaved), /「发布顺序是啥」/);
|
|
184
|
-
|
|
185
|
-
// ── search / read / forget ───────────────────────────────────────────────────
|
|
186
|
-
const hits = await exec('memory_search', { query: '发布流程' });
|
|
187
|
-
assert.ok(hits.count >= 1);
|
|
188
|
-
assert.ok(hits.chars > 0);
|
|
189
|
-
const hitText = render('memory_search', {}, hits);
|
|
190
|
-
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] 「`));
|
|
191
|
-
assert.doesNotMatch(hitText, /mem_[a-z0-9]{6,}/, '检索结果只给标题与适用范围');
|
|
192
|
-
assert.doesNotMatch(hitText, /第 .* 版/);
|
|
193
|
-
/** 用量只在真交付后才记:检索候选(尤其被预算/上限丢掉的那些)不算用过。 */
|
|
194
|
-
const contractScope = store.scopeOf(cwd);
|
|
195
|
-
await exec('memory_save', { title: '用量记账探针', summary: '只在交付后记 lastUsed', kind: 'note' });
|
|
196
|
-
const usedRow = () => store.loadIndex(contractScope).entries.find((item) => item.title === '用量记账探针');
|
|
197
|
-
const oldUse = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
198
|
-
const probeId = usedRow().id;
|
|
199
|
-
state.transact(contractScope, {}, (draft) => {
|
|
200
|
-
const row = draft.entries.find((item) => item.id === probeId);
|
|
201
|
-
row.maintenance.lastUsed = oldUse;
|
|
202
|
-
return { changed: true };
|
|
203
|
-
});
|
|
204
|
-
assert.equal(usedRow().lastUsed, oldUse);
|
|
205
|
-
const silent = store.search(contractScope, '用量记账探针', 50, { markUsed: false });
|
|
206
|
-
assert.ok(silent.length >= 1);
|
|
207
|
-
assert.equal(usedRow().lastUsed, oldUse, '候选检索不记 usage');
|
|
208
|
-
await exec('memory_search', { query: '用量记账探针' });
|
|
209
|
-
assert.notEqual(usedRow().lastUsed, oldUse, '真交付给模型后才记 usage');
|
|
210
|
-
/** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
|
|
211
|
-
const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
|
|
212
|
-
assert.equal(miss.count, 0);
|
|
213
|
-
assert.equal(typeof miss.noTriggerReason, 'string');
|
|
214
|
-
assert.equal(render('memory_search', {}, miss), 'memory · 没有命中');
|
|
215
|
-
|
|
216
|
-
const read = await exec('memory_read', { title: saved.title });
|
|
217
|
-
assert.equal(read.status, 'needs_context');
|
|
218
|
-
assert.equal(read.body, '', '条件未知时标题只能定位,不能证明适用,正文不返回');
|
|
219
|
-
assert.doesNotMatch(render('memory_read', {}, read), /发版/);
|
|
220
|
-
assertSchema('memory_read', read);
|
|
221
|
-
const plain = await exec('memory_read', { title: distinct.title });
|
|
222
|
-
/** 工具写入的来源一路带到正文回执:界面靠它标 [模型],不靠猜。 */
|
|
223
|
-
assert.equal(plain.origin, 'model_drafted');
|
|
224
|
-
assertSchema('memory_read', plain);
|
|
225
|
-
assert.equal(plain.body, '另一条');
|
|
226
|
-
assert.equal(plain.revision, 1);
|
|
227
|
-
assert.match(render('memory_read', {}, plain), /^memory · \[[^\]]+\] 「发布顺序是啥」/);
|
|
228
|
-
assert.ok(!render('memory_read', {}, plain).includes('mem_'));
|
|
229
|
-
assert.equal(plain.title, distinct.title);
|
|
230
|
-
|
|
231
|
-
/* 界面与工具文本一律不出现内部编号:id 只是存储地址。 */
|
|
232
|
-
for (const [name, value] of [['memory_save', saved], ['memory_save', merged], ['memory_save', patch],
|
|
233
|
-
['memory_search', hits], ['memory_read', plain], ['memory_save', probe]]) {
|
|
234
|
-
assert.doesNotMatch(render(name, {}, value), /\b(mem|sug|cf)_[a-z0-9]{6,}/, `${name} 文案里没有内部编号`);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// 没点明归档还是彻底忘掉时不动盘,先返回待确认
|
|
238
|
-
const undecided = await exec('memory_forget', { title: distinct.title });
|
|
239
|
-
assert.equal(undecided.mode, 'needs_resolution');
|
|
240
|
-
assert.match(render('memory_forget', {}, undecided), /^memory · 先确认是归档还是彻底忘掉 · 「发布顺序是啥」$/);
|
|
241
|
-
const distinctRow = store.search(store.scopeOf(cwd), distinct.title).find((row) => row.title === distinct.title);
|
|
242
|
-
assert.ok(distinctRow);
|
|
243
|
-
assert.ok(store.locate(store.scopeOf(cwd), distinctRow.id));
|
|
244
|
-
const archived = await exec('memory_forget', { title: distinct.title, mode: 'archive' });
|
|
245
|
-
assert.equal(archived.title, distinct.title);
|
|
246
|
-
assert.equal(archived.id, undefined);
|
|
247
|
-
assert.match(render('memory_forget', {}, archived), /^memory · archived \[[^\]]+\] "发布顺序是啥",可找回$/);
|
|
248
|
-
const readArchived = await exec('memory_read', { title: distinct.title });
|
|
249
|
-
assert.equal(readArchived.status, 'archived');
|
|
250
|
-
assert.equal(readArchived.body, '');
|
|
251
|
-
assert.match(render('memory_read', { title: distinct.title }, readArchived), /^memory · 这条记忆已归档$/);
|
|
252
|
-
assertSchema('memory_read', readArchived);
|
|
253
|
-
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
254
|
-
|
|
255
|
-
// ── 失败回执同样过输出 schema:不能再返裸 {error} 让界面渲染 undefined ──────────
|
|
256
|
-
await exec('memory_save', { title: '跨层同名', summary: '项目层这一条', body: '项目正文' });
|
|
257
|
-
/** 直接落全局层:正常保存有跨层相近拦截,同层/跨层同名只可能来自历史数据。 */
|
|
258
|
-
store.saveEntry(store.globalScope(), { title: '跨层同名', summary: '全局层另一条', body: '全局正文', writeMode: 'patch' });
|
|
259
|
-
/** 同名两层各有一条时,读哪条由 scope 决定:默认项目层,显式 global 才拿全局正文。 */
|
|
260
|
-
const readTwin = await exec('memory_read', { title: '跨层同名' });
|
|
261
|
-
assert.equal(readTwin.scope, 'project');
|
|
262
|
-
assert.equal(readTwin.body, '项目正文');
|
|
263
|
-
const readGlobalTwin = await exec('memory_read', { title: '跨层同名', scope: 'global' });
|
|
264
|
-
assert.equal(readGlobalTwin.scope, 'global');
|
|
265
|
-
assert.equal(readGlobalTwin.body, '全局正文');
|
|
266
|
-
const readProjectTwin = await exec('memory_read', { title: '跨层同名', scope: 'project' });
|
|
267
|
-
assert.equal(readProjectTwin.scope, 'project');
|
|
268
|
-
assert.equal(readProjectTwin.body, '项目正文');
|
|
269
|
-
const ambiguousSave = await exec('memory_save', { title: '别的标题', summary: '尝试改同名条目', updateTitle: '跨层同名' });
|
|
270
|
-
assert.ok(ambiguousSave.error, '跨层同名要报改哪一层');
|
|
271
|
-
assertSchema('memory_save', ambiguousSave);
|
|
272
|
-
assert.match(render('memory_save', {}, ambiguousSave), /^memory · /);
|
|
273
|
-
assert.doesNotMatch(render('memory_save', {}, ambiguousSave), /undefined|saved|merged/);
|
|
274
|
-
/** 同层同名不是「取第一条」:读、忘、更新都必须报歧义,按 id 仍能精确到条。 */
|
|
275
|
-
const twinScope = store.scopeOf(cwd);
|
|
276
|
-
const twinA = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第一条', body: '正文甲', when: '机器甲', writeMode: 'patch' });
|
|
277
|
-
const twinB = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第二条', body: '正文乙', when: '机器乙', writeMode: 'patch' });
|
|
278
|
-
const twinAId = twinA.record?.id ?? twinA.id;
|
|
279
|
-
const twinBId = twinB.record?.id ?? twinB.id;
|
|
280
|
-
assert.notEqual(twinAId, twinBId);
|
|
281
|
-
await assert.rejects(() => exec('memory_read', { title: '同层重名探针' }), /有 2 条同名/);
|
|
282
|
-
await assert.rejects(() => exec('memory_forget', { title: '同层重名探针', mode: 'archive' }), /有 2 条同名/);
|
|
283
|
-
const twinUpdate = await exec('memory_save', { title: '换个标题', summary: '尝试更新同名条目', updateTitle: '同层重名探针' });
|
|
284
|
-
assert.ok(twinUpdate.error, '同层同名更新要报歧义');
|
|
285
|
-
assert.match(twinUpdate.error, /有 2 条同名/);
|
|
286
|
-
assertSchema('memory_save', twinUpdate);
|
|
287
|
-
assert.equal(store.readEntry(twinScope, twinAId).body, '正文甲', '按 id 读不能被同名条目串走');
|
|
288
|
-
assert.equal(store.readEntry(twinScope, twinBId).body, '正文乙');
|
|
289
|
-
|
|
290
|
-
/** 上下文里常只剩缩短的标题:精确匹配失败时要把完整标题就地报出来,不让模型再猜一轮。 */
|
|
291
|
-
store.saveEntry(twinScope, { title: '生效验证顺序(含:桌面端没有 Cmd+R)', summary: '缩短标题的探针', body: '正文', writeMode: 'patch' });
|
|
292
|
-
await assert.rejects(
|
|
293
|
-
() => exec('memory_read', { title: '生效验证顺序' }),
|
|
294
|
-
/相近的里有:「生效验证顺序(含:桌面端没有 Cmd\+R)」/,
|
|
295
|
-
);
|
|
296
|
-
const hintedRead = await exec('memory_read', { title: '生效验证顺序(含:桌面端没有 Cmd+R)' });
|
|
297
|
-
assert.equal(hintedRead.body, '正文');
|
|
298
|
-
const hintedUpdate = await exec('memory_save', { title: '缩短标题的探针', summary: '改正文', body: '新正文', updateTitle: '生效验证顺序' });
|
|
299
|
-
assert.ok(hintedUpdate.error);
|
|
300
|
-
assert.match(hintedUpdate.error, /相近的里有:「生效验证顺序(含:桌面端没有 Cmd\+R)」/);
|
|
301
|
-
assert.doesNotMatch(hintedUpdate.error, /undefined/);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
/** 重命名后仍按原 id 定位到同一条;归档后重建同名条目也不能冒领旧 id。 */
|
|
305
|
-
const renameSaved = store.saveEntry(twinScope, { title: '改名前的探针', summary: '旧身份', body: '旧正文', writeMode: 'patch' });
|
|
306
|
-
const renameSeedId = renameSaved.record?.id ?? renameSaved.id;
|
|
307
|
-
const renamed = await exec('memory_save', { title: '改名后的探针', summary: '旧身份', updateTitle: '改名前的探针' });
|
|
308
|
-
assert.equal(renamed.changed, true);
|
|
309
|
-
assert.equal(store.locateByTitle(twinScope, '改名后的探针').record.id, renameSeedId, '改名必须落在同一条记录上');
|
|
310
|
-
const oldIdRead = store.readEntry(twinScope, renameSeedId);
|
|
311
|
-
assert.equal(oldIdRead.title, '改名后的探针');
|
|
312
|
-
assert.equal(oldIdRead.body, '旧正文');
|
|
313
|
-
await exec('memory_forget', { id: renameSeedId, mode: 'archive' });
|
|
314
|
-
const recreated = await exec('memory_save', { title: '改名后的探针', summary: '新身份', body: '新正文' });
|
|
315
|
-
const recreatedId = store.locateByTitle(twinScope, '改名后的探针').record.id;
|
|
316
|
-
assert.notEqual(recreatedId, renameSeedId, '删掉再建是新的身份');
|
|
317
|
-
const staleRead = await exec('memory_read', { id: renameSeedId });
|
|
318
|
-
assert.equal(staleRead.status, 'archived', '旧 id 只能读到旧条目的归档状态');
|
|
319
|
-
assert.equal(staleRead.body, '');
|
|
320
|
-
const freshRead = await exec('memory_read', { id: recreatedId });
|
|
321
|
-
assert.equal(freshRead.body, '新正文');
|
|
322
|
-
|
|
323
|
-
const missingSave = await exec('memory_save', { title: '别的标题', summary: '没有这条', updateTitle: '根本不存在的条目' });
|
|
324
|
-
assert.ok(missingSave.error);
|
|
325
|
-
assertSchema('memory_save', missingSave);
|
|
326
|
-
const badLink = await exec('memory_save', { title: '关联写法不对', summary: '关联要写标题', links: [{}] });
|
|
327
|
-
assert.ok(badLink.error);
|
|
328
|
-
assertSchema('memory_save', badLink);
|
|
329
|
-
const missingLink = await exec('memory_save', { title: '关联目标不存在', summary: '目标没有落盘', links: [{ type: 'related', title: '根本没这条' }] });
|
|
330
|
-
assert.ok(missingLink.error);
|
|
331
|
-
assertSchema('memory_save', missingLink);
|
|
332
|
-
assert.match(render('memory_save', {}, missingLink), /^memory · 没有「根本没这条」这条记忆/);
|
|
333
|
-
|
|
334
|
-
// 成功回执也必须过同一份 schema
|
|
335
|
-
for (const value of [saved, merged, patch, probe, distinct]) assertSchema('memory_save', value);
|
|
336
|
-
|
|
337
|
-
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
338
|
-
const suggested = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
|
|
339
|
-
assert.equal(suggested.asked, true);
|
|
340
|
-
assert.match(suggested.id, /^sug_[a-z0-9]+$/);
|
|
341
|
-
assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查"$/);
|
|
342
|
-
|
|
343
|
-
const duplicate = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
|
|
344
|
-
assert.equal(duplicate.asked, false);
|
|
345
|
-
assert.match(render('memory_suggest', {}, duplicate), /^memory · skipped\n48 小时内已经建议过一条$/);
|
|
346
|
-
|
|
347
|
-
const kept = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract', cwd });
|
|
348
|
-
assert.equal(kept.status, 200);
|
|
349
|
-
assert.equal(kept.body.ok, true);
|
|
350
|
-
assert.equal(kept.body.mode, 'created');
|
|
351
|
-
const stored = store.search(store.scopeOf(cwd), '发布前跑文案检查')[0];
|
|
352
|
-
assert.equal(stored.when, '发布前');
|
|
353
|
-
assert.equal(store.readEntry(store.scopeOf(cwd), stored.id).body, 'node ui-copy-lint.mjs');
|
|
354
|
-
|
|
355
|
-
const replay = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract', cwd });
|
|
356
|
-
assert.equal(replay.status, 200);
|
|
357
|
-
assert.equal(replay.body.replay, true);
|
|
358
|
-
assert.equal(replay.body.id, kept.body.id);
|
|
359
|
-
const reused = await call('/lite-memory/keep', { op: 'keep', candidateId: suggested.id, operationId: 'op_contract_other', cwd });
|
|
360
|
-
assert.equal(reused.status, 404);
|
|
361
|
-
assert.equal(reused.body.ok, false);
|
|
362
|
-
|
|
363
|
-
const scope = store.scopeOf(cwd);
|
|
364
|
-
const reset = store.loadIndex(scope);
|
|
365
|
-
reset.lastSuggestedAt = '';
|
|
366
|
-
store.saveIndex(scope, reset);
|
|
367
|
-
const another = await exec('memory_suggest', { title: '另一条待确认建议', summary: '用来测试拒绝的候选' });
|
|
368
|
-
assert.equal(another.asked, true);
|
|
369
|
-
const rejected = await call('/lite-memory/reject', { op: 'reject', candidateId: another.id, cwd });
|
|
370
|
-
assert.equal(rejected.status, 200);
|
|
371
|
-
assert.equal(rejected.body.ok, true);
|
|
372
|
-
|
|
373
|
-
// ── undo by id; old title-based payloads are refused ─────────────────────────
|
|
374
|
-
const undoRevision = store.readEntry(store.scopeOf(cwd), kept.body.id).revision;
|
|
375
|
-
assert.equal((await call('/lite-memory/undo', { op: 'undo', id: kept.body.id, cwd })).status, 400);
|
|
376
|
-
const undone = await call('/lite-memory/undo', { op: 'undo', id: kept.body.id, revision: undoRevision, cwd });
|
|
377
|
-
assert.equal(undone.status, 200);
|
|
378
|
-
assert.equal(undone.body.mode, 'archived');
|
|
379
|
-
assert.equal(store.loadIndex(store.scopeOf(cwd)).entries.some((entry) => entry.id === kept.body.id), false);
|
|
380
|
-
const byTitle = await call('/lite-memory/undo', { op: 'undo', title: '发布顺序', cwd });
|
|
381
|
-
assert.equal(byTitle.status, 400);
|
|
382
|
-
const missing = await call('/lite-memory/undo', { op: 'undo', id: 'mem_000000000000', cwd });
|
|
383
|
-
assert.equal(missing.status, 404);
|
|
384
|
-
|
|
385
|
-
// ── route guards ─────────────────────────────────────────────────────────────
|
|
386
|
-
assert.equal((await call('/lite-memory/keep', { candidateId: 'sug_000000', cwd }, { origin: 'http://evil.example' })).status, 403);
|
|
387
|
-
assert.equal((await call('/lite-memory/keep', { candidateId: 'sug_000000' })).status, 400);
|
|
388
|
-
assert.equal((await call('/lite-memory/keep', { cwd })).status, 400);
|
|
389
|
-
assert.equal((await call('/lite-memory/undo', { cwd })).status, 400);
|
|
390
|
-
assert.equal((await call('/lite-memory/unknown', { cwd })).status, 404);
|
|
391
|
-
assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 405);
|
|
392
|
-
|
|
393
|
-
// ── global layer ─────────────────────────────────────────────────────────────
|
|
394
|
-
const proposedGlobal = await exec('memory_save', { title: '回复语言模型提案', summary: '模型建议回复语言用中文', scope: 'global' });
|
|
395
|
-
assert.equal(proposedGlobal.scope, 'project', '模型自填 scope 只是提案,默认落项目层');
|
|
396
|
-
assert.match(proposedGlobal.scopeNote, /模型归类只是提案/, '降级要能解释');
|
|
397
|
-
const global = store.saveEntry(store.globalScope(), {
|
|
398
|
-
title: '回复用中文', summary: '用户要求回复用中文', body: '回复默认用中文', scopeConfirmed: true,
|
|
399
|
-
});
|
|
400
|
-
assert.ok(global.record, '人明确要全局时才落全局层');
|
|
401
|
-
assert.equal(global.chosenScope, 'global');
|
|
402
|
-
assert.equal(exec === null, false);
|
|
403
|
-
|
|
404
|
-
// 显式写 global 也不再被悄无声息地改层:本机痕迹和项目事实各有一条能解释的降级。
|
|
405
|
-
const machineGlobal = await exec('memory_save', {
|
|
406
|
-
title: '本机目录同步步骤',
|
|
407
|
-
summary: '流程里写了本机路径,写全局也会留在项目层',
|
|
408
|
-
body: '在 /Users/apple/Desktop/DSH 下改完再同步。',
|
|
409
|
-
scope: 'global',
|
|
410
|
-
memoryClass: 'portable_procedure',
|
|
411
|
-
});
|
|
412
|
-
assert.equal(machineGlobal.scope, 'project');
|
|
413
|
-
assert.match(machineGlobal.scopeNote, /按本机痕迹留在项目层/);
|
|
414
|
-
const factGlobal = await exec('memory_save', {
|
|
415
|
-
title: '发版顺序只在仓库成立',
|
|
416
|
-
summary: '项目事实换项目不成立,写全局也会留在项目层',
|
|
417
|
-
body: '这个仓库的发版顺序是先前置检查再打包。',
|
|
418
|
-
scope: 'global',
|
|
419
|
-
memoryClass: 'project_fact',
|
|
420
|
-
});
|
|
421
|
-
assert.equal(factGlobal.scope, 'project');
|
|
422
|
-
assert.match(factGlobal.scopeNote, /按项目事实留在项目层/);
|
|
423
|
-
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
424
|
-
assert.ok(globalSearch.count >= 1, '全局层条目能被搜到');
|
|
425
|
-
assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
|
|
426
|
-
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
427
|
-
const stats = globalThis.__liteMemoryStats(cwd);
|
|
428
|
-
assert.ok(stats.count >= 1);
|
|
429
|
-
|
|
430
|
-
// ── 引用条:会话读过哪几条,点开能拿到正文 ───────────────────────────────────
|
|
431
|
-
const cited = await exec('memory_save', { title: '引用测试', summary: '底部引用条要能点开正文', body: '引用正文第一行' });
|
|
432
|
-
await exec('memory_read', { title: cited.title });
|
|
433
|
-
const citedRow = store.search(store.scopeOf(cwd), '引用测试').find((row) => row.title === '引用测试');
|
|
434
|
-
assert.ok(citedRow);
|
|
435
|
-
const cites = await call('/lite-memory/citations', { sessionId: 'session-contract' });
|
|
436
|
-
assert.equal(cites.status, 200);
|
|
437
|
-
assert.ok(cites.body.items.some((item) => item.id === citedRow.id && item.title === '引用测试'));
|
|
438
|
-
assert.equal(cites.body.items.some((item) => 'path' in item), false);
|
|
439
|
-
const opened = await call('/lite-memory/body', { sessionId: 'session-contract', id: citedRow.id });
|
|
440
|
-
assert.equal(opened.status, 200);
|
|
441
|
-
assert.match(opened.body.body, /引用正文第一行/);
|
|
442
|
-
/** 界面只有标题:按标题也要能点开,不能因为引用行不存 title 就报已删除。 */
|
|
443
|
-
const openedByTitle = await call('/lite-memory/body', { sessionId: 'session-contract', title: '引用测试', revision: opened.body.revision });
|
|
444
|
-
assert.equal(openedByTitle.status, 200);
|
|
445
|
-
assert.equal(openedByTitle.body.status, 'active');
|
|
446
|
-
assert.match(openedByTitle.body.body, /引用正文第一行/);
|
|
447
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-contract', title: '没有这条' })).body.status, 'gone');
|
|
448
|
-
assert.equal(opened.body.scopeLabel, path.basename(cwd));
|
|
449
|
-
const gone = await call('/lite-memory/body', { sessionId: 'session-contract', id: 'mem_999999999999' });
|
|
450
|
-
assert.equal(gone.status, 200);
|
|
451
|
-
assert.equal(gone.body.status, 'gone');
|
|
452
|
-
assert.equal('title' in gone.body, false);
|
|
453
|
-
/** 别的会话、别的工作目录都不能拿 id 探正文;本会话引用过或本目录这一层才行。 */
|
|
454
|
-
const foreignSession = await call('/lite-memory/body', { sessionId: 'session-contract-2', id: citedRow.id });
|
|
455
|
-
assert.equal(foreignSession.status, 200);
|
|
456
|
-
assert.equal(foreignSession.body.status, 'gone');
|
|
457
|
-
assert.equal('body' in foreignSession.body, false);
|
|
458
|
-
const viaCwd = await call('/lite-memory/body', { cwd, id: citedRow.id });
|
|
459
|
-
assert.equal(viaCwd.status, 200);
|
|
460
|
-
assert.match(viaCwd.body.body, /引用正文第一行/);
|
|
461
|
-
const foreignCwd = await call('/lite-memory/body', { cwd: path.join(os.tmpdir(), 'lite-memory-contract-foreign'), id: citedRow.id });
|
|
462
|
-
assert.equal(foreignCwd.body.status, 'gone');
|
|
463
|
-
assert.equal((await call('/lite-memory/body', { id: citedRow.id })).status, 400);
|
|
464
|
-
assert.equal((await call('/lite-memory/citations', {})).status, 400);
|
|
465
|
-
assert.equal((await call('/lite-memory/citations', {}, { origin: 'http://evil.example' })).status, 403);
|
|
466
|
-
assert.notEqual((await call('/lite-memory/citations', { sessionId: 'session-contract' }, { origin: 'dsh-app://app' })).status, 403);
|
|
467
|
-
assert.notEqual((await call('/lite-memory/keep', { candidateId: 'sug_000000', cwd }, { origin: 'dsh-app://app' })).status, 403);
|
|
468
|
-
assert.equal((await call('/lite-memory/citations', {}, { method: 'GET' })).status, 405);
|
|
469
|
-
|
|
470
|
-
// ── 项目实验开关:每个项目各自一份,默认关闭,切换不碰别的项目 ─────────────────
|
|
471
|
-
const evoCwd = path.join(os.tmpdir(), 'lite-memory-contract-evolution');
|
|
472
|
-
const evoOther = path.join(os.tmpdir(), 'lite-memory-contract-evolution-other');
|
|
473
|
-
fs.mkdirSync(evoCwd, { recursive: true });
|
|
474
|
-
fs.mkdirSync(evoOther, { recursive: true });
|
|
475
|
-
sessionCwds.set('session-evo', evoCwd);
|
|
476
|
-
sessionCwds.set('session-evo-other', evoOther);
|
|
477
|
-
const evoOff = await call('/lite-memory/evolution', { sessionId: 'session-evo' });
|
|
478
|
-
assert.equal(evoOff.status, 200);
|
|
479
|
-
assert.equal(evoOff.body.ok, true);
|
|
480
|
-
assert.equal(evoOff.body.enabled, false);
|
|
481
|
-
assert.equal(evoOff.body.compatible, true);
|
|
482
|
-
assert.equal(evoOff.body.project, path.basename(evoCwd));
|
|
483
|
-
const evoOn = await call('/lite-memory/evolution', { sessionId: 'session-evo', enabled: true, operationId: 'op_evo_on' });
|
|
484
|
-
assert.equal(evoOn.status, 200);
|
|
485
|
-
assert.equal(evoOn.body.enabled, true);
|
|
486
|
-
assert.equal(evoOn.body.changed, true);
|
|
487
|
-
assert.notEqual(evoOn.body.epoch, evoOff.body.epoch);
|
|
488
|
-
const evoStatus = await call('/lite-memory/evolution', { sessionId: 'session-evo' });
|
|
489
|
-
assert.equal(evoStatus.body.enabled, true);
|
|
490
|
-
const evoSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-evo', learningAction: 'snapshot' });
|
|
491
|
-
assert.equal(evoSnapshot.status, 200);
|
|
492
|
-
assert.equal(Array.isArray(evoSnapshot.body.rules), true);
|
|
493
|
-
assert.equal(typeof evoSnapshot.body.counts.active, 'number');
|
|
494
|
-
assert.equal(evoSnapshot.body.payload.bytes, null);
|
|
495
|
-
assert.equal(evoSnapshot.body.economy.inputTokens, null);
|
|
496
|
-
const evoOtherStatus = await call('/lite-memory/evolution', { sessionId: 'session-evo-other' });
|
|
497
|
-
assert.equal(evoOtherStatus.body.enabled, false);
|
|
498
|
-
const evoStateFile = path.join(store.scopeOf(evoCwd).dir, 'state.json');
|
|
499
|
-
const evoDisk = JSON.parse(fs.readFileSync(evoStateFile, 'utf8'));
|
|
500
|
-
assert.equal(evoDisk.format, 'dsh-lite-memory/state@4');
|
|
501
|
-
assert.equal(evoDisk.payload.experimental.projectEvolution.enabled, true);
|
|
502
|
-
const evoOffAgain = await call('/lite-memory/evolution', { sessionId: 'session-evo', enabled: false });
|
|
503
|
-
assert.equal(evoOffAgain.body.enabled, false);
|
|
504
|
-
assert.equal(evoOffAgain.body.compatible, true);
|
|
505
|
-
assert.equal((await call('/lite-memory/evolution', {})).status, 400);
|
|
506
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo' }, { origin: 'http://evil.example' })).status, 403);
|
|
507
|
-
// 身份拿不准时拒绝写,也不拿进程 cwd 顶替
|
|
508
|
-
const evoUnknown = path.join(os.tmpdir(), 'lite-memory-contract-evolution-unknown');
|
|
509
|
-
fs.mkdirSync(evoUnknown, { recursive: true });
|
|
510
|
-
const savedPath = process.env.PATH;
|
|
511
|
-
process.env.PATH = '';
|
|
512
|
-
sessionCwds.set('session-evo-unknown', evoUnknown);
|
|
513
|
-
const evoUnconfirmed = await call('/lite-memory/evolution', { sessionId: 'session-evo-unknown', enabled: true });
|
|
514
|
-
process.env.PATH = savedPath;
|
|
515
|
-
assert.equal(evoUnconfirmed.status, 409);
|
|
516
|
-
assert.equal(evoUnconfirmed.body.confirmed, false);
|
|
517
|
-
assert.equal(fs.existsSync(path.join(store.scopeOf(evoUnknown).dir, 'state.json')), false);
|
|
518
|
-
|
|
519
|
-
// ── 自动召回:只在这句话跟已有记忆有关时出现,且不改命中计数 ─────────────────
|
|
520
|
-
const hinted = await exec('memory_save', { title: 'Chrome 弹钥匙串窗口要带 mock keychain', summary: '临时 profile 起 Chrome 会弹钥匙串窗口' });
|
|
521
|
-
const hintedRow = store.search(store.scopeOf(cwd), 'mock keychain').find((row) => row.title === hinted.title);
|
|
522
|
-
assert.ok(hintedRow);
|
|
523
|
-
// 只有一句 when、没有结构化条件的条目属于未解决,不自动注入
|
|
524
|
-
await exec('memory_save', { title: '发布窗口在周四晚上', summary: '只在周四晚上发版', when: '周四晚上' });
|
|
525
|
-
const recallAgent = { id: 'session-contract', cwd, session: { id: 'session-recall', header: { cwd } } };
|
|
526
|
-
currentAgent = recallAgent;
|
|
527
|
-
const preStep = subscriptions.get('agent/pre-step');
|
|
528
|
-
assert.equal(typeof preStep, 'function');
|
|
529
|
-
const pass = (text, id) => async () => ({
|
|
530
|
-
kind: 'enter',
|
|
531
|
-
messages: [{ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text }] }],
|
|
532
|
-
});
|
|
533
|
-
const recall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
534
|
-
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
535
|
-
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
536
|
-
assert.ok(recall, 'recall context registered');
|
|
537
|
-
assert.equal(recall.order, 60);
|
|
538
|
-
assert.equal(recall.interpolate, false);
|
|
539
|
-
assert.equal(recall.required, false);
|
|
540
|
-
|
|
541
|
-
const sig1 = new AbortController().signal;
|
|
542
|
-
const hitsBefore = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
543
|
-
await preStep({ agent: recallAgent, signal: sig1, turn: '2' }, pass('又弹钥匙串窗口了,怎么弄', 'h1'));
|
|
544
|
-
const hintText = recall.text({ agent: recallAgent, signal: sig1 });
|
|
545
|
-
assert.equal(store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits, hitsBefore, '注入正文本身是纯读,不写 hits');
|
|
546
|
-
assert.doesNotMatch(hintText, /mem_[a-z0-9]{6,}/, '召回卡只给标题,不给内部编号');
|
|
547
|
-
assert.match(hintText, /需要细节用 memory_read/);
|
|
548
|
-
sessionCwds.set('session-recall', cwd);
|
|
549
|
-
const recallReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
550
|
-
assert.equal(recallReceipt.status, 200);
|
|
551
|
-
assert.equal(recallReceipt.body.current.recall.reason, 'hit', '面板能回答这次为什么提醒');
|
|
552
|
-
assert.equal(recallReceipt.body.current.recall.limit, 3);
|
|
553
|
-
assert.equal(recallReceipt.body.current.recall.cards, 1);
|
|
554
|
-
assert.ok(recallReceipt.body.current.recall.chars > 0);
|
|
555
|
-
assert.ok(!/mem_[a-z0-9]{6,}/.test(JSON.stringify(recallReceipt.body.current.recall)), '判断依据不带内部编号');
|
|
556
|
-
const missTurn = await preStep({ agent: recallAgent, signal: sig1, turn: '3' }, pass('把这段视频的字幕导出来', 'h2'));
|
|
557
|
-
assert.equal(missTurn.kind, 'enter');
|
|
558
|
-
assert.equal(recall.text({ agent: recallAgent, signal: sig1 }), '');
|
|
559
|
-
const missReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
560
|
-
assert.ok(['no_overlap', 'needs_context'].includes(missReceipt.body.current.recall.reason), '没提醒时也说明依据');
|
|
561
|
-
assert.equal(missReceipt.body.current.recall.cards, 0);
|
|
562
|
-
const hitsAfter = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
563
|
-
assert.equal(hitsAfter, hitsBefore + 1, '交付过的卡片在下一轮开头记一次使用');
|
|
564
|
-
|
|
565
|
-
const sig2 = new AbortController().signal;
|
|
566
|
-
await preStep({ agent: recallAgent, signal: sig2, turn: '3' }, pass('周四晚上发版要注意什么', 'h2'));
|
|
567
|
-
assert.equal(recall.text({ agent: recallAgent, signal: sig2 }).includes('发布窗口在周四晚上'), false);
|
|
568
|
-
const waitingReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
569
|
-
assert.equal(waitingReceipt.body.current.recall.reason, 'needs_context', '有候选但条件未确认时,不误报成没有匹配');
|
|
570
|
-
assert.equal(waitingReceipt.body.current.recall.cards, 0);
|
|
571
|
-
|
|
572
|
-
// 同一主题的两条现行规范互相冲突:整组不进自动召回,但面板要说是「相关但没确认」,不是「没有匹配」
|
|
573
|
-
await exec('memory_save', { title: '发版前检查清单', summary: '发版前先跑一遍检查清单', topicKey: 'release-checklist' });
|
|
574
|
-
await exec('memory_save', { title: '发版前通知渠道', summary: '发版前在群里通知相关人', topicKey: 'release-checklist', resolution: 'distinct' });
|
|
575
|
-
await preStep({ agent: recallAgent, signal: sig2, turn: '4' }, pass('发版前要做哪些检查', 'h4'));
|
|
576
|
-
const conflictReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
577
|
-
assert.equal(conflictReceipt.body.current.recall.reason, 'needs_context', '冲突未解决时也不能报成没有匹配');
|
|
578
|
-
assert.equal(conflictReceipt.body.current.recall.cards, 0);
|
|
579
|
-
assert.ok(conflictReceipt.body.current.recall.matched >= 1);
|
|
580
|
-
|
|
581
|
-
const sig3 = new AbortController().signal;
|
|
582
|
-
await preStep({ agent: recallAgent, signal: sig3, turn: '4' }, pass('把这个按钮的圆角改小一点', 'h3'));
|
|
583
|
-
assert.equal(recall.text({ agent: recallAgent, signal: sig3 }), '');
|
|
584
|
-
assert.equal(subscriptions.has('agent/disposed'), true);
|
|
585
|
-
|
|
586
|
-
// ── 项目实验:宿主事件顺序下由最终人类消息决定读取 ───────────────────────────
|
|
587
|
-
const evoReadCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-evo-read-'));
|
|
588
|
-
sessionCwds.set('session-evo-read', evoReadCwd);
|
|
589
|
-
const evoReadOn = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', enabled: true });
|
|
590
|
-
assert.equal(evoReadOn.status, 200);
|
|
591
|
-
assert.equal(evoReadOn.body.enabled, true);
|
|
592
|
-
const evoReadScope = store.scopeOf(evoReadCwd);
|
|
593
|
-
const evoAgentSeed = { id: 'session-evo-read', cwd: evoReadCwd, session: { id: 'session-evo-read', header: { cwd: evoReadCwd } } };
|
|
594
|
-
const evoRule = await defs.get('memory_save').execute(
|
|
595
|
-
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
596
|
-
{ agent: evoAgentSeed },
|
|
597
|
-
);
|
|
598
|
-
const evoRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === evoRule.title);
|
|
599
|
-
assert.ok(evoRow, '口播开头落在这一层');
|
|
600
|
-
state.transact(evoReadScope, {}, (draft) => {
|
|
601
|
-
const row = draft.entries.find((entry) => entry.id === evoRow.id);
|
|
602
|
-
row.origin = 'project_evolution';
|
|
603
|
-
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
604
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution', specDigest: 'seeded' } };
|
|
605
|
-
return { changed: true };
|
|
606
|
-
});
|
|
607
|
-
await store.setTaskDefault(evoReadScope, { seriesId: '口播' });
|
|
608
|
-
|
|
609
|
-
const evoAgent = { id: 'session-evo-read', cwd: evoReadCwd, session: { id: 'session-evo-read', header: { cwd: evoReadCwd } } };
|
|
610
|
-
currentAgent = evoAgent;
|
|
611
|
-
const evoSig = new AbortController().signal;
|
|
612
|
-
const evoEnter = async () => ({
|
|
613
|
-
kind: 'enter',
|
|
614
|
-
messages: [
|
|
615
|
-
// 宿主可能先给出被拒/草稿消息,只有最终收下的 user 消息算新的人类输入
|
|
616
|
-
{ role: 'user', id: 'draft-1', source: { kind: 'synthetic' }, content: [{ type: 'text', text: '改做 README 文案' }] },
|
|
617
|
-
{ role: 'user', id: 'final-1', source: { kind: 'user' }, content: [{ type: 'text', text: '这期口播怎么开头' }] },
|
|
618
|
-
],
|
|
619
|
-
});
|
|
620
|
-
await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, evoEnter);
|
|
621
|
-
const evoRecall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
622
|
-
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
623
|
-
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
624
|
-
assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
|
|
625
|
-
assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }).includes('README'), false);
|
|
626
|
-
|
|
627
|
-
// 工具续步没有新人类消息:沿用本批快照,交给 refreshContext 的是同一份内容
|
|
628
|
-
await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, async () => ({ kind: 'enter', messages: [] }));
|
|
629
|
-
assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
|
|
630
|
-
|
|
631
|
-
// 事件确实触发:本回合停止后快照立即失效,下一批必须重新算
|
|
632
|
-
const stopping = subscriptions.get('agent/turn-stopping');
|
|
633
|
-
assert.equal(typeof stopping, 'function');
|
|
634
|
-
stopping({ turn: '21', signal: evoSig });
|
|
635
|
-
assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }), '');
|
|
636
|
-
|
|
637
|
-
// 管理面:读当前项目的成果和上次载荷,删除按看到的版本
|
|
638
|
-
const evoManage = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
639
|
-
assert.equal(evoManage.status, 200);
|
|
640
|
-
assert.equal(evoManage.body.rules.length, 1);
|
|
641
|
-
assert.equal(evoManage.body.rules[0].id, evoRow.id);
|
|
642
|
-
assert.ok(evoManage.body.payload.bytes > 0);
|
|
643
|
-
const evoDeleteStale = await call('/lite-memory/evolution', {
|
|
644
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision - 1,
|
|
645
|
-
});
|
|
646
|
-
assert.equal(evoDeleteStale.status, 409);
|
|
647
|
-
const evoDelete = await call('/lite-memory/evolution', {
|
|
648
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision,
|
|
649
|
-
});
|
|
650
|
-
assert.equal(evoDelete.status, 200);
|
|
651
|
-
assert.equal(evoDelete.body.ok, true);
|
|
652
|
-
|
|
653
|
-
// 管理面成功路径:候选可修改、采纳、暂停、恢复、放弃,每次返回后能继续操作
|
|
654
|
-
const manageSeed = await defs.get('memory_save').execute(
|
|
655
|
-
{ title: '口播收尾', summary: '口播收尾留一句行动', body: '结尾给一句具体行动。' },
|
|
656
|
-
{ agent: evoAgent },
|
|
657
|
-
);
|
|
658
|
-
const manageRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === manageSeed.title);
|
|
659
|
-
assert.ok(manageRow, '口播收尾落在这一层');
|
|
660
|
-
state.transact(evoReadScope, {}, (draft) => {
|
|
661
|
-
const row = draft.entries.find((entry) => entry.id === manageRow.id);
|
|
662
|
-
row.origin = 'project_evolution';
|
|
663
|
-
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
664
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution', specDigest: 'seeded' } };
|
|
665
|
-
return { changed: true };
|
|
666
|
-
});
|
|
667
|
-
const manageSnap = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
668
|
-
const manageRule = manageSnap.body.rules.find((rule) => rule.id === manageRow.id);
|
|
669
|
-
assert.ok(manageRule);
|
|
670
|
-
assert.equal(manageRule.state, 'pending');
|
|
671
|
-
const manageEdit = await call('/lite-memory/evolution', {
|
|
672
|
-
sessionId: 'session-evo-read', learningAction: 'edit', id: manageRow.id,
|
|
673
|
-
expectedRevision: manageRule.revision, body: '结尾给一句具体动作。',
|
|
674
|
-
});
|
|
675
|
-
assert.equal(manageEdit.status, 200);
|
|
676
|
-
assert.equal(manageEdit.body.ok, true);
|
|
677
|
-
const afterManageEdit = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id);
|
|
678
|
-
assert.equal(afterManageEdit.state, 'pending');
|
|
679
|
-
assert.equal(afterManageEdit.body, '结尾给一句具体动作。');
|
|
680
|
-
const manageAdopt = await call('/lite-memory/evolution', {
|
|
681
|
-
sessionId: 'session-evo-read', learningAction: 'adopt', id: manageRow.id, expectedRevision: afterManageEdit.revision,
|
|
682
|
-
});
|
|
683
|
-
assert.equal(manageAdopt.status, 200);
|
|
684
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id).state, 'active');
|
|
685
|
-
const managePause = await call('/lite-memory/evolution', {
|
|
686
|
-
sessionId: 'session-evo-read', learningAction: 'pause', ids: [manageRow.id],
|
|
687
|
-
});
|
|
688
|
-
assert.equal(managePause.status, 200);
|
|
689
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id).state, 'paused');
|
|
690
|
-
const manageResume = await call('/lite-memory/evolution', {
|
|
691
|
-
sessionId: 'session-evo-read', learningAction: 'resume', id: manageRow.id,
|
|
692
|
-
});
|
|
693
|
-
assert.equal(manageResume.status, 200);
|
|
694
|
-
const manageResumed = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id);
|
|
695
|
-
assert.equal(manageResumed.state, 'trial');
|
|
696
|
-
const manageDiscard = await call('/lite-memory/evolution', {
|
|
697
|
-
sessionId: 'session-evo-read', learningAction: 'discard', id: manageRow.id, expectedRevision: manageResumed.revision,
|
|
698
|
-
});
|
|
699
|
-
assert.equal(manageDiscard.status, 200);
|
|
700
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.length, 0);
|
|
701
|
-
|
|
702
|
-
// ── 关闭项目记忆后:面板只读,管理面写操作全部拒绝,工具也不落盘 ─────────────
|
|
703
|
-
const closedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-closed-'));
|
|
704
|
-
sessionCwds.set('session-closed', closedCwd);
|
|
705
|
-
const closedScope = store.scopeOf(closedCwd);
|
|
706
|
-
const closedAgent = { id: 'session-closed', cwd: closedCwd, session: { id: 'session-closed', header: { cwd: closedCwd } } };
|
|
707
|
-
const closedSeed = await defs.get('memory_save').execute(
|
|
708
|
-
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
709
|
-
{ agent: closedAgent },
|
|
710
|
-
);
|
|
711
|
-
const closedRow = store.loadIndex(closedScope).entries.find((entry) => entry.title === closedSeed.title);
|
|
712
|
-
assert.ok(closedRow, '关闭前的样例落在这一层');
|
|
713
|
-
state.transact(closedScope, {}, (draft) => {
|
|
714
|
-
const row = draft.entries.find((entry) => entry.id === closedRow.id);
|
|
715
|
-
row.origin = 'project_evolution';
|
|
716
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', specDigest: 'seeded' } };
|
|
717
|
-
return { changed: true };
|
|
718
|
-
});
|
|
719
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-closed', enabled: true })).status, 200);
|
|
720
|
-
const closedOff = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: false, operationId: 'op_close_contract' });
|
|
721
|
-
assert.equal(closedOff.status, 200);
|
|
722
|
-
assert.equal(closedOff.body.enabled, false);
|
|
723
|
-
const closedFile = path.join(closedScope.dir, 'state.json');
|
|
724
|
-
const closedBefore = fs.readFileSync(closedFile);
|
|
725
|
-
const closedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
726
|
-
assert.equal(closedSnapshot.status, 200);
|
|
727
|
-
assert.equal(closedSnapshot.body.enabled, false);
|
|
728
|
-
assert.equal(closedSnapshot.body.rules.length, 1);
|
|
729
|
-
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
730
|
-
const closedRevision = closedSnapshot.body.rules[0].revision;
|
|
731
|
-
for (const action of ['adopt', 'edit', 'delete', 'pause', 'resume', 'pin', 'undo']) {
|
|
732
|
-
const payload = { sessionId: 'session-closed', learningAction: action, id: closedSeed.id, expectedRevision: closedRevision, body: '改过的正文' };
|
|
733
|
-
if (action === 'pin') payload.pinned = true;
|
|
734
|
-
if (action === 'pause') payload.ids = [closedRow.id];
|
|
735
|
-
const result = await call('/lite-memory/evolution', payload);
|
|
736
|
-
assert.equal(result.status, 409, action);
|
|
737
|
-
assert.equal(result.body.code, 'memory_disabled', action);
|
|
738
|
-
}
|
|
739
|
-
const closedTool = await defs.get('memory_save').execute(
|
|
740
|
-
{ title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' },
|
|
741
|
-
{ agent: closedAgent },
|
|
742
|
-
);
|
|
743
|
-
assert.equal(closedTool.changed, false);
|
|
744
|
-
assert.equal(closedTool.reason, 'memory_disabled');
|
|
745
|
-
assertSchema('memory_save', closedTool);
|
|
746
|
-
assert.equal(closedTool.id, undefined);
|
|
747
|
-
/** 关闭态的保存回执也要能渲染:不能等用户真去写一条才报 ReferenceError。 */
|
|
748
|
-
assert.match(render('memory_save', {}, closedTool), /^memory · 本项目记忆已关闭/);
|
|
749
|
-
/** 关闭状态下的读取也只报状态,不返回正文。 */
|
|
750
|
-
const closedRead = await defs.get('memory_read').execute({ title: closedSeed.title }, { agent: closedAgent });
|
|
751
|
-
assert.equal(closedRead.status, 'memory_disabled');
|
|
752
|
-
assert.equal(closedRead.body, undefined, '关闭时连空正文也不占位');
|
|
753
|
-
assertSchema('memory_read', closedRead);
|
|
754
|
-
assert.match(render('memory_read', {}, closedRead), /^memory · 记忆检索已关闭$/);
|
|
755
|
-
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
756
|
-
const toolOnly = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, mode: 'tool_only', operationId: 'op_mode_contract' });
|
|
757
|
-
assert.equal(toolOnly.status, 200);
|
|
758
|
-
assert.equal(toolOnly.body.enabled, true);
|
|
759
|
-
assert.equal(toolOnly.body.mode, 'tool_only');
|
|
760
|
-
const backToAuto = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, mode: 'adaptive', operationId: 'op_mode_back_contract' });
|
|
761
|
-
assert.equal(backToAuto.status, 200);
|
|
762
|
-
assert.equal(backToAuto.body.mode, 'adaptive');
|
|
763
|
-
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
764
|
-
assert.equal(reopened.status, 200);
|
|
765
|
-
const reopenedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
766
|
-
assert.equal(reopenedSnapshot.body.enabled, false);
|
|
767
|
-
assert.equal(reopenedSnapshot.body.rules.length, 1);
|
|
768
|
-
|
|
769
|
-
// ── 界面只吃持久元数据,不猜文案 ─────────────────────────────────────────────
|
|
770
|
-
const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(args, value);
|
|
771
|
-
const savedMeta = metaOf('memory_save', {}, saved);
|
|
772
|
-
assert.equal(savedMeta.kind, 'memory-save');
|
|
773
|
-
assert.equal(savedMeta.recordKind, saved.kind);
|
|
774
|
-
assert.equal(savedMeta.id, undefined);
|
|
775
|
-
assert.equal(savedMeta.title, '发布顺序');
|
|
776
|
-
assert.equal(savedMeta.changed, true);
|
|
777
|
-
assert.ok(['created', 'updated'].includes(savedMeta.mode));
|
|
778
|
-
assert.equal('path' in savedMeta, false);
|
|
779
|
-
const searchMeta = metaOf('memory_search', {}, globalSearch);
|
|
780
|
-
assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['kind', 'origin', 'revision', 'scope', 'status', 'title', 'updated', 'when']);
|
|
781
|
-
/** 输出 schema 不能再要求内部 id:引用对外只有标题,宿主校验会因此整条工具报错。 */
|
|
782
|
-
const searchRefSchema = defs.get('memory_search').output.schema.properties.refs.items;
|
|
783
|
-
assert.equal('id' in searchRefSchema.properties, false);
|
|
784
|
-
assert.deepEqual([...searchRefSchema.required].sort(), ['kind', 'origin', 'revision', 'scope', 'status', 'title', 'when']);
|
|
785
|
-
assert.equal(searchMeta.refs[0].title, '回复用中文');
|
|
786
|
-
assert.equal(searchMeta.refs[0].scope, 'global');
|
|
787
|
-
/** 引用要能区分偏好/约定/流程/资料,界面才不必猜标题文案。 */
|
|
788
|
-
assert.equal(searchMeta.refs[0].kind, store.loadIndex(store.globalScope()).entries.find((item) => item.title === '回复用中文').kind);
|
|
789
|
-
assert.equal(searchMeta.refs[0].when, '');
|
|
790
|
-
/** 出处行要说「N 天前更新」,只给时间不进正文。 */
|
|
791
|
-
assert.equal(typeof searchMeta.refs[0].updated, 'string');
|
|
792
|
-
const readValue = await exec('memory_read', { title: '回复用中文' });
|
|
793
|
-
const readMeta = metaOf('memory_read', {}, readValue);
|
|
794
|
-
assert.equal(readMeta.kind, 'memory-read');
|
|
795
|
-
assert.equal(readMeta.recordKind, readValue.kind);
|
|
796
|
-
assert.equal(readMeta.title, '回复用中文');
|
|
797
|
-
assert.equal(readMeta.revision, 1);
|
|
798
|
-
const suggestValue = await exec('memory_suggest', { title: '元数据建议', summary: '建议行只认元数据里的候选 id' });
|
|
799
|
-
const suggestMeta = metaOf('memory_suggest', {}, suggestValue);
|
|
800
|
-
assert.equal(suggestMeta.kind, 'memory-suggest');
|
|
801
|
-
assert.equal(typeof suggestMeta.asked, 'boolean');
|
|
802
|
-
assert.equal('path' in suggestMeta, false);
|
|
803
|
-
if (suggestMeta.asked === true) assert.match(suggestMeta.id, /^sug_[a-z0-9]+$/);
|
|
804
|
-
|
|
805
|
-
// ── 撤销带着当时的版本:过期就冲突,不覆盖后来的修改 ─────────────────────────
|
|
806
|
-
const guarded = await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本' });
|
|
807
|
-
await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本', body: '第二版正文' });
|
|
808
|
-
const stale = await call('/lite-memory/undo', { op: 'undo', title: '版本守卫', revision: 1, cwd });
|
|
809
|
-
assert.equal(stale.status, 409);
|
|
810
|
-
const fresh = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.title === '版本守卫');
|
|
811
|
-
const accepted = await call('/lite-memory/undo', { op: 'undo', title: '版本守卫', revision: fresh.revision, cwd });
|
|
812
|
-
assert.equal(accepted.status, 200);
|
|
813
|
-
|
|
814
|
-
// ── 第二组:确认前后语义一致、比较口径统一 ──────────────────────────────────
|
|
815
|
-
const cwd2 = path.join(os.tmpdir(), 'lite-memory-contract-project-2');
|
|
816
|
-
fs.mkdirSync(cwd2, { recursive: true });
|
|
817
|
-
const exec2 = (name, args) => defs.get(name).execute(args, { agent: { cwd: cwd2, session: { id: 'session-contract-2', header: { cwd: cwd2 } } } });
|
|
818
|
-
const projectScope = store.scopeOf(cwd2);
|
|
819
|
-
const projectEntries = () => store.loadIndex(projectScope).entries;
|
|
820
|
-
|
|
821
|
-
/** 建议时给的适用范围与条件,确认后必须原样落盘,不能因为过了一道待确认区就变成普遍适用。 */
|
|
822
|
-
const condSuggest = await exec2('memory_suggest', {
|
|
823
|
-
title: '只在发布机跑冒烟',
|
|
824
|
-
summary: '发布机上的冒烟检查命令',
|
|
825
|
-
body: 'bash smoke.sh',
|
|
826
|
-
when: '发布机上',
|
|
827
|
-
scope: 'project',
|
|
828
|
-
applicability: 'conditional',
|
|
829
|
-
conditions: [{ key: 'machine', value: 'release-box' }],
|
|
830
|
-
triggers: ['冒烟'],
|
|
831
|
-
});
|
|
832
|
-
assert.equal(condSuggest.asked, true, JSON.stringify(condSuggest));
|
|
833
|
-
const condKept = await call('/lite-memory/keep', { op: 'keep', candidateId: condSuggest.id, operationId: 'op_cond_keep', cwd: cwd2 });
|
|
834
|
-
assert.equal(condKept.status, 200);
|
|
835
|
-
const condRecord = projectEntries().find((item) => item.title === '只在发布机跑冒烟');
|
|
836
|
-
assert.ok(condRecord, '确认后应写入项目层');
|
|
837
|
-
assert.equal(condRecord.applicability, 'conditional');
|
|
838
|
-
assert.deepEqual(condRecord.conditions, [{ key: 'machine', value: 'release-box' }]);
|
|
839
|
-
assert.deepEqual(condRecord.triggers, ['冒烟']);
|
|
840
|
-
|
|
841
|
-
/** 只改摘要不该把原来有条件的规则放宽成普遍适用。 */
|
|
842
|
-
const narrow = await exec2('memory_save', {
|
|
843
|
-
title: 'windows 上跑构建',
|
|
844
|
-
summary: 'windows 构建命令',
|
|
845
|
-
body: 'pnpm build',
|
|
846
|
-
when: 'windows 上',
|
|
847
|
-
scope: 'project',
|
|
848
|
-
applicability: 'conditional',
|
|
849
|
-
conditions: [{ key: 'targetOs', value: 'windows' }],
|
|
850
|
-
});
|
|
851
|
-
assert.equal(narrow.changed, true);
|
|
852
|
-
const narrowed = await exec2('memory_save', { title: 'windows 上跑构建', summary: 'windows 构建用这条命令' });
|
|
853
|
-
assert.equal(narrowed.changed, true);
|
|
854
|
-
const narrowRecord = projectEntries().find((item) => item.title === 'windows 上跑构建');
|
|
855
|
-
assert.equal(narrowRecord.applicability, 'conditional');
|
|
856
|
-
assert.deepEqual(narrowRecord.conditions, [{ key: 'targetOs', value: 'windows' }]);
|
|
857
|
-
|
|
858
|
-
/** 同名但机器条件不同是两条规则:新的一条不能覆盖旧的一条。 */
|
|
859
|
-
const boxA = await exec2('memory_save', {
|
|
860
|
-
title: '冒烟顺序',
|
|
861
|
-
summary: '冒烟按这个顺序跑',
|
|
862
|
-
body: '先登录再下单',
|
|
863
|
-
when: '冒烟时',
|
|
864
|
-
scope: 'project',
|
|
865
|
-
applicability: 'conditional',
|
|
866
|
-
conditions: [{ key: 'machine', value: 'box-a' }],
|
|
867
|
-
});
|
|
868
|
-
assert.equal(boxA.changed, true);
|
|
869
|
-
const boxB = await exec2('memory_save', {
|
|
870
|
-
title: '冒烟顺序',
|
|
871
|
-
summary: '冒烟按这个顺序跑',
|
|
872
|
-
body: '先建号再登录',
|
|
873
|
-
when: '冒烟时',
|
|
874
|
-
scope: 'project',
|
|
875
|
-
applicability: 'conditional',
|
|
876
|
-
conditions: [{ key: 'machine', value: 'box-b' }],
|
|
877
|
-
});
|
|
878
|
-
assert.equal(boxB.changed, true);
|
|
879
|
-
const bothBoxes = projectEntries().filter((item) => item.title === '冒烟顺序' && item.status === 'active');
|
|
880
|
-
assert.equal(bothBoxes.length, 2);
|
|
881
|
-
assert.deepEqual(bothBoxes.map((item) => item.conditions[0].value).sort(), ['box-a', 'box-b']);
|
|
882
|
-
assert.equal(bothBoxes.find((item) => item.conditions[0].value === 'box-a').body, '先登录再下单');
|
|
883
|
-
assert.equal(bothBoxes.find((item) => item.conditions[0].value === 'box-b').body, '先建号再登录');
|
|
884
|
-
|
|
885
|
-
/** 同一主题、条件一致但正文不同:读取要报冲突,不能悄悄挑一条当现行规范。 */
|
|
886
|
-
const slotA = await exec2('memory_save', {
|
|
887
|
-
title: '发版检查项',
|
|
888
|
-
summary: '发版前检查的固定清单',
|
|
889
|
-
body: '第一步:跑 lint',
|
|
890
|
-
scope: 'project',
|
|
891
|
-
applicability: 'all_in_scope',
|
|
892
|
-
topicKey: '发版检查',
|
|
893
|
-
resolution: 'distinct',
|
|
894
|
-
});
|
|
895
|
-
const slotB = await exec2('memory_save', {
|
|
896
|
-
title: '发版检查项',
|
|
897
|
-
summary: '发版前检查的固定清单',
|
|
898
|
-
body: '第一步:跑单测',
|
|
899
|
-
scope: 'project',
|
|
900
|
-
applicability: 'all_in_scope',
|
|
901
|
-
topicKey: '发版检查',
|
|
902
|
-
resolution: 'distinct',
|
|
903
|
-
});
|
|
904
|
-
const slotEntries = projectEntries().filter((item) => item.title === '发版检查项');
|
|
905
|
-
assert.equal(slotEntries.length, 2);
|
|
906
|
-
assert.notEqual(slotEntries[0].id, slotEntries[1].id);
|
|
907
|
-
const resolvedSlots = store.visibleRules(projectScope, {}).resolved;
|
|
908
|
-
const slotRow = [...resolvedSlots.values()].find((row) => row.key === '发版检查');
|
|
909
|
-
assert.ok(slotRow);
|
|
910
|
-
assert.equal(slotRow.status, 'conflict');
|
|
911
|
-
|
|
912
|
-
/** 工具参数里的 links / selection.items / topicKey:null 是合法输入,不能被 schema 挡掉或被校验器拒绝。 */
|
|
913
|
-
assert.equal(saveParams.links.items.type, 'object');
|
|
914
|
-
assert.deepEqual(saveParams.links.items.required, ['title']);
|
|
915
|
-
assert.equal(saveParams.selection.properties.items.type, 'array');
|
|
916
|
-
assert.deepEqual(saveParams.topicKey.type, ['string', 'null']);
|
|
917
|
-
const linkedA = await exec2('memory_save', { title: '关联目标甲', summary: '用来当关联目标', body: '甲', scope: 'project' });
|
|
918
|
-
const linkedB = await exec2('memory_save', {
|
|
919
|
-
title: '关联目标乙',
|
|
920
|
-
summary: '带关联的那条',
|
|
921
|
-
body: '乙',
|
|
922
|
-
scope: 'project',
|
|
923
|
-
resolution: 'distinct',
|
|
924
|
-
links: [{ type: 'related', title: linkedA.title }],
|
|
925
|
-
});
|
|
926
|
-
assert.equal(linkedB.changed, true, JSON.stringify(linkedB));
|
|
927
|
-
const linkedRecord = projectEntries().find((item) => item.title === '关联目标乙');
|
|
928
|
-
const linkedTarget = projectEntries().find((item) => item.title === '关联目标甲');
|
|
929
|
-
assert.ok(linkedRecord && linkedTarget);
|
|
930
|
-
assert.equal(linkedRecord.links.length, 1);
|
|
931
|
-
assert.equal(linkedRecord.links[0].id, linkedTarget.id);
|
|
932
|
-
assert.equal(linkedRecord.links[0].type, 'related');
|
|
933
|
-
/** 关联清单也要给标题:只发内部 id 会让界面显示 undefined。 */
|
|
934
|
-
const linkRead = await exec2('memory_read', { title: '关联目标乙', view: 'links' });
|
|
935
|
-
assert.equal(linkRead.title, '关联目标乙');
|
|
936
|
-
assert.match(linkRead.links, /关联目标甲/);
|
|
937
|
-
assert.doesNotMatch(render('memory_read', { title: '关联目标乙', view: 'links' }, linkRead), /undefined/);
|
|
938
|
-
/** 更新句柄也是标题:抄标题改这一条,不需要任何内部编号。 */
|
|
939
|
-
const retitled = await exec2('memory_save', { updateTitle: linkedA.title, title: linkedA.title, summary: '改过的摘要' });
|
|
940
|
-
assert.equal(retitled.changed, true);
|
|
941
|
-
assert.equal(retitled.summary, '改过的摘要');
|
|
942
|
-
const missingUpdate = await exec2('memory_save', { updateTitle: '从来没有存过的标题', title: '新的一条', summary: '新' });
|
|
943
|
-
assert.match(missingUpdate.error, /没有「从来没有存过的标题」这条记忆/);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
// ── 项目实验:正常回合结束后走一次短整理,只写后台结果 ─────────────────────────
|
|
947
|
-
const orgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-organize-'));
|
|
948
|
-
sessionCwds.set('session-organize', orgCwd);
|
|
949
|
-
const orgOn = await call('/lite-memory/evolution', { sessionId: 'session-organize', enabled: true });
|
|
950
|
-
assert.equal(orgOn.status, 200);
|
|
951
|
-
const orgScope = store.scopeOf(orgCwd);
|
|
952
|
-
await store.setTaskDefault(orgScope, { taskKind: 'video', seriesId: '口播' });
|
|
953
|
-
let orgOptions = null;
|
|
954
|
-
let orgPrepare = null;
|
|
955
|
-
let orgMode = 'new';
|
|
956
|
-
llmImpl = {
|
|
957
|
-
prepareCall: async (config, signal) => {
|
|
958
|
-
orgPrepare = { config, signal };
|
|
959
|
-
return {
|
|
960
|
-
config: { provider: 'deepseek', model: 'chat' },
|
|
961
|
-
stream: async function* (options) {
|
|
962
|
-
orgOptions = options;
|
|
963
|
-
const prompt = options.messages[0].content[0].text;
|
|
964
|
-
const ref = (prompt.match(/"ref":"(r\d+)"/) || [])[1] || 'r0';
|
|
965
|
-
const change = orgMode === 'checkpoint'
|
|
966
|
-
? { target: ref, feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'], persistence: 'project_candidate' }
|
|
967
|
-
: orgMode === 'sample'
|
|
968
|
-
? { target: ref, feedbackType: 'artifact_accept', evidence: ['u0'], persistence: 'project_candidate' }
|
|
969
|
-
: { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论。', applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'seriesId', value: '口播' }, { key: 'phase', value: 'brief' }], evidence: ['u0'], persistence: 'project_candidate' };
|
|
970
|
-
yield { type: 'text-delta', text: JSON.stringify({ changes: [change] }) };
|
|
971
|
-
yield { type: 'usage', usage: { inputTokens: 12, outputTokens: 6 } };
|
|
972
|
-
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
973
|
-
},
|
|
974
|
-
};
|
|
975
|
-
},
|
|
976
|
-
};
|
|
977
|
-
const orgAgent = { id: 'session-organize', cwd: orgCwd, session: { id: 'session-organize', header: { id: 'session-organize', cwd: orgCwd }, requestHeader: () => ({ config: { provider: 'deepseek', model: 'chat' } }) } };
|
|
978
|
-
currentAgent = orgAgent;
|
|
979
|
-
const orgSig = new AbortController().signal;
|
|
980
|
-
await preStep({ agent: orgAgent, signal: orgSig, turn: '31' }, async () => ({
|
|
981
|
-
kind: 'enter',
|
|
982
|
-
messages: [{ role: 'user', id: 'org-m1', source: { kind: 'user' }, content: [{ type: 'text', text: '以后开头先给结论,别再铺垫。' }] }],
|
|
983
|
-
}));
|
|
984
|
-
assert.equal(store.learningSnapshot(orgScope).rules.length, 0);
|
|
985
|
-
const orgTurnEnd = subscriptions.get('session/event');
|
|
986
|
-
assert.equal(typeof orgTurnEnd, 'function');
|
|
987
|
-
orgTurnEnd(orgAgent.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
988
|
-
for (let i = 0; i < 100 && store.learningSnapshot(orgScope).rules.length === 0; i += 1) {
|
|
989
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
990
|
-
}
|
|
991
|
-
const orgRules = store.learningSnapshot(orgScope).rules;
|
|
992
|
-
assert.equal(orgRules.length, 1);
|
|
993
|
-
assert.equal(orgRules[0].body, '开头先给结论。');
|
|
994
|
-
assert.equal(orgRules[0].state, 'active');
|
|
995
|
-
assert.equal(orgPrepare.config.maxTokens, 768);
|
|
996
|
-
assert.equal(orgOptions.purpose, 'project-evolution');
|
|
997
|
-
assert.equal('tools' in orgOptions, false);
|
|
998
|
-
assert.equal(orgOptions.sessionId, 'session-organize');
|
|
999
|
-
assert.equal(orgRules[0].origin, 'project_evolution');
|
|
1000
|
-
const orgEconomy = store.learningSnapshot(orgScope).economy;
|
|
1001
|
-
assert.equal(orgEconomy.calls, 1);
|
|
1002
|
-
assert.equal(orgEconomy.knownCalls, 1);
|
|
1003
|
-
assert.equal(orgEconomy.inputTokens, 12);
|
|
1004
|
-
|
|
1005
|
-
/** 第二步:执行没做到,后台只补检查点;主持人新开一版也不改正文。 */
|
|
1006
|
-
orgMode = 'checkpoint';
|
|
1007
|
-
await preStep({ agent: orgAgent, signal: orgSig, turn: '32' }, async () => ({
|
|
1008
|
-
kind: 'enter',
|
|
1009
|
-
messages: [{ role: 'user', id: 'org-m2', source: { kind: 'user' }, content: [{ type: 'text', text: '之前说过开头先给结论,这次怎么又没做到。' }] }],
|
|
1010
|
-
}));
|
|
1011
|
-
orgTurnEnd(orgAgent.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
1012
|
-
const orgRuleId = store.learningSnapshot(orgScope).rules[0].id;
|
|
1013
|
-
const orgFlat = () => store.loadIndex(orgScope).entries.find((row) => row.id === orgRuleId);
|
|
1014
|
-
for (let i = 0; i < 100 && !(orgFlat()?.learning?.checkpoints || []).length; i += 1) {
|
|
1015
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1016
|
-
}
|
|
1017
|
-
assert.equal(orgFlat().body, '开头先给结论。');
|
|
1018
|
-
assert.equal((orgFlat().learning.checkpoints || []).length, 1);
|
|
1019
|
-
assert.match(orgFlat().learning.checkpoints[0].text, /核对/);
|
|
1020
|
-
assert.equal(orgFlat().learning.state, 'active');
|
|
1021
|
-
assert.equal(orgFlat().learning.evidence.length, 1);
|
|
1022
|
-
|
|
1023
|
-
/** 第三步:另一期作品认可这个开头,只记一条有版本、有维度的样例。 */
|
|
1024
|
-
sessionCwds.set('session-organize-2', orgCwd);
|
|
1025
|
-
const orgScope2 = orgScope;
|
|
1026
|
-
orgMode = 'sample';
|
|
1027
|
-
const orgAgent2 = { id: 'session-organize-2', cwd: orgCwd, session: { id: 'session-organize-2', header: { id: 'session-organize-2', cwd: orgCwd }, requestHeader: () => ({ config: { provider: 'deepseek', model: 'chat' } }) } };
|
|
1028
|
-
currentAgent = orgAgent2;
|
|
1029
|
-
await preStep({ agent: orgAgent2, signal: orgSig, turn: '41' }, async () => ({
|
|
1030
|
-
kind: 'enter',
|
|
1031
|
-
messages: [{ role: 'user', id: 'org-m3', source: { kind: 'user' }, content: [{ type: 'text', text: '这版可以,开头这样就行。' }] }],
|
|
1032
|
-
}));
|
|
1033
|
-
orgMode = 'sample';
|
|
1034
|
-
orgTurnEnd(orgAgent2.session, { type: 'turn/end', data: { reason: { kind: 'completed' } } });
|
|
1035
|
-
const orgRule2 = () => store.loadIndex(orgScope2).entries.find((row) => row.origin === 'project_evolution');
|
|
1036
|
-
for (let i = 0; i < 100 && !(orgRule2()?.learning?.samples || []).length; i += 1) {
|
|
1037
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1038
|
-
}
|
|
1039
|
-
assert.equal(orgRule2().body, '开头先给结论。');
|
|
1040
|
-
assert.equal((orgRule2().learning.samples || []).length, 1);
|
|
1041
|
-
assert.equal(orgRule2().learning.samples[0].dimension, '开头');
|
|
1042
|
-
assert.equal(orgRule2().learning.samples[0].verified, false);
|
|
1043
|
-
assert.ok(orgRule2().learning.samples[0].artifactVersion);
|
|
1044
|
-
const orgSnapshot2 = await call('/lite-memory/evolution', { sessionId: 'session-organize-2', learningAction: 'snapshot' });
|
|
1045
|
-
assert.equal(orgSnapshot2.status, 200);
|
|
1046
|
-
assert.equal(orgSnapshot2.body.rules.length, 1);
|
|
1047
|
-
assert.equal(orgSnapshot2.body.rules[0].samples.length, 1);
|
|
1048
|
-
assert.equal(orgSnapshot2.body.rules[0].checkpoints.length, 1);
|
|
1049
|
-
const orgEconomy2 = store.learningSnapshot(orgScope2).economy;
|
|
1050
|
-
assert.equal(orgEconomy2.calls, 3);
|
|
1051
|
-
assert.equal(orgEconomy2.knownCalls, 3);
|
|
1052
|
-
assert.equal(orgEconomy2.inputTokens, 36);
|
|
1053
|
-
llmImpl = null;
|
|
1054
|
-
|
|
1055
|
-
// ── 记忆面板总览:只读枚举各项目层,正文按 scopeKey 单读,不泄露内部路径 ──────
|
|
1056
|
-
const ovCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-'));
|
|
1057
|
-
const ovOtherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-other-'));
|
|
1058
|
-
sessionCwds.set('session-overview', ovCwd);
|
|
1059
|
-
sessionCwds.set('session-overview-other', ovOtherCwd);
|
|
1060
|
-
const ovAgent = { id: 'session-overview', cwd: ovCwd, session: { id: 'session-overview', header: { cwd: ovCwd } } };
|
|
1061
|
-
const ovFirst = await defs.get('memory_save').execute(
|
|
1062
|
-
{ title: '开场先给结论', summary: '口播开头先给结论', body: '开场三句内给结论。', tags: ['口播', '开头'], links: [] },
|
|
1063
|
-
{ agent: ovAgent },
|
|
1064
|
-
);
|
|
1065
|
-
const ovSecond = await defs.get('memory_save').execute(
|
|
1066
|
-
{ title: '封面留白', summary: '封面别放满', body: '右侧留白。', tags: ['封面'] },
|
|
1067
|
-
{ agent: ovAgent },
|
|
1068
|
-
);
|
|
1069
|
-
await defs.get('memory_save').execute(
|
|
1070
|
-
{ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', when: '周四晚上' },
|
|
1071
|
-
{ agent: ovAgent },
|
|
1072
|
-
);
|
|
1073
|
-
store.saveEntry(store.globalScope(), {
|
|
1074
|
-
title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scopeConfirmed: true,
|
|
1075
|
-
});
|
|
1076
|
-
const ovOther = await defs.get('memory_save').execute(
|
|
1077
|
-
{ title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
|
|
1078
|
-
{ agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
|
|
1079
|
-
);
|
|
1080
|
-
const ov = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
1081
|
-
assert.equal(ov.status, 200);
|
|
1082
|
-
assert.equal(ov.body.ok, true);
|
|
1083
|
-
assert.equal(ov.body.current.key, store.scopeOf(ovCwd).key);
|
|
1084
|
-
assert.equal(ov.body.current.label, path.basename(ovCwd));
|
|
1085
|
-
assert.equal(ov.body.current.evolution.enabled, false);
|
|
1086
|
-
assert.equal(ov.body.current.recall, null, '没发生过自动召回时不给判断依据');
|
|
1087
|
-
assert.equal(ov.body.selected.key, ov.body.current.key);
|
|
1088
|
-
assert.equal(ov.body.selected.kind, 'project');
|
|
1089
|
-
assert.equal(ov.body.selected.evolution.enabled, false);
|
|
1090
|
-
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
1091
|
-
assert.ok(ov.body.selected.records.some((row) => row.title === '开场先给结论'));
|
|
1092
|
-
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
1093
|
-
assert.equal(ov.body.selected.stats.total, 3);
|
|
1094
|
-
assert.equal(ov.body.selected.stats.tags, 3);
|
|
1095
|
-
assert.equal(JSON.stringify(ov.body).includes('"dir"'), false, '不暴露内部目录');
|
|
1096
|
-
assert.equal(JSON.stringify(ov.body).includes(process.env.DSH_HOME), false);
|
|
1097
|
-
const ovKeys = ov.body.scopes.map((row) => row.key);
|
|
1098
|
-
assert.ok(ovKeys.includes(ov.body.current.key));
|
|
1099
|
-
assert.ok(ovKeys.includes(store.scopeOf(ovOtherCwd).key));
|
|
1100
|
-
assert.ok(ovKeys.includes('global'), '全局层总在列表里');
|
|
1101
|
-
|
|
1102
|
-
// 切到别的项目只是换一面看,不写那个项目
|
|
1103
|
-
const ovOtherView = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
1104
|
-
assert.equal(ovOtherView.status, 200);
|
|
1105
|
-
assert.equal(ovOtherView.body.selected.key, store.scopeOf(ovOtherCwd).key);
|
|
1106
|
-
assert.deepEqual(ovOtherView.body.selected.records.map((row) => row.title), ['别的项目']);
|
|
1107
|
-
assert.equal(ovOtherView.body.selected.evolution.enabled, false, '别的项目也要给只读实验状态');
|
|
1108
|
-
assert.equal(ovOtherView.body.current.key, ov.body.current.key);
|
|
1109
|
-
const ovGlobal = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: 'global' });
|
|
1110
|
-
assert.equal(ovGlobal.status, 200);
|
|
1111
|
-
assert.equal(ovGlobal.body.selected.kind, 'global');
|
|
1112
|
-
assert.ok(ovGlobal.body.selected.records.some((row) => row.title === '观察全局'));
|
|
1113
|
-
|
|
1114
|
-
// 面板改正文:只动当前项目、必须带读到的版本,冲突和改名撞车都不覆盖
|
|
1115
|
-
const ovRow = ov.body.selected.records.find((row) => row.title === '开场先给结论');
|
|
1116
|
-
const ovEntry = await call('/lite-memory/entry', {
|
|
1117
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
1118
|
-
title: '开场先给结论', body: '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', expectedRevision: ovRow.revision,
|
|
1119
|
-
});
|
|
1120
|
-
assert.equal(ovEntry.status, 200);
|
|
1121
|
-
assert.equal(ovEntry.body.ok, true);
|
|
1122
|
-
assert.equal(ovEntry.body.title, '开场先给结论');
|
|
1123
|
-
assert.ok(ovEntry.body.revision > ovRow.revision, '改过就升版本');
|
|
1124
|
-
const ovKept = store.readEntry(store.scopeOf(ovCwd), ovRow.id);
|
|
1125
|
-
assert.deepEqual(ovKept.tags, ['口播', '开头'], '只改标题正文,标签等字段保持');
|
|
1126
|
-
assert.equal(ovKept.kind, 'note');
|
|
1127
|
-
const ovEditedBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
1128
|
-
assert.equal(ovEditedBody.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。');
|
|
1129
|
-
const ovStale = await call('/lite-memory/entry', {
|
|
1130
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
1131
|
-
title: '开场先给结论', body: '被旧版本覆盖。', expectedRevision: ovRow.revision,
|
|
1132
|
-
});
|
|
1133
|
-
assert.equal(ovStale.status, 409);
|
|
1134
|
-
assert.equal(ovStale.body.retry, true, '版本过期要明说可以重试');
|
|
1135
|
-
const ovAfterStale = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
1136
|
-
assert.equal(ovAfterStale.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', '旧版本写不进去');
|
|
1137
|
-
const ovRenameClash = await call('/lite-memory/entry', {
|
|
1138
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
1139
|
-
title: '封面留白', body: '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', expectedRevision: ovEntry.body.revision,
|
|
1140
|
-
});
|
|
1141
|
-
assert.equal(ovRenameClash.status, 400, '改名撞上另一条要拒绝');
|
|
1142
|
-
assert.match(ovRenameClash.body.reason, /同名/);
|
|
1143
|
-
const ovOtherEdit = await call('/lite-memory/entry', {
|
|
1144
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovOther.body && ovOther.body.record ? ovOther.body.record.id : 'mem_other',
|
|
1145
|
-
title: '别的项目', body: '想跨项目改。', expectedRevision: 1,
|
|
1146
|
-
});
|
|
1147
|
-
assert.equal(ovOtherEdit.status >= 400, true, '别的项目不能从这里改');
|
|
1148
|
-
const ovGlobalRow = ovGlobal.body.selected.records.find((row) => row.title === '观察全局');
|
|
1149
|
-
const ovGlobalEdit = await call('/lite-memory/entry', {
|
|
1150
|
-
sessionId: 'session-overview', scopeKey: 'global', id: ovGlobalRow.id,
|
|
1151
|
-
title: '观察全局', body: '想改全局。', expectedRevision: ovGlobalRow.revision,
|
|
1152
|
-
});
|
|
1153
|
-
assert.equal(ovGlobalEdit.status, 409, '全局层在项目会话里只读');
|
|
1154
|
-
assert.equal((await call('/lite-memory/entry', { sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id, title: '', body: 'x', expectedRevision: 1 })).status, 400);
|
|
1155
|
-
|
|
1156
|
-
// 适用范围只有两种来源:模型当时没确认,或人看过之后明确确认。面板能补后者,不替模型猜。
|
|
1157
|
-
const ovPendingRow = ov.body.selected.records.find((row) => row.title === '发布窗口');
|
|
1158
|
-
assert.equal(ovPendingRow.applicability, 'unresolved');
|
|
1159
|
-
const ovPendingBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '发布窗口', scopeKey: ov.body.current.key });
|
|
1160
|
-
assert.equal(ovPendingBody.status, 200);
|
|
1161
|
-
assert.equal(ovPendingBody.body.applicability, 'unresolved');
|
|
1162
|
-
const ovConfirmBad = await call('/lite-memory/entry', {
|
|
1163
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovPendingRow.id,
|
|
1164
|
-
title: '发布窗口', body: '周四晚上再发版。', expectedRevision: ovPendingRow.revision, applicability: 'always',
|
|
1165
|
-
});
|
|
1166
|
-
assert.equal(ovConfirmBad.status, 400, '三档之外的值不能写进记忆');
|
|
1167
|
-
const ovConfirm = await call('/lite-memory/entry', {
|
|
1168
|
-
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovPendingRow.id,
|
|
1169
|
-
title: '发布窗口', body: '周四晚上再发版。', expectedRevision: ovPendingRow.revision,
|
|
1170
|
-
applicability: 'all_in_scope', conditions: [],
|
|
1171
|
-
});
|
|
1172
|
-
assert.equal(ovConfirm.status, 200);
|
|
1173
|
-
assert.equal(ovConfirm.body.ok, true);
|
|
1174
|
-
const ovPendingAfter = await call('/lite-memory/body', { sessionId: 'session-overview', title: '发布窗口', scopeKey: ov.body.current.key });
|
|
1175
|
-
assert.equal(ovPendingAfter.body.applicability, 'all_in_scope');
|
|
1176
|
-
assert.deepEqual(ovPendingAfter.body.conditions, []);
|
|
1177
|
-
assert.equal(store.readEntry(store.scopeOf(ovCwd), ovPendingRow.id).applicability, 'all_in_scope', '确认落到那一条上');
|
|
1178
|
-
assert.equal(store.readEntry(store.scopeOf(ovCwd), ovRow.id).applicability, 'all_in_scope', '别条不受影响');
|
|
1179
|
-
|
|
1180
|
-
// 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行。
|
|
1181
|
-
// 宫殿里点开一条只走这个只读入口:不改记忆、不记用量、不占额度。
|
|
1182
|
-
const ovStateFile = path.join(store.scopeOf(ovCwd).dir, 'state.json');
|
|
1183
|
-
const ovStateBefore = fs.readFileSync(ovStateFile, 'utf8');
|
|
1184
|
-
const ovBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
1185
|
-
assert.equal(ovBody.status, 200);
|
|
1186
|
-
assert.equal(fs.readFileSync(ovStateFile, 'utf8'), ovStateBefore, '面板看正文不写状态');
|
|
1187
|
-
assert.equal(ovBody.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。');
|
|
1188
|
-
assert.equal(ovBody.body.scopeLabel, path.basename(ovCwd));
|
|
1189
|
-
const ovOtherBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '别的项目', scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
1190
|
-
assert.equal(ovOtherBody.status, 200);
|
|
1191
|
-
assert.equal(ovOtherBody.body.scopeLabel, path.basename(ovOtherCwd));
|
|
1192
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: 'nope' })).status, 404);
|
|
1193
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: '../x' })).status, 404);
|
|
1194
|
-
assert.equal((await call('/lite-memory/overview', {})).status, 400);
|
|
1195
|
-
assert.equal((await call('/lite-memory/overview', { sessionId: 'session-overview' }, { origin: 'http://evil.example' })).status, 403);
|
|
1196
|
-
const ovUnknownCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-unknown-'));
|
|
1197
|
-
sessionCwds.set('session-overview-unknown', ovUnknownCwd);
|
|
1198
|
-
const ovUnconfirmedSaved = process.env.PATH;
|
|
1199
|
-
process.env.PATH = '';
|
|
1200
|
-
const ovUnconfirmed = await call('/lite-memory/overview', { sessionId: 'session-overview-unknown' });
|
|
1201
|
-
process.env.PATH = ovUnconfirmedSaved;
|
|
1202
|
-
assert.equal(ovUnconfirmed.status, 409);
|
|
1203
|
-
assert.equal(fs.existsSync(path.join(store.scopeOf(ovUnknownCwd).dir, 'state.json')), false);
|
|
1204
|
-
|
|
1205
|
-
// 实验条目也画进图里,但只有面板能看见;普通读取面仍然拿不到
|
|
1206
|
-
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
1207
|
-
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
1208
|
-
row.origin = 'project_evolution';
|
|
1209
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution', specDigest: 'seeded' } };
|
|
1210
|
-
return { changed: true };
|
|
1211
|
-
});
|
|
1212
|
-
const ovExperimental = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
1213
|
-
assert.equal(ovExperimental.body.selected.stats.experimental, 1);
|
|
1214
|
-
assert.equal(ovExperimental.body.selected.records.find((row) => row.title === '封面留白').experimental, true);
|
|
1215
|
-
assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.title === '封面留白'), true);
|
|
1216
|
-
|
|
1217
|
-
// 归档不是死数字:总数诚实,列表单独取,正文还能按 id 打开
|
|
1218
|
-
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
1219
|
-
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
1220
|
-
row.status = 'archived';
|
|
1221
|
-
return { changed: true };
|
|
1222
|
-
});
|
|
1223
|
-
const ovArchived = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
1224
|
-
assert.equal(ovArchived.body.selected.stats.archived, 1);
|
|
1225
|
-
assert.equal(ovArchived.body.selected.records.some((row) => row.title === '封面留白'), false);
|
|
1226
|
-
assert.equal(ovArchived.body.selected.archived.some((row) => row.title === '封面留白'), true);
|
|
1227
|
-
const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '封面留白', scopeKey: ovArchived.body.selected.key });
|
|
1228
|
-
assert.equal(ovArchivedBody.status, 200);
|
|
1229
|
-
assert.equal(ovArchivedBody.body.status, 'archived');
|
|
1230
|
-
assert.equal(ovArchivedBody.body.body, '右侧留白。');
|
|
1231
|
-
|
|
1232
|
-
// 待裁决:面板看得到队列,确认后按草稿另存,不预写正文
|
|
1233
|
-
const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-'));
|
|
1234
|
-
sessionCwds.set('session-conflict-panel', cfCwd);
|
|
1235
|
-
sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
|
|
1236
|
-
const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
|
|
1237
|
-
store.saveEntry(store.globalScope(), { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scopeConfirmed: true });
|
|
1238
|
-
const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
|
|
1239
|
-
assert.equal(cfSave.needsResolution, true);
|
|
1240
|
-
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
|
|
1241
|
-
const cfBefore = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1242
|
-
assert.equal(cfBefore.status, 200);
|
|
1243
|
-
assert.equal(cfBefore.body.selected.conflicts.length, 1);
|
|
1244
|
-
const cfRow = cfBefore.body.selected.conflicts[0];
|
|
1245
|
-
assert.equal(cfRow.title, '发布窗口');
|
|
1246
|
-
assert.equal(cfRow.draft.body, '周五发版。');
|
|
1247
|
-
assert.ok(cfRow.id, '待裁决要有稳定 id');
|
|
1248
|
-
assert.equal(cfRow.revision, 1);
|
|
1249
|
-
const cfScopeKey = cfBefore.body.selected.key;
|
|
1250
|
-
|
|
1251
|
-
// 跨项目:当前会话是 A,请求 B 的范围必须拒绝,且不能动到 A 的草稿
|
|
1252
|
-
const cfCross = await call('/lite-memory/conflict', {
|
|
1253
|
-
sessionId: 'session-conflict-panel', scopeKey: '另一个项目', conflictId: cfRow.id, expectedRevision: 1, op: 'drop',
|
|
1254
|
-
});
|
|
1255
|
-
assert.equal(cfCross.status, 409);
|
|
1256
|
-
assert.match(cfCross.body.reason, /只读/);
|
|
1257
|
-
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).conflicts.length, 1, '跨项目请求不改数据');
|
|
1258
|
-
|
|
1259
|
-
// 关闭项目记忆后,本入口也不能落盘
|
|
1260
|
-
await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: false, operationId: 'op_cf_close' });
|
|
1261
|
-
const cfClosed = await call('/lite-memory/conflict', {
|
|
1262
|
-
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
|
|
1263
|
-
});
|
|
1264
|
-
assert.equal(cfClosed.status, 409);
|
|
1265
|
-
assert.equal(cfClosed.body.code, 'memory_disabled');
|
|
1266
|
-
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '关闭状态不写记忆');
|
|
1267
|
-
await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: true, operationId: 'op_cf_reopen' });
|
|
1268
|
-
|
|
1269
|
-
// 草稿被后来的反馈替换:旧版本点击必须拒绝
|
|
1270
|
-
await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目改成周六发版', body: '周六发版。' }, { agent: cfAgent });
|
|
1271
|
-
const cfStale = await call('/lite-memory/conflict', {
|
|
1272
|
-
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
|
|
1273
|
-
});
|
|
1274
|
-
assert.equal(cfStale.status, 409);
|
|
1275
|
-
assert.equal(cfStale.body.code, 'stale_draft');
|
|
1276
|
-
const cfStaleView = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1277
|
-
assert.equal(cfStaleView.body.selected.conflicts[0].revision, 2, '草稿换了版本要涨');
|
|
1278
|
-
assert.equal(cfStaleView.body.selected.conflicts[0].draft.body, '周六发版。');
|
|
1279
|
-
|
|
1280
|
-
const cfKeep = await call('/lite-memory/conflict', {
|
|
1281
|
-
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
|
|
1282
|
-
});
|
|
1283
|
-
assert.equal(cfKeep.status, 200);
|
|
1284
|
-
assert.equal(cfKeep.body.ok, true);
|
|
1285
|
-
const cfAfter = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1286
|
-
assert.equal(cfAfter.body.selected.conflicts.length, 0);
|
|
1287
|
-
assert.ok(cfAfter.body.selected.records.some((row) => row.title === '发布窗口' && row.summary === '本项目改成周六发版'));
|
|
1288
|
-
const cfAgain = await call('/lite-memory/conflict', {
|
|
1289
|
-
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
|
|
1290
|
-
});
|
|
1291
|
-
assert.equal(cfAgain.status, 409, '队列没了就明确说,不重复落库');
|
|
1292
|
-
|
|
1293
|
-
// 项目记忆关闭时,选全局待裁决也不能借这个入口落盘
|
|
1294
|
-
const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-global-'));
|
|
1295
|
-
sessionCwds.set('session-conflict-global', cfgCwd);
|
|
1296
|
-
const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
|
|
1297
|
-
await defs.get('memory_save').execute({ title: '命名规则', summary: '项目里用短横线', body: '短横线。' }, { agent: cfgAgent });
|
|
1298
|
-
const cfgSave = store.saveEntry(store.scopeOf(cfgCwd), { title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global', scopeConfirmed: true });
|
|
1299
|
-
assert.equal(cfgSave.needsResolution, true);
|
|
1300
|
-
const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
|
|
1301
|
-
assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
|
|
1302
|
-
const cfgRow = cfgView.body.selected.conflicts[0];
|
|
1303
|
-
await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: false, operationId: 'op_cfg_close' });
|
|
1304
|
-
const cfgClosed = await call('/lite-memory/conflict', {
|
|
1305
|
-
sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
|
|
1306
|
-
});
|
|
1307
|
-
assert.equal(cfgClosed.status, 409);
|
|
1308
|
-
assert.equal(cfgClosed.body.code, 'memory_disabled');
|
|
1309
|
-
assert.equal(store.conflictsOf(store.globalScope()).length, 1, '关闭状态不批准全局草稿');
|
|
1310
|
-
await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: true, operationId: 'op_cfg_reopen' });
|
|
1311
|
-
const cfgKeep = await call('/lite-memory/conflict', {
|
|
1312
|
-
sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
|
|
1313
|
-
});
|
|
1314
|
-
assert.equal(cfgKeep.status, 200);
|
|
1315
|
-
assert.equal(store.conflictsOf(store.globalScope()).length, 0);
|
|
1316
|
-
|
|
1317
|
-
// ── 面板搬移:只带会话就能换层,身份按 id 换但正文与条件跟着走 ─────────────
|
|
1318
|
-
const moveCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-move-'));
|
|
1319
|
-
sessionCwds.set('session-move', moveCwd);
|
|
1320
|
-
store.saveEntry(store.scopeOf(moveCwd), {
|
|
1321
|
-
title: '搬移探针', summary: '只在项目层', body: '搬移正文。', tags: ['搬移'], writeMode: 'patch',
|
|
1322
|
-
});
|
|
1323
|
-
const movedOut = await call('/lite-memory/move', {
|
|
1324
|
-
sessionId: 'session-move', target: 'global',
|
|
1325
|
-
id: store.loadIndex(store.scopeOf(moveCwd)).entries.find((row) => row.title === '搬移探针').id,
|
|
1326
|
-
});
|
|
1327
|
-
assert.equal(movedOut.status, 200);
|
|
1328
|
-
assert.equal(movedOut.body.ok, true);
|
|
1329
|
-
assert.equal(movedOut.body.status, 'moved');
|
|
1330
|
-
assert.equal(movedOut.body.to, 'global');
|
|
1331
|
-
assert.ok(movedOut.body.id && movedOut.body.id !== movedOut.body.previousId, '换层后身份换新 id');
|
|
1332
|
-
assert.equal(store.loadIndex(store.scopeOf(moveCwd)).entries.some((row) => row.title === '搬移探针'), false, '项目层不再有这条');
|
|
1333
|
-
assert.equal(store.loadIndex(store.globalScope()).entries.some((row) => row.id === movedOut.body.id), true, '全局层拿到这条');
|
|
1334
|
-
const movedBack = await call('/lite-memory/move', {
|
|
1335
|
-
sessionId: 'session-move', target: 'project', id: movedOut.body.id,
|
|
1336
|
-
});
|
|
1337
|
-
assert.equal(movedBack.status, 200);
|
|
1338
|
-
assert.equal(movedBack.body.to, 'project');
|
|
1339
|
-
assert.equal(store.loadIndex(store.scopeOf(moveCwd)).entries.some((row) => row.id === movedBack.body.id), true, '搬回当前项目');
|
|
1340
|
-
assert.equal(store.loadIndex(store.globalScope()).entries.some((row) => row.id === movedOut.body.id), false, '全局层不再有这条');
|
|
1341
|
-
|
|
1342
|
-
console.log('contract.test.mjs: all passed');
|