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.
Files changed (42) hide show
  1. package/README.md +15 -15
  2. package/TRIGGERS.md +1 -1
  3. package/client.js +343 -44
  4. package/docs/BENCH.md +10 -8
  5. package/docs/RELEASE-0.18.0.md +35 -0
  6. package/docs/RELEASE-0.18.1.md +18 -0
  7. package/docs/RELEASE-0.18.10.md +25 -0
  8. package/docs/RELEASE-0.18.11.md +48 -0
  9. package/docs/RELEASE-0.18.12.md +27 -0
  10. package/docs/RELEASE-0.18.13.md +13 -0
  11. package/docs/RELEASE-0.18.14.md +13 -0
  12. package/docs/RELEASE-0.18.15.md +5 -0
  13. package/docs/RELEASE-0.18.2.md +21 -0
  14. package/docs/RELEASE-0.18.3.md +16 -0
  15. package/docs/RELEASE-0.18.4.md +15 -0
  16. package/docs/RELEASE-0.18.5.md +21 -0
  17. package/docs/RELEASE-0.18.6.md +18 -0
  18. package/docs/RELEASE-0.18.7.md +23 -0
  19. package/docs/RELEASE-0.18.8.md +18 -0
  20. package/docs/RELEASE-0.18.9.md +24 -0
  21. package/index.js +403 -131
  22. package/learning.js +65 -6
  23. package/package.json +7 -4
  24. package/state.js +56 -8
  25. package/store.js +361 -33
  26. package/test/client.test.mjs +79 -17
  27. package/test/contract.test.mjs +231 -93
  28. package/test/control.test.mjs +2 -2
  29. package/test/crossing.test.mjs +22 -1
  30. package/test/eval-seed.test.mjs +113 -0
  31. package/test/evolution-read.test.mjs +33 -10
  32. package/test/ha.test.mjs +186 -0
  33. package/test/layer-read.test.mjs +293 -0
  34. package/test/learning.test.mjs +80 -0
  35. package/test/palace.test.mjs +2 -0
  36. package/test/recall.test.mjs +80 -9
  37. package/test/record.test.mjs +130 -0
  38. package/test/safety.test.mjs +119 -0
  39. package/test/state.test.mjs +57 -1
  40. package/test/store.test.mjs +49 -5
  41. package/test/view.test.mjs +3 -0
  42. package/view.js +97 -13
@@ -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.required.length >= 1);
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
- assert.equal(saveParams.id.type, 'string');
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', 'id', 'revision']);
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.id, 'string');
113
- assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
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.id, saved.id);
125
+ assert.equal(merged.title, saved.title);
118
126
  assert.equal(merged.revision, 2);
119
- assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
127
+ assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序"$/);
120
128
 
121
- const patch = await exec('memory_save', { id: saved.id, title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
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.id, saved.id);
136
+ assert.equal(again.title, saved.title);
129
137
  assert.equal(again.revision, 3);
130
- assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
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.similarIds.length > 0);
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, /要更新它,用 id 重存/);
141
- assert.match(probeText, new RegExp(saved.id));
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.notEqual(distinct.id, saved.id);
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- \\[[^\\]]+\\] mem_`));
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', { id: saved.id });
176
+ const read = await exec('memory_read', { title: saved.title });
161
177
  assert.equal(read.status, 'needs_context');
162
- assert.equal(read.body, '');
163
- const plain = await exec('memory_read', { id: distinct.id });
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), new RegExp(`^memory · \\[[^\\]]+\\] 「发布顺序是啥」 · ${distinct.id}`));
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', { id: distinct.id });
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), new RegExp(`^memory · 先确认是归档还是彻底忘掉 · ${distinct.id}$`));
172
- assert.ok(store.locate(store.scopeOf(cwd), distinct.id));
173
- const archived = await exec('memory_forget', { id: distinct.id, mode: 'archive' });
174
- assert.equal(archived.id, distinct.id);
175
- assert.match(render('memory_forget', {}, archived), new RegExp(`^memory · archived \\[[^\\]]+\\] "发布顺序是啥" · ${distinct.id},可找回$`));
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 "发布前跑文案检查" · sug_[a-z0-9]+$/);
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\] "回复用中文" · mem_[a-z0-9]+$/);
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\] mem_/);
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', { id: cited.id });
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 === cited.id && item.title === '引用测试'));
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: cited.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: cited.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: cited.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: cited.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: cited.id })).status, 400);
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 === hinted.id).hits;
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.match(hintText, new RegExp(hinted.id));
390
+ assert.doesNotMatch(hintText, /mem_[a-z0-9]{6,}/, '召回卡只给标题,不给内部编号');
349
391
  assert.match(hintText, /需要细节用 memory_read/);
350
- const hitsAfter = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.id === hinted.id).hits;
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 === evoRule.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, evoRule.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: evoRule.id, expectedRevision: evoManage.body.rules[0].revision - 1,
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: evoRule.id, expectedRevision: evoManage.body.rules[0].revision,
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 === manageSeed.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 === manageSeed.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: manageSeed.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 === manageSeed.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: manageSeed.id, expectedRevision: afterManageEdit.revision,
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 === manageSeed.id).state, 'active');
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: [manageSeed.id],
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 === manageSeed.id).state, 'paused');
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: manageSeed.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 === manageSeed.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: manageSeed.id, expectedRevision: manageResumed.revision,
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 === closedSeed.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 = [closedSeed.id];
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, saved.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(), ['id', 'kind', 'origin', 'revision', 'scope', 'status', 'title', 'when']);
533
- assert.equal(searchMeta.refs[0].id, global.id);
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.id === global.id).kind);
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
- const readValue = await exec('memory_read', { id: global.id });
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', id: guarded.id, revision: 1, cwd });
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.id === guarded.id);
556
- const accepted = await call('/lite-memory/undo', { op: 'undo', id: guarded.id, revision: fresh.revision, cwd });
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', { id: narrow.id, title: 'windows 上跑构建', summary: 'windows 构建用这条命令' });
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.id === narrow.id);
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.id === boxA.id).body, '先登录再下单');
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
- assert.notEqual(slotA.id, slotB.id);
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, ['id']);
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', id: linkedA.id }],
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.id === linkedB.id);
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, linkedA.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: 'all_in_scope', conditions: [], evidence: ['u0'], persistence: 'project_candidate' };
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.id === ovFirst.id && row.title === '开场先给结论'));
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
- // 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行
841
- const ovBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: ov.body.current.key });
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(ovBody.body.body, '开场三句内给结论。');
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', id: ovOther.id, scopeKey: store.scopeOf(ovOtherCwd).key });
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', id: ovFirst.id, scopeKey: 'nope' })).status, 404);
849
- assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: '../x' })).status, 404);
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.id === ovSecond.id);
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.id === ovSecond.id).experimental, true);
871
- assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.id === ovSecond.id), true);
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.id === ovSecond.id);
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.id === ovSecond.id), false);
882
- assert.equal(ovArchived.body.selected.archived.some((row) => row.id === ovSecond.id), true);
883
- const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovSecond.id, scopeKey: ovArchived.body.selected.key });
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, '右侧留白。');