dsh-lite-memory 0.17.0 → 0.18.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -15
- package/TRIGGERS.md +1 -1
- package/client.js +343 -44
- package/docs/BENCH.md +10 -8
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +403 -131
- package/learning.js +65 -6
- package/package.json +7 -4
- package/state.js +56 -8
- package/store.js +361 -33
- package/test/client.test.mjs +79 -17
- package/test/contract.test.mjs +231 -93
- package/test/control.test.mjs +2 -2
- package/test/crossing.test.mjs +22 -1
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +33 -10
- package/test/ha.test.mjs +186 -0
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +80 -9
- package/test/record.test.mjs +130 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +97 -13
package/test/contract.test.mjs
CHANGED
|
@@ -67,13 +67,15 @@ const json = (body) => JSON.parse(body);
|
|
|
67
67
|
// ── tool contract ────────────────────────────────────────────────────────────
|
|
68
68
|
const names = ['memory_save', 'memory_suggest', 'memory_search', 'memory_read', 'memory_forget'];
|
|
69
69
|
assert.deepEqual([...defs.keys()].sort(), [...names].sort());
|
|
70
|
+
const TITLE_ADDRESSED = ['memory_read', 'memory_forget'];
|
|
70
71
|
for (const name of names) {
|
|
71
72
|
const def = defs.get(name);
|
|
72
73
|
assert.equal(def.parameters.type, 'object');
|
|
73
74
|
assert.equal(def.parameters.additionalProperties, false);
|
|
74
75
|
assert.equal(typeof def.output.render, 'function');
|
|
75
76
|
assert.equal(typeof def.output.schema, 'object');
|
|
76
|
-
assert.ok(def.parameters.
|
|
77
|
+
assert.ok(Object.keys(def.parameters.properties).length >= 1);
|
|
78
|
+
if (TITLE_ADDRESSED.includes(name)) assert.equal('title' in def.parameters.properties, true, `${name} 按标题定位`);
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const saveParams = defs.get('memory_save').parameters.properties;
|
|
@@ -82,6 +84,9 @@ for (const key of ['source', 'origin', 'confirmed', 'force', 'autoRecall']) {
|
|
|
82
84
|
assert.equal(key in saveParams, false, `memory_save 不该接受 ${key}`);
|
|
83
85
|
}
|
|
84
86
|
assert.equal(saveParams.mode.enum.includes('replace_current'), true);
|
|
87
|
+
/** 关联目标也按标题给,参数里不该出现 mem_ 开头的 id 字段。 */
|
|
88
|
+
assert.equal(saveParams.links.items.properties.id, undefined);
|
|
89
|
+
assert.equal(saveParams.links.items.properties.title.type, 'string');
|
|
85
90
|
assert.equal(JSON.stringify(saveParams.applicability.enum), JSON.stringify(['all_in_scope', 'conditional', 'unresolved']));
|
|
86
91
|
|
|
87
92
|
assert.deepEqual(saveParams.resolution.enum, ['distinct']);
|
|
@@ -89,12 +94,14 @@ assert.deepEqual(saveParams.resolution.enum, ['distinct']);
|
|
|
89
94
|
assert.equal(defs.get('memory_search').parameters.properties.limit.maximum, 4);
|
|
90
95
|
/** 空检索结果会带 noTriggerReason,输出 schema 必须声明它,否则宿主校验直接报错。 */
|
|
91
96
|
assert.equal(defs.get('memory_search').output.schema.properties.noTriggerReason.type, 'string');
|
|
92
|
-
|
|
97
|
+
/** 更新句柄只暴露标题:id 是底层实现,参数表里不该出现。 */
|
|
98
|
+
assert.equal(saveParams.updateTitle.type, 'string');
|
|
99
|
+
assert.equal(saveParams.id, undefined);
|
|
93
100
|
assert.deepEqual(saveParams.scope.enum, ['auto', 'project', 'global']);
|
|
94
101
|
assert.equal(saveParams.memoryClass.type, 'string');
|
|
95
102
|
assert.deepEqual(saveParams.memoryClass.enum, ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure']);
|
|
96
103
|
assert.deepEqual(saveParams.durability.enum, ['durable', 'task']);
|
|
97
|
-
assert.deepEqual(saveParams.sameTopicAs.required, ['scope', '
|
|
104
|
+
assert.deepEqual(saveParams.sameTopicAs.required, ['scope', 'title']);
|
|
98
105
|
assert.equal(defs.get('memory_suggest').parameters.properties.scope, undefined);
|
|
99
106
|
assert.equal(defs.get('memory_suggest').parameters.properties.resolution, undefined);
|
|
100
107
|
|
|
@@ -109,47 +116,56 @@ const phrased = await exec('memory_save', { title: '以后都用这个一次性
|
|
|
109
116
|
assert.equal(phrased.scope, 'project');
|
|
110
117
|
assert.equal(store.loadIndex(store.globalScope()).entries.some((item) => item.title === '以后都用这个一次性脚本'), false);
|
|
111
118
|
|
|
112
|
-
assert.equal(typeof saved.
|
|
113
|
-
assert.
|
|
119
|
+
assert.equal(typeof saved.title, 'string');
|
|
120
|
+
assert.equal(saved.id, undefined);
|
|
121
|
+
assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序"$/);
|
|
114
122
|
|
|
115
123
|
const merged = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n第二步' });
|
|
116
124
|
assert.equal(merged.merged, true);
|
|
117
|
-
assert.equal(merged.
|
|
125
|
+
assert.equal(merged.title, saved.title);
|
|
118
126
|
assert.equal(merged.revision, 2);
|
|
119
|
-
assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序"
|
|
127
|
+
assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序"$/);
|
|
120
128
|
|
|
121
|
-
const patch = await exec('memory_save', {
|
|
129
|
+
const patch = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
122
130
|
assert.equal(patch.when, '发版当天');
|
|
123
131
|
assert.match(render('memory_save', {}, patch), /(发版当天)/);
|
|
124
132
|
|
|
125
133
|
/** 同样的字段再存一次不算修改:不改版次,也不该被读成「存过一次」。 */
|
|
126
134
|
const again = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
127
135
|
assert.equal(again.changed, false);
|
|
128
|
-
assert.equal(again.
|
|
136
|
+
assert.equal(again.title, saved.title);
|
|
129
137
|
assert.equal(again.revision, 3);
|
|
130
|
-
assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序"
|
|
138
|
+
assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序"$/);
|
|
131
139
|
|
|
132
140
|
// ── similar title asks before overwriting ────────────────────────────────────
|
|
133
141
|
const probe = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条' });
|
|
134
142
|
assert.equal(probe.needsResolution, true);
|
|
135
|
-
assert.equal(probe.id,
|
|
143
|
+
assert.equal(probe.id, undefined);
|
|
136
144
|
assert.equal(probe.revision, 0);
|
|
137
|
-
assert.ok(probe.
|
|
145
|
+
assert.ok(probe.similarTitles.length > 0);
|
|
146
|
+
assert.doesNotMatch(probe.similarTitles, /mem_[a-z0-9]{6,}/, '相近候选按标题点名');
|
|
138
147
|
const probeText = render('memory_save', {}, probe);
|
|
139
148
|
assert.match(probeText.split('\n')[0], /^memory · 需要确认:「发布顺序是啥」与已有条目相近$/);
|
|
140
|
-
assert.match(probeText,
|
|
141
|
-
assert.match(probeText,
|
|
149
|
+
assert.match(probeText, /要更新它:用同一个标题再存一次/);
|
|
150
|
+
assert.match(probeText, /「发布顺序」/);
|
|
151
|
+
assert.doesNotMatch(probeText, /mem_[a-z0-9]{6,}/, '相近提示只给标题');
|
|
142
152
|
const distinct = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条', resolution: 'distinct' });
|
|
143
153
|
assert.equal(distinct.needsResolution, false);
|
|
144
154
|
assert.equal(distinct.merged, false);
|
|
145
|
-
assert.
|
|
155
|
+
assert.equal(distinct.id, undefined);
|
|
156
|
+
assert.notEqual(distinct.title, saved.title);
|
|
157
|
+
/** 刚存完就能查回来:不是只统计条数,命中的必须是刚存的那一条。 */
|
|
158
|
+
const justSaved = await exec('memory_search', { query: '发布顺序是啥' });
|
|
159
|
+
assert.ok(justSaved.count >= 1);
|
|
160
|
+
assert.match(render('memory_search', {}, justSaved), /「发布顺序是啥」/);
|
|
146
161
|
|
|
147
162
|
// ── search / read / forget ───────────────────────────────────────────────────
|
|
148
163
|
const hits = await exec('memory_search', { query: '发布流程' });
|
|
149
164
|
assert.ok(hits.count >= 1);
|
|
150
165
|
assert.ok(hits.chars > 0);
|
|
151
166
|
const hitText = render('memory_search', {}, hits);
|
|
152
|
-
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\]
|
|
167
|
+
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] 「`));
|
|
168
|
+
assert.doesNotMatch(hitText, /mem_[a-z0-9]{6,}/, '检索结果只给标题与适用范围');
|
|
153
169
|
assert.doesNotMatch(hitText, /第 .* 版/);
|
|
154
170
|
/** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
|
|
155
171
|
const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
|
|
@@ -157,29 +173,45 @@ assert.equal(miss.count, 0);
|
|
|
157
173
|
assert.equal(typeof miss.noTriggerReason, 'string');
|
|
158
174
|
assert.equal(render('memory_search', {}, miss), 'memory · 没有命中');
|
|
159
175
|
|
|
160
|
-
const read = await exec('memory_read', {
|
|
176
|
+
const read = await exec('memory_read', { title: saved.title });
|
|
161
177
|
assert.equal(read.status, 'needs_context');
|
|
162
|
-
assert.equal(read.body, '');
|
|
163
|
-
|
|
178
|
+
assert.equal(read.body, '', '条件未知时标题只能定位,不能证明适用,正文不返回');
|
|
179
|
+
assert.doesNotMatch(render('memory_read', {}, read), /发版/);
|
|
180
|
+
const plain = await exec('memory_read', { title: distinct.title });
|
|
164
181
|
assert.equal(plain.body, '另一条');
|
|
165
182
|
assert.equal(plain.revision, 1);
|
|
166
|
-
assert.match(render('memory_read', {}, plain),
|
|
183
|
+
assert.match(render('memory_read', {}, plain), /^memory · \[[^\]]+\] 「发布顺序是啥」/);
|
|
184
|
+
assert.ok(!render('memory_read', {}, plain).includes('mem_'));
|
|
185
|
+
assert.equal(plain.title, distinct.title);
|
|
186
|
+
|
|
187
|
+
/* 界面与工具文本一律不出现内部编号:id 只是存储地址。 */
|
|
188
|
+
for (const [name, value] of [['memory_save', saved], ['memory_save', merged], ['memory_save', patch],
|
|
189
|
+
['memory_search', hits], ['memory_read', plain], ['memory_save', probe]]) {
|
|
190
|
+
assert.doesNotMatch(render(name, {}, value), /\b(mem|sug|cf)_[a-z0-9]{6,}/, `${name} 文案里没有内部编号`);
|
|
191
|
+
}
|
|
167
192
|
|
|
168
193
|
// 没点明归档还是彻底忘掉时不动盘,先返回待确认
|
|
169
|
-
const undecided = await exec('memory_forget', {
|
|
194
|
+
const undecided = await exec('memory_forget', { title: distinct.title });
|
|
170
195
|
assert.equal(undecided.mode, 'needs_resolution');
|
|
171
|
-
assert.match(render('memory_forget', {}, undecided),
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
assert.
|
|
175
|
-
|
|
196
|
+
assert.match(render('memory_forget', {}, undecided), /^memory · 先确认是归档还是彻底忘掉 · 「发布顺序是啥」$/);
|
|
197
|
+
const distinctRow = store.search(store.scopeOf(cwd), distinct.title).find((row) => row.title === distinct.title);
|
|
198
|
+
assert.ok(distinctRow);
|
|
199
|
+
assert.ok(store.locate(store.scopeOf(cwd), distinctRow.id));
|
|
200
|
+
const archived = await exec('memory_forget', { title: distinct.title, mode: 'archive' });
|
|
201
|
+
assert.equal(archived.title, distinct.title);
|
|
202
|
+
assert.equal(archived.id, undefined);
|
|
203
|
+
assert.match(render('memory_forget', {}, archived), /^memory · archived \[[^\]]+\] "发布顺序是啥",可找回$/);
|
|
204
|
+
const readArchived = await exec('memory_read', { title: distinct.title });
|
|
205
|
+
assert.equal(readArchived.status, 'archived');
|
|
206
|
+
assert.equal(readArchived.body, '');
|
|
207
|
+
assert.match(render('memory_read', { title: distinct.title }, readArchived), /^memory · 这条记忆已归档$/);
|
|
176
208
|
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
177
209
|
|
|
178
210
|
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
179
211
|
const suggested = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
|
|
180
212
|
assert.equal(suggested.asked, true);
|
|
181
213
|
assert.match(suggested.id, /^sug_[a-z0-9]+$/);
|
|
182
|
-
assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查"
|
|
214
|
+
assert.match(render('memory_suggest', {}, suggested), /^memory · suggested "发布前跑文案检查"$/);
|
|
183
215
|
|
|
184
216
|
const duplicate = await exec('memory_suggest', { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
|
|
185
217
|
assert.equal(duplicate.asked, false);
|
|
@@ -234,40 +266,48 @@ assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 40
|
|
|
234
266
|
// ── global layer ─────────────────────────────────────────────────────────────
|
|
235
267
|
const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
|
|
236
268
|
assert.equal(global.scope, 'global');
|
|
237
|
-
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"
|
|
269
|
+
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
|
|
238
270
|
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
239
271
|
assert.equal(globalSearch.count, 1);
|
|
240
|
-
assert.match(render('memory_search', {}, globalSearch), /\[global\]
|
|
272
|
+
assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
|
|
241
273
|
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
242
274
|
const stats = globalThis.__liteMemoryStats(cwd);
|
|
243
275
|
assert.ok(stats.count >= 1);
|
|
244
276
|
|
|
245
277
|
// ── 引用条:会话读过哪几条,点开能拿到正文 ───────────────────────────────────
|
|
246
278
|
const cited = await exec('memory_save', { title: '引用测试', summary: '底部引用条要能点开正文', body: '引用正文第一行' });
|
|
247
|
-
await exec('memory_read', {
|
|
279
|
+
await exec('memory_read', { title: cited.title });
|
|
280
|
+
const citedRow = store.search(store.scopeOf(cwd), '引用测试').find((row) => row.title === '引用测试');
|
|
281
|
+
assert.ok(citedRow);
|
|
248
282
|
const cites = await call('/lite-memory/citations', { sessionId: 'session-contract' });
|
|
249
283
|
assert.equal(cites.status, 200);
|
|
250
|
-
assert.ok(cites.body.items.some((item) => item.id ===
|
|
284
|
+
assert.ok(cites.body.items.some((item) => item.id === citedRow.id && item.title === '引用测试'));
|
|
251
285
|
assert.equal(cites.body.items.some((item) => 'path' in item), false);
|
|
252
|
-
const opened = await call('/lite-memory/body', { sessionId: 'session-contract', id:
|
|
286
|
+
const opened = await call('/lite-memory/body', { sessionId: 'session-contract', id: citedRow.id });
|
|
253
287
|
assert.equal(opened.status, 200);
|
|
254
288
|
assert.match(opened.body.body, /引用正文第一行/);
|
|
289
|
+
/** 界面只有标题:按标题也要能点开,不能因为引用行不存 title 就报已删除。 */
|
|
290
|
+
const openedByTitle = await call('/lite-memory/body', { sessionId: 'session-contract', title: '引用测试', revision: opened.body.revision });
|
|
291
|
+
assert.equal(openedByTitle.status, 200);
|
|
292
|
+
assert.equal(openedByTitle.body.status, 'active');
|
|
293
|
+
assert.match(openedByTitle.body.body, /引用正文第一行/);
|
|
294
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-contract', title: '没有这条' })).body.status, 'gone');
|
|
255
295
|
assert.equal(opened.body.scopeLabel, path.basename(cwd));
|
|
256
296
|
const gone = await call('/lite-memory/body', { sessionId: 'session-contract', id: 'mem_999999999999' });
|
|
257
297
|
assert.equal(gone.status, 200);
|
|
258
298
|
assert.equal(gone.body.status, 'gone');
|
|
259
299
|
assert.equal('title' in gone.body, false);
|
|
260
300
|
/** 别的会话、别的工作目录都不能拿 id 探正文;本会话引用过或本目录这一层才行。 */
|
|
261
|
-
const foreignSession = await call('/lite-memory/body', { sessionId: 'session-contract-2', id:
|
|
301
|
+
const foreignSession = await call('/lite-memory/body', { sessionId: 'session-contract-2', id: citedRow.id });
|
|
262
302
|
assert.equal(foreignSession.status, 200);
|
|
263
303
|
assert.equal(foreignSession.body.status, 'gone');
|
|
264
304
|
assert.equal('body' in foreignSession.body, false);
|
|
265
|
-
const viaCwd = await call('/lite-memory/body', { cwd, id:
|
|
305
|
+
const viaCwd = await call('/lite-memory/body', { cwd, id: citedRow.id });
|
|
266
306
|
assert.equal(viaCwd.status, 200);
|
|
267
307
|
assert.match(viaCwd.body.body, /引用正文第一行/);
|
|
268
|
-
const foreignCwd = await call('/lite-memory/body', { cwd: path.join(os.tmpdir(), 'lite-memory-contract-foreign'), id:
|
|
308
|
+
const foreignCwd = await call('/lite-memory/body', { cwd: path.join(os.tmpdir(), 'lite-memory-contract-foreign'), id: citedRow.id });
|
|
269
309
|
assert.equal(foreignCwd.body.status, 'gone');
|
|
270
|
-
assert.equal((await call('/lite-memory/body', { id:
|
|
310
|
+
assert.equal((await call('/lite-memory/body', { id: citedRow.id })).status, 400);
|
|
271
311
|
assert.equal((await call('/lite-memory/citations', {})).status, 400);
|
|
272
312
|
assert.equal((await call('/lite-memory/citations', {}, { origin: 'http://evil.example' })).status, 403);
|
|
273
313
|
assert.notEqual((await call('/lite-memory/citations', { sessionId: 'session-contract' }, { origin: 'dsh-app://app' })).status, 403);
|
|
@@ -325,6 +365,8 @@ assert.equal(fs.existsSync(path.join(store.scopeOf(evoUnknown).dir, 'state.json'
|
|
|
325
365
|
|
|
326
366
|
// ── 自动召回:只在这句话跟已有记忆有关时出现,且不改命中计数 ─────────────────
|
|
327
367
|
const hinted = await exec('memory_save', { title: 'Chrome 弹钥匙串窗口要带 mock keychain', summary: '临时 profile 起 Chrome 会弹钥匙串窗口' });
|
|
368
|
+
const hintedRow = store.search(store.scopeOf(cwd), 'mock keychain').find((row) => row.title === hinted.title);
|
|
369
|
+
assert.ok(hintedRow);
|
|
328
370
|
// 只有一句 when、没有结构化条件的条目属于未解决,不自动注入
|
|
329
371
|
await exec('memory_save', { title: '发布窗口在周四晚上', summary: '只在周四晚上发版', when: '周四晚上' });
|
|
330
372
|
const recallAgent = { id: 'session-contract', cwd, session: { id: 'session-recall', header: { cwd } } };
|
|
@@ -342,17 +384,43 @@ assert.equal(recall.interpolate, false);
|
|
|
342
384
|
assert.equal(recall.required, false);
|
|
343
385
|
|
|
344
386
|
const sig1 = new AbortController().signal;
|
|
345
|
-
const hitsBefore = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id ===
|
|
387
|
+
const hitsBefore = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
346
388
|
await preStep({ agent: recallAgent, signal: sig1, turn: '2' }, pass('又弹钥匙串窗口了,怎么弄', 'h1'));
|
|
347
389
|
const hintText = recall.text({ agent: recallAgent, signal: sig1 });
|
|
348
|
-
assert.
|
|
390
|
+
assert.doesNotMatch(hintText, /mem_[a-z0-9]{6,}/, '召回卡只给标题,不给内部编号');
|
|
349
391
|
assert.match(hintText, /需要细节用 memory_read/);
|
|
350
|
-
|
|
392
|
+
sessionCwds.set('session-recall', cwd);
|
|
393
|
+
const recallReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
394
|
+
assert.equal(recallReceipt.status, 200);
|
|
395
|
+
assert.equal(recallReceipt.body.current.recall.reason, 'hit', '面板能回答这次为什么提醒');
|
|
396
|
+
assert.equal(recallReceipt.body.current.recall.limit, 3);
|
|
397
|
+
assert.equal(recallReceipt.body.current.recall.cards, 1);
|
|
398
|
+
assert.ok(recallReceipt.body.current.recall.chars > 0);
|
|
399
|
+
assert.ok(!/mem_[a-z0-9]{6,}/.test(JSON.stringify(recallReceipt.body.current.recall)), '判断依据不带内部编号');
|
|
400
|
+
const missTurn = await preStep({ agent: recallAgent, signal: sig1, turn: '3' }, pass('把这段视频的字幕导出来', 'h2'));
|
|
401
|
+
assert.equal(missTurn.kind, 'enter');
|
|
402
|
+
assert.equal(recall.text({ agent: recallAgent, signal: sig1 }), '');
|
|
403
|
+
const missReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
404
|
+
assert.ok(['no_overlap', 'needs_context'].includes(missReceipt.body.current.recall.reason), '没提醒时也说明依据');
|
|
405
|
+
assert.equal(missReceipt.body.current.recall.cards, 0);
|
|
406
|
+
const hitsAfter = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hintedRow.id).hits;
|
|
351
407
|
assert.equal(hitsAfter, hitsBefore);
|
|
352
408
|
|
|
353
409
|
const sig2 = new AbortController().signal;
|
|
354
410
|
await preStep({ agent: recallAgent, signal: sig2, turn: '3' }, pass('周四晚上发版要注意什么', 'h2'));
|
|
355
411
|
assert.equal(recall.text({ agent: recallAgent, signal: sig2 }).includes('发布窗口在周四晚上'), false);
|
|
412
|
+
const waitingReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
413
|
+
assert.equal(waitingReceipt.body.current.recall.reason, 'needs_context', '有候选但条件未确认时,不误报成没有匹配');
|
|
414
|
+
assert.equal(waitingReceipt.body.current.recall.cards, 0);
|
|
415
|
+
|
|
416
|
+
// 同一主题的两条现行规范互相冲突:整组不进自动召回,但面板要说是「相关但没确认」,不是「没有匹配」
|
|
417
|
+
await exec('memory_save', { title: '发版前检查清单', summary: '发版前先跑一遍检查清单', topicKey: 'release-checklist' });
|
|
418
|
+
await exec('memory_save', { title: '发版前通知渠道', summary: '发版前在群里通知相关人', topicKey: 'release-checklist', resolution: 'distinct' });
|
|
419
|
+
await preStep({ agent: recallAgent, signal: sig2, turn: '4' }, pass('发版前要做哪些检查', 'h4'));
|
|
420
|
+
const conflictReceipt = await call('/lite-memory/overview', { sessionId: 'session-recall' });
|
|
421
|
+
assert.equal(conflictReceipt.body.current.recall.reason, 'needs_context', '冲突未解决时也不能报成没有匹配');
|
|
422
|
+
assert.equal(conflictReceipt.body.current.recall.cards, 0);
|
|
423
|
+
assert.ok(conflictReceipt.body.current.recall.matched >= 1);
|
|
356
424
|
|
|
357
425
|
const sig3 = new AbortController().signal;
|
|
358
426
|
await preStep({ agent: recallAgent, signal: sig3, turn: '4' }, pass('把这个按钮的圆角改小一点', 'h3'));
|
|
@@ -371,11 +439,13 @@ const evoRule = await defs.get('memory_save').execute(
|
|
|
371
439
|
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
372
440
|
{ agent: evoAgentSeed },
|
|
373
441
|
);
|
|
442
|
+
const evoRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === evoRule.title);
|
|
443
|
+
assert.ok(evoRow, '口播开头落在这一层');
|
|
374
444
|
state.transact(evoReadScope, {}, (draft) => {
|
|
375
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
445
|
+
const row = draft.entries.find((entry) => entry.id === evoRow.id);
|
|
376
446
|
row.origin = 'project_evolution';
|
|
377
447
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
378
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution' } };
|
|
448
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution', specDigest: 'seeded' } };
|
|
379
449
|
return { changed: true };
|
|
380
450
|
});
|
|
381
451
|
await store.setTaskDefault(evoReadScope, { seriesId: '口播' });
|
|
@@ -410,14 +480,14 @@ assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }), '');
|
|
|
410
480
|
const evoManage = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
411
481
|
assert.equal(evoManage.status, 200);
|
|
412
482
|
assert.equal(evoManage.body.rules.length, 1);
|
|
413
|
-
assert.equal(evoManage.body.rules[0].id,
|
|
483
|
+
assert.equal(evoManage.body.rules[0].id, evoRow.id);
|
|
414
484
|
assert.ok(evoManage.body.payload.bytes > 0);
|
|
415
485
|
const evoDeleteStale = await call('/lite-memory/evolution', {
|
|
416
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id:
|
|
486
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision - 1,
|
|
417
487
|
});
|
|
418
488
|
assert.equal(evoDeleteStale.status, 409);
|
|
419
489
|
const evoDelete = await call('/lite-memory/evolution', {
|
|
420
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id:
|
|
490
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision,
|
|
421
491
|
});
|
|
422
492
|
assert.equal(evoDelete.status, 200);
|
|
423
493
|
assert.equal(evoDelete.body.ok, true);
|
|
@@ -427,44 +497,46 @@ const manageSeed = await defs.get('memory_save').execute(
|
|
|
427
497
|
{ title: '口播收尾', summary: '口播收尾留一句行动', body: '结尾给一句具体行动。' },
|
|
428
498
|
{ agent: evoAgent },
|
|
429
499
|
);
|
|
500
|
+
const manageRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === manageSeed.title);
|
|
501
|
+
assert.ok(manageRow, '口播收尾落在这一层');
|
|
430
502
|
state.transact(evoReadScope, {}, (draft) => {
|
|
431
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
503
|
+
const row = draft.entries.find((entry) => entry.id === manageRow.id);
|
|
432
504
|
row.origin = 'project_evolution';
|
|
433
505
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
434
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution' } };
|
|
506
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution', specDigest: 'seeded' } };
|
|
435
507
|
return { changed: true };
|
|
436
508
|
});
|
|
437
509
|
const manageSnap = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
438
|
-
const manageRule = manageSnap.body.rules.find((rule) => rule.id ===
|
|
510
|
+
const manageRule = manageSnap.body.rules.find((rule) => rule.id === manageRow.id);
|
|
439
511
|
assert.ok(manageRule);
|
|
440
512
|
assert.equal(manageRule.state, 'pending');
|
|
441
513
|
const manageEdit = await call('/lite-memory/evolution', {
|
|
442
|
-
sessionId: 'session-evo-read', learningAction: 'edit', id:
|
|
514
|
+
sessionId: 'session-evo-read', learningAction: 'edit', id: manageRow.id,
|
|
443
515
|
expectedRevision: manageRule.revision, body: '结尾给一句具体动作。',
|
|
444
516
|
});
|
|
445
517
|
assert.equal(manageEdit.status, 200);
|
|
446
518
|
assert.equal(manageEdit.body.ok, true);
|
|
447
|
-
const afterManageEdit = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id ===
|
|
519
|
+
const afterManageEdit = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id);
|
|
448
520
|
assert.equal(afterManageEdit.state, 'pending');
|
|
449
521
|
assert.equal(afterManageEdit.body, '结尾给一句具体动作。');
|
|
450
522
|
const manageAdopt = await call('/lite-memory/evolution', {
|
|
451
|
-
sessionId: 'session-evo-read', learningAction: 'adopt', id:
|
|
523
|
+
sessionId: 'session-evo-read', learningAction: 'adopt', id: manageRow.id, expectedRevision: afterManageEdit.revision,
|
|
452
524
|
});
|
|
453
525
|
assert.equal(manageAdopt.status, 200);
|
|
454
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id ===
|
|
526
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id).state, 'active');
|
|
455
527
|
const managePause = await call('/lite-memory/evolution', {
|
|
456
|
-
sessionId: 'session-evo-read', learningAction: 'pause', ids: [
|
|
528
|
+
sessionId: 'session-evo-read', learningAction: 'pause', ids: [manageRow.id],
|
|
457
529
|
});
|
|
458
530
|
assert.equal(managePause.status, 200);
|
|
459
|
-
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id ===
|
|
531
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id).state, 'paused');
|
|
460
532
|
const manageResume = await call('/lite-memory/evolution', {
|
|
461
|
-
sessionId: 'session-evo-read', learningAction: 'resume', id:
|
|
533
|
+
sessionId: 'session-evo-read', learningAction: 'resume', id: manageRow.id,
|
|
462
534
|
});
|
|
463
535
|
assert.equal(manageResume.status, 200);
|
|
464
|
-
const manageResumed = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id ===
|
|
536
|
+
const manageResumed = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageRow.id);
|
|
465
537
|
assert.equal(manageResumed.state, 'trial');
|
|
466
538
|
const manageDiscard = await call('/lite-memory/evolution', {
|
|
467
|
-
sessionId: 'session-evo-read', learningAction: 'discard', id:
|
|
539
|
+
sessionId: 'session-evo-read', learningAction: 'discard', id: manageRow.id, expectedRevision: manageResumed.revision,
|
|
468
540
|
});
|
|
469
541
|
assert.equal(manageDiscard.status, 200);
|
|
470
542
|
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.length, 0);
|
|
@@ -478,10 +550,12 @@ const closedSeed = await defs.get('memory_save').execute(
|
|
|
478
550
|
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
479
551
|
{ agent: closedAgent },
|
|
480
552
|
);
|
|
553
|
+
const closedRow = store.loadIndex(closedScope).entries.find((entry) => entry.title === closedSeed.title);
|
|
554
|
+
assert.ok(closedRow, '关闭前的样例落在这一层');
|
|
481
555
|
state.transact(closedScope, {}, (draft) => {
|
|
482
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
556
|
+
const row = draft.entries.find((entry) => entry.id === closedRow.id);
|
|
483
557
|
row.origin = 'project_evolution';
|
|
484
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active' } };
|
|
558
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', specDigest: 'seeded' } };
|
|
485
559
|
return { changed: true };
|
|
486
560
|
});
|
|
487
561
|
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-closed', enabled: true })).status, 200);
|
|
@@ -499,7 +573,7 @@ const closedRevision = closedSnapshot.body.rules[0].revision;
|
|
|
499
573
|
for (const action of ['adopt', 'edit', 'delete', 'pause', 'resume', 'pin', 'undo']) {
|
|
500
574
|
const payload = { sessionId: 'session-closed', learningAction: action, id: closedSeed.id, expectedRevision: closedRevision, body: '改过的正文' };
|
|
501
575
|
if (action === 'pin') payload.pinned = true;
|
|
502
|
-
if (action === 'pause') payload.ids = [
|
|
576
|
+
if (action === 'pause') payload.ids = [closedRow.id];
|
|
503
577
|
const result = await call('/lite-memory/evolution', payload);
|
|
504
578
|
assert.equal(result.status, 409, action);
|
|
505
579
|
assert.equal(result.body.code, 'memory_disabled', action);
|
|
@@ -510,7 +584,7 @@ const closedTool = await defs.get('memory_save').execute(
|
|
|
510
584
|
);
|
|
511
585
|
assert.equal(closedTool.changed, false);
|
|
512
586
|
assert.equal(closedTool.reason, 'memory_disabled');
|
|
513
|
-
assert.equal(closedTool.id,
|
|
587
|
+
assert.equal(closedTool.id, undefined);
|
|
514
588
|
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
515
589
|
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
516
590
|
assert.equal(reopened.status, 200);
|
|
@@ -523,18 +597,25 @@ const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(arg
|
|
|
523
597
|
const savedMeta = metaOf('memory_save', {}, saved);
|
|
524
598
|
assert.equal(savedMeta.kind, 'memory-save');
|
|
525
599
|
assert.equal(savedMeta.recordKind, saved.kind);
|
|
526
|
-
assert.equal(savedMeta.id,
|
|
600
|
+
assert.equal(savedMeta.id, undefined);
|
|
527
601
|
assert.equal(savedMeta.title, '发布顺序');
|
|
528
602
|
assert.equal(savedMeta.changed, true);
|
|
529
603
|
assert.ok(['created', 'updated'].includes(savedMeta.mode));
|
|
530
604
|
assert.equal('path' in savedMeta, false);
|
|
531
605
|
const searchMeta = metaOf('memory_search', {}, globalSearch);
|
|
532
|
-
assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['
|
|
533
|
-
|
|
606
|
+
assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['kind', 'origin', 'revision', 'scope', 'status', 'title', 'updated', 'when']);
|
|
607
|
+
/** 输出 schema 不能再要求内部 id:引用对外只有标题,宿主校验会因此整条工具报错。 */
|
|
608
|
+
const searchRefSchema = defs.get('memory_search').output.schema.properties.refs.items;
|
|
609
|
+
assert.equal('id' in searchRefSchema.properties, false);
|
|
610
|
+
assert.deepEqual([...searchRefSchema.required].sort(), ['kind', 'origin', 'revision', 'scope', 'status', 'title', 'when']);
|
|
611
|
+
assert.equal(searchMeta.refs[0].title, '回复用中文');
|
|
612
|
+
assert.equal(searchMeta.refs[0].scope, 'global');
|
|
534
613
|
/** 引用要能区分偏好/约定/流程/资料,界面才不必猜标题文案。 */
|
|
535
|
-
assert.equal(searchMeta.refs[0].kind, store.loadIndex(store.globalScope()).entries.find((item) => item.
|
|
614
|
+
assert.equal(searchMeta.refs[0].kind, store.loadIndex(store.globalScope()).entries.find((item) => item.title === '回复用中文').kind);
|
|
536
615
|
assert.equal(searchMeta.refs[0].when, '');
|
|
537
|
-
|
|
616
|
+
/** 出处行要说「N 天前更新」,只给时间不进正文。 */
|
|
617
|
+
assert.equal(typeof searchMeta.refs[0].updated, 'string');
|
|
618
|
+
const readValue = await exec('memory_read', { title: '回复用中文' });
|
|
538
619
|
const readMeta = metaOf('memory_read', {}, readValue);
|
|
539
620
|
assert.equal(readMeta.kind, 'memory-read');
|
|
540
621
|
assert.equal(readMeta.recordKind, readValue.kind);
|
|
@@ -550,10 +631,10 @@ if (suggestMeta.asked === true) assert.match(suggestMeta.id, /^sug_[a-z0-9]+$/);
|
|
|
550
631
|
// ── 撤销带着当时的版本:过期就冲突,不覆盖后来的修改 ─────────────────────────
|
|
551
632
|
const guarded = await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本' });
|
|
552
633
|
await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本', body: '第二版正文' });
|
|
553
|
-
const stale = await call('/lite-memory/undo', { op: 'undo',
|
|
634
|
+
const stale = await call('/lite-memory/undo', { op: 'undo', title: '版本守卫', revision: 1, cwd });
|
|
554
635
|
assert.equal(stale.status, 409);
|
|
555
|
-
const fresh = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.
|
|
556
|
-
const accepted = await call('/lite-memory/undo', { op: 'undo',
|
|
636
|
+
const fresh = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.title === '版本守卫');
|
|
637
|
+
const accepted = await call('/lite-memory/undo', { op: 'undo', title: '版本守卫', revision: fresh.revision, cwd });
|
|
557
638
|
assert.equal(accepted.status, 200);
|
|
558
639
|
|
|
559
640
|
// ── 第二组:确认前后语义一致、比较口径统一 ──────────────────────────────────
|
|
@@ -594,9 +675,9 @@ const narrow = await exec2('memory_save', {
|
|
|
594
675
|
conditions: [{ key: 'targetOs', value: 'windows' }],
|
|
595
676
|
});
|
|
596
677
|
assert.equal(narrow.changed, true);
|
|
597
|
-
const narrowed = await exec2('memory_save', {
|
|
678
|
+
const narrowed = await exec2('memory_save', { title: 'windows 上跑构建', summary: 'windows 构建用这条命令' });
|
|
598
679
|
assert.equal(narrowed.changed, true);
|
|
599
|
-
const narrowRecord = projectEntries().find((item) => item.
|
|
680
|
+
const narrowRecord = projectEntries().find((item) => item.title === 'windows 上跑构建');
|
|
600
681
|
assert.equal(narrowRecord.applicability, 'conditional');
|
|
601
682
|
assert.deepEqual(narrowRecord.conditions, [{ key: 'targetOs', value: 'windows' }]);
|
|
602
683
|
|
|
@@ -621,11 +702,11 @@ const boxB = await exec2('memory_save', {
|
|
|
621
702
|
conditions: [{ key: 'machine', value: 'box-b' }],
|
|
622
703
|
});
|
|
623
704
|
assert.equal(boxB.changed, true);
|
|
624
|
-
assert.notEqual(boxB.id, boxA.id);
|
|
625
705
|
const bothBoxes = projectEntries().filter((item) => item.title === '冒烟顺序' && item.status === 'active');
|
|
626
706
|
assert.equal(bothBoxes.length, 2);
|
|
627
707
|
assert.deepEqual(bothBoxes.map((item) => item.conditions[0].value).sort(), ['box-a', 'box-b']);
|
|
628
|
-
assert.equal(bothBoxes.find((item) => item.
|
|
708
|
+
assert.equal(bothBoxes.find((item) => item.conditions[0].value === 'box-a').body, '先登录再下单');
|
|
709
|
+
assert.equal(bothBoxes.find((item) => item.conditions[0].value === 'box-b').body, '先建号再登录');
|
|
629
710
|
|
|
630
711
|
/** 同一主题、条件一致但正文不同:读取要报冲突,不能悄悄挑一条当现行规范。 */
|
|
631
712
|
const slotA = await exec2('memory_save', {
|
|
@@ -646,7 +727,9 @@ const slotB = await exec2('memory_save', {
|
|
|
646
727
|
topicKey: '发版检查',
|
|
647
728
|
resolution: 'distinct',
|
|
648
729
|
});
|
|
649
|
-
|
|
730
|
+
const slotEntries = projectEntries().filter((item) => item.title === '发版检查项');
|
|
731
|
+
assert.equal(slotEntries.length, 2);
|
|
732
|
+
assert.notEqual(slotEntries[0].id, slotEntries[1].id);
|
|
650
733
|
const resolvedSlots = store.visibleRules(projectScope, {}).resolved;
|
|
651
734
|
const slotRow = [...resolvedSlots.values()].find((row) => row.key === '发版检查');
|
|
652
735
|
assert.ok(slotRow);
|
|
@@ -654,7 +737,7 @@ assert.equal(slotRow.status, 'conflict');
|
|
|
654
737
|
|
|
655
738
|
/** 工具参数里的 links / selection.items / topicKey:null 是合法输入,不能被 schema 挡掉或被校验器拒绝。 */
|
|
656
739
|
assert.equal(saveParams.links.items.type, 'object');
|
|
657
|
-
assert.deepEqual(saveParams.links.items.required, ['
|
|
740
|
+
assert.deepEqual(saveParams.links.items.required, ['title']);
|
|
658
741
|
assert.equal(saveParams.selection.properties.items.type, 'array');
|
|
659
742
|
assert.deepEqual(saveParams.topicKey.type, ['string', 'null']);
|
|
660
743
|
const linkedA = await exec2('memory_save', { title: '关联目标甲', summary: '用来当关联目标', body: '甲', scope: 'project' });
|
|
@@ -664,13 +747,21 @@ const linkedB = await exec2('memory_save', {
|
|
|
664
747
|
body: '乙',
|
|
665
748
|
scope: 'project',
|
|
666
749
|
resolution: 'distinct',
|
|
667
|
-
links: [{ type: 'related',
|
|
750
|
+
links: [{ type: 'related', title: linkedA.title }],
|
|
668
751
|
});
|
|
669
752
|
assert.equal(linkedB.changed, true, JSON.stringify(linkedB));
|
|
670
|
-
const linkedRecord = projectEntries().find((item) => item.
|
|
753
|
+
const linkedRecord = projectEntries().find((item) => item.title === '关联目标乙');
|
|
754
|
+
const linkedTarget = projectEntries().find((item) => item.title === '关联目标甲');
|
|
755
|
+
assert.ok(linkedRecord && linkedTarget);
|
|
671
756
|
assert.equal(linkedRecord.links.length, 1);
|
|
672
|
-
assert.equal(linkedRecord.links[0].id,
|
|
757
|
+
assert.equal(linkedRecord.links[0].id, linkedTarget.id);
|
|
673
758
|
assert.equal(linkedRecord.links[0].type, 'related');
|
|
759
|
+
/** 更新句柄也是标题:抄标题改这一条,不需要任何内部编号。 */
|
|
760
|
+
const retitled = await exec2('memory_save', { updateTitle: linkedA.title, title: linkedA.title, summary: '改过的摘要' });
|
|
761
|
+
assert.equal(retitled.changed, true);
|
|
762
|
+
assert.equal(retitled.summary, '改过的摘要');
|
|
763
|
+
const missingUpdate = await exec2('memory_save', { updateTitle: '从来没有存过的标题', title: '新的一条', summary: '新' });
|
|
764
|
+
assert.match(missingUpdate.error, /没有「从来没有存过的标题」这条记忆/);
|
|
674
765
|
|
|
675
766
|
|
|
676
767
|
// ── 项目实验:正常回合结束后走一次短整理,只写后台结果 ─────────────────────────
|
|
@@ -696,7 +787,7 @@ llmImpl = {
|
|
|
696
787
|
? { target: ref, feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'], persistence: 'project_candidate' }
|
|
697
788
|
: orgMode === 'sample'
|
|
698
789
|
? { target: ref, feedbackType: 'artifact_accept', evidence: ['u0'], persistence: 'project_candidate' }
|
|
699
|
-
: { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论。', applicability: '
|
|
790
|
+
: { 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' };
|
|
700
791
|
yield { type: 'text-delta', text: JSON.stringify({ changes: [change] }) };
|
|
701
792
|
yield { type: 'usage', usage: { inputTokens: 12, outputTokens: 6 } };
|
|
702
793
|
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
@@ -810,11 +901,12 @@ assert.equal(ov.body.ok, true);
|
|
|
810
901
|
assert.equal(ov.body.current.key, store.scopeOf(ovCwd).key);
|
|
811
902
|
assert.equal(ov.body.current.label, path.basename(ovCwd));
|
|
812
903
|
assert.equal(ov.body.current.evolution.enabled, false);
|
|
904
|
+
assert.equal(ov.body.current.recall, null, '没发生过自动召回时不给判断依据');
|
|
813
905
|
assert.equal(ov.body.selected.key, ov.body.current.key);
|
|
814
906
|
assert.equal(ov.body.selected.kind, 'project');
|
|
815
907
|
assert.equal(ov.body.selected.evolution.enabled, false);
|
|
816
908
|
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
817
|
-
assert.ok(ov.body.selected.records.some((row) => row.
|
|
909
|
+
assert.ok(ov.body.selected.records.some((row) => row.title === '开场先给结论'));
|
|
818
910
|
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
819
911
|
assert.equal(ov.body.selected.stats.total, 2);
|
|
820
912
|
assert.equal(ov.body.selected.stats.tags, 3);
|
|
@@ -837,16 +929,62 @@ assert.equal(ovGlobal.status, 200);
|
|
|
837
929
|
assert.equal(ovGlobal.body.selected.kind, 'global');
|
|
838
930
|
assert.ok(ovGlobal.body.selected.records.some((row) => row.title === '观察全局'));
|
|
839
931
|
|
|
840
|
-
//
|
|
841
|
-
const
|
|
932
|
+
// 面板改正文:只动当前项目、必须带读到的版本,冲突和改名撞车都不覆盖
|
|
933
|
+
const ovRow = ov.body.selected.records.find((row) => row.title === '开场先给结论');
|
|
934
|
+
const ovEntry = await call('/lite-memory/entry', {
|
|
935
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
936
|
+
title: '开场先给结论', body: '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', expectedRevision: ovRow.revision,
|
|
937
|
+
});
|
|
938
|
+
assert.equal(ovEntry.status, 200);
|
|
939
|
+
assert.equal(ovEntry.body.ok, true);
|
|
940
|
+
assert.equal(ovEntry.body.title, '开场先给结论');
|
|
941
|
+
assert.ok(ovEntry.body.revision > ovRow.revision, '改过就升版本');
|
|
942
|
+
const ovKept = store.readEntry(store.scopeOf(ovCwd), ovRow.id);
|
|
943
|
+
assert.deepEqual(ovKept.tags, ['口播', '开头'], '只改标题正文,标签等字段保持');
|
|
944
|
+
assert.equal(ovKept.kind, 'note');
|
|
945
|
+
const ovEditedBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
946
|
+
assert.equal(ovEditedBody.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。');
|
|
947
|
+
const ovStale = await call('/lite-memory/entry', {
|
|
948
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
949
|
+
title: '开场先给结论', body: '被旧版本覆盖。', expectedRevision: ovRow.revision,
|
|
950
|
+
});
|
|
951
|
+
assert.equal(ovStale.status, 409);
|
|
952
|
+
assert.equal(ovStale.body.retry, true, '版本过期要明说可以重试');
|
|
953
|
+
const ovAfterStale = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
954
|
+
assert.equal(ovAfterStale.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', '旧版本写不进去');
|
|
955
|
+
const ovRenameClash = await call('/lite-memory/entry', {
|
|
956
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovRow.id,
|
|
957
|
+
title: '封面留白', body: '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。', expectedRevision: ovEntry.body.revision,
|
|
958
|
+
});
|
|
959
|
+
assert.equal(ovRenameClash.status, 400, '改名撞上另一条要拒绝');
|
|
960
|
+
assert.match(ovRenameClash.body.reason, /同名/);
|
|
961
|
+
const ovOtherEdit = await call('/lite-memory/entry', {
|
|
962
|
+
sessionId: 'session-overview', scopeKey: ov.body.current.key, id: ovOther.body && ovOther.body.record ? ovOther.body.record.id : 'mem_other',
|
|
963
|
+
title: '别的项目', body: '想跨项目改。', expectedRevision: 1,
|
|
964
|
+
});
|
|
965
|
+
assert.equal(ovOtherEdit.status >= 400, true, '别的项目不能从这里改');
|
|
966
|
+
const ovGlobalRow = ovGlobal.body.selected.records.find((row) => row.title === '观察全局');
|
|
967
|
+
const ovGlobalEdit = await call('/lite-memory/entry', {
|
|
968
|
+
sessionId: 'session-overview', scopeKey: 'global', id: ovGlobalRow.id,
|
|
969
|
+
title: '观察全局', body: '想改全局。', expectedRevision: ovGlobalRow.revision,
|
|
970
|
+
});
|
|
971
|
+
assert.equal(ovGlobalEdit.status, 409, '全局层在项目会话里只读');
|
|
972
|
+
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);
|
|
973
|
+
|
|
974
|
+
// 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行。
|
|
975
|
+
// 宫殿里点开一条只走这个只读入口:不改记忆、不记用量、不占额度。
|
|
976
|
+
const ovStateFile = path.join(store.scopeOf(ovCwd).dir, 'state.json');
|
|
977
|
+
const ovStateBefore = fs.readFileSync(ovStateFile, 'utf8');
|
|
978
|
+
const ovBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: ov.body.current.key });
|
|
842
979
|
assert.equal(ovBody.status, 200);
|
|
843
|
-
assert.equal(
|
|
980
|
+
assert.equal(fs.readFileSync(ovStateFile, 'utf8'), ovStateBefore, '面板看正文不写状态');
|
|
981
|
+
assert.equal(ovBody.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。');
|
|
844
982
|
assert.equal(ovBody.body.scopeLabel, path.basename(ovCwd));
|
|
845
|
-
const ovOtherBody = await call('/lite-memory/body', { sessionId: 'session-overview',
|
|
983
|
+
const ovOtherBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '别的项目', scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
846
984
|
assert.equal(ovOtherBody.status, 200);
|
|
847
985
|
assert.equal(ovOtherBody.body.scopeLabel, path.basename(ovOtherCwd));
|
|
848
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview',
|
|
849
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview',
|
|
986
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: 'nope' })).status, 404);
|
|
987
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', title: '开场先给结论', scopeKey: '../x' })).status, 404);
|
|
850
988
|
assert.equal((await call('/lite-memory/overview', {})).status, 400);
|
|
851
989
|
assert.equal((await call('/lite-memory/overview', { sessionId: 'session-overview' }, { origin: 'http://evil.example' })).status, 403);
|
|
852
990
|
const ovUnknownCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-unknown-'));
|
|
@@ -860,27 +998,27 @@ assert.equal(fs.existsSync(path.join(store.scopeOf(ovUnknownCwd).dir, 'state.jso
|
|
|
860
998
|
|
|
861
999
|
// 实验条目也画进图里,但只有面板能看见;普通读取面仍然拿不到
|
|
862
1000
|
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
863
|
-
const row = draft.entries.find((entry) => entry.
|
|
1001
|
+
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
864
1002
|
row.origin = 'project_evolution';
|
|
865
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution' } };
|
|
1003
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution', specDigest: 'seeded' } };
|
|
866
1004
|
return { changed: true };
|
|
867
1005
|
});
|
|
868
1006
|
const ovExperimental = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
869
1007
|
assert.equal(ovExperimental.body.selected.stats.experimental, 1);
|
|
870
|
-
assert.equal(ovExperimental.body.selected.records.find((row) => row.
|
|
871
|
-
assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.
|
|
1008
|
+
assert.equal(ovExperimental.body.selected.records.find((row) => row.title === '封面留白').experimental, true);
|
|
1009
|
+
assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.title === '封面留白'), true);
|
|
872
1010
|
|
|
873
1011
|
// 归档不是死数字:总数诚实,列表单独取,正文还能按 id 打开
|
|
874
1012
|
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
875
|
-
const row = draft.entries.find((entry) => entry.
|
|
1013
|
+
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
876
1014
|
row.status = 'archived';
|
|
877
1015
|
return { changed: true };
|
|
878
1016
|
});
|
|
879
1017
|
const ovArchived = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
880
1018
|
assert.equal(ovArchived.body.selected.stats.archived, 1);
|
|
881
|
-
assert.equal(ovArchived.body.selected.records.some((row) => row.
|
|
882
|
-
assert.equal(ovArchived.body.selected.archived.some((row) => row.
|
|
883
|
-
const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview',
|
|
1019
|
+
assert.equal(ovArchived.body.selected.records.some((row) => row.title === '封面留白'), false);
|
|
1020
|
+
assert.equal(ovArchived.body.selected.archived.some((row) => row.title === '封面留白'), true);
|
|
1021
|
+
const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview', title: '封面留白', scopeKey: ovArchived.body.selected.key });
|
|
884
1022
|
assert.equal(ovArchivedBody.status, 200);
|
|
885
1023
|
assert.equal(ovArchivedBody.body.status, 'archived');
|
|
886
1024
|
assert.equal(ovArchivedBody.body.body, '右侧留白。');
|