dsh-lite-memory 0.18.1 → 0.18.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -13
- package/TRIGGERS.md +1 -1
- package/client.js +184 -34
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +347 -123
- package/learning.js +65 -6
- package/package.json +3 -3
- package/state.js +47 -6
- package/store.js +180 -18
- package/test/client.test.mjs +47 -25
- package/test/contract.test.mjs +221 -86
- package/test/control.test.mjs +2 -2
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +10 -10
- package/test/ha.test.mjs +55 -5
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +67 -9
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +85 -12
package/test/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.
|
|
119
|
+
assert.equal(typeof saved.title, 'string');
|
|
120
|
+
assert.equal(saved.id, undefined);
|
|
113
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
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
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,23 +173,38 @@ 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
183
|
assert.match(render('memory_read', {}, plain), /^memory · \[[^\]]+\] 「发布顺序是啥」/);
|
|
167
|
-
assert.ok(!render('memory_read', {}, plain).includes(
|
|
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
|
+
}
|
|
168
192
|
|
|
169
193
|
// 没点明归档还是彻底忘掉时不动盘,先返回待确认
|
|
170
|
-
const undecided = await exec('memory_forget', {
|
|
194
|
+
const undecided = await exec('memory_forget', { title: distinct.title });
|
|
171
195
|
assert.equal(undecided.mode, 'needs_resolution');
|
|
172
196
|
assert.match(render('memory_forget', {}, undecided), /^memory · 先确认是归档还是彻底忘掉 · 「发布顺序是啥」$/);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
assert.
|
|
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);
|
|
176
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 · 这条记忆已归档$/);
|
|
177
208
|
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
178
209
|
|
|
179
210
|
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
@@ -238,37 +269,45 @@ assert.equal(global.scope, 'global');
|
|
|
238
269
|
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文"$/);
|
|
239
270
|
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
240
271
|
assert.equal(globalSearch.count, 1);
|
|
241
|
-
assert.match(render('memory_search', {}, globalSearch), /\[global\]
|
|
272
|
+
assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
|
|
242
273
|
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
243
274
|
const stats = globalThis.__liteMemoryStats(cwd);
|
|
244
275
|
assert.ok(stats.count >= 1);
|
|
245
276
|
|
|
246
277
|
// ── 引用条:会话读过哪几条,点开能拿到正文 ───────────────────────────────────
|
|
247
278
|
const cited = await exec('memory_save', { title: '引用测试', summary: '底部引用条要能点开正文', body: '引用正文第一行' });
|
|
248
|
-
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);
|
|
249
282
|
const cites = await call('/lite-memory/citations', { sessionId: 'session-contract' });
|
|
250
283
|
assert.equal(cites.status, 200);
|
|
251
|
-
assert.ok(cites.body.items.some((item) => item.id ===
|
|
284
|
+
assert.ok(cites.body.items.some((item) => item.id === citedRow.id && item.title === '引用测试'));
|
|
252
285
|
assert.equal(cites.body.items.some((item) => 'path' in item), false);
|
|
253
|
-
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 });
|
|
254
287
|
assert.equal(opened.status, 200);
|
|
255
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');
|
|
256
295
|
assert.equal(opened.body.scopeLabel, path.basename(cwd));
|
|
257
296
|
const gone = await call('/lite-memory/body', { sessionId: 'session-contract', id: 'mem_999999999999' });
|
|
258
297
|
assert.equal(gone.status, 200);
|
|
259
298
|
assert.equal(gone.body.status, 'gone');
|
|
260
299
|
assert.equal('title' in gone.body, false);
|
|
261
300
|
/** 别的会话、别的工作目录都不能拿 id 探正文;本会话引用过或本目录这一层才行。 */
|
|
262
|
-
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 });
|
|
263
302
|
assert.equal(foreignSession.status, 200);
|
|
264
303
|
assert.equal(foreignSession.body.status, 'gone');
|
|
265
304
|
assert.equal('body' in foreignSession.body, false);
|
|
266
|
-
const viaCwd = await call('/lite-memory/body', { cwd, id:
|
|
305
|
+
const viaCwd = await call('/lite-memory/body', { cwd, id: citedRow.id });
|
|
267
306
|
assert.equal(viaCwd.status, 200);
|
|
268
307
|
assert.match(viaCwd.body.body, /引用正文第一行/);
|
|
269
|
-
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 });
|
|
270
309
|
assert.equal(foreignCwd.body.status, 'gone');
|
|
271
|
-
assert.equal((await call('/lite-memory/body', { id:
|
|
310
|
+
assert.equal((await call('/lite-memory/body', { id: citedRow.id })).status, 400);
|
|
272
311
|
assert.equal((await call('/lite-memory/citations', {})).status, 400);
|
|
273
312
|
assert.equal((await call('/lite-memory/citations', {}, { origin: 'http://evil.example' })).status, 403);
|
|
274
313
|
assert.notEqual((await call('/lite-memory/citations', { sessionId: 'session-contract' }, { origin: 'dsh-app://app' })).status, 403);
|
|
@@ -326,6 +365,8 @@ assert.equal(fs.existsSync(path.join(store.scopeOf(evoUnknown).dir, 'state.json'
|
|
|
326
365
|
|
|
327
366
|
// ── 自动召回:只在这句话跟已有记忆有关时出现,且不改命中计数 ─────────────────
|
|
328
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);
|
|
329
370
|
// 只有一句 when、没有结构化条件的条目属于未解决,不自动注入
|
|
330
371
|
await exec('memory_save', { title: '发布窗口在周四晚上', summary: '只在周四晚上发版', when: '周四晚上' });
|
|
331
372
|
const recallAgent = { id: 'session-contract', cwd, session: { id: 'session-recall', header: { cwd } } };
|
|
@@ -343,17 +384,43 @@ assert.equal(recall.interpolate, false);
|
|
|
343
384
|
assert.equal(recall.required, false);
|
|
344
385
|
|
|
345
386
|
const sig1 = new AbortController().signal;
|
|
346
|
-
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;
|
|
347
388
|
await preStep({ agent: recallAgent, signal: sig1, turn: '2' }, pass('又弹钥匙串窗口了,怎么弄', 'h1'));
|
|
348
389
|
const hintText = recall.text({ agent: recallAgent, signal: sig1 });
|
|
349
|
-
assert.
|
|
390
|
+
assert.doesNotMatch(hintText, /mem_[a-z0-9]{6,}/, '召回卡只给标题,不给内部编号');
|
|
350
391
|
assert.match(hintText, /需要细节用 memory_read/);
|
|
351
|
-
|
|
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;
|
|
352
407
|
assert.equal(hitsAfter, hitsBefore);
|
|
353
408
|
|
|
354
409
|
const sig2 = new AbortController().signal;
|
|
355
410
|
await preStep({ agent: recallAgent, signal: sig2, turn: '3' }, pass('周四晚上发版要注意什么', 'h2'));
|
|
356
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);
|
|
357
424
|
|
|
358
425
|
const sig3 = new AbortController().signal;
|
|
359
426
|
await preStep({ agent: recallAgent, signal: sig3, turn: '4' }, pass('把这个按钮的圆角改小一点', 'h3'));
|
|
@@ -372,11 +439,13 @@ const evoRule = await defs.get('memory_save').execute(
|
|
|
372
439
|
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
373
440
|
{ agent: evoAgentSeed },
|
|
374
441
|
);
|
|
442
|
+
const evoRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === evoRule.title);
|
|
443
|
+
assert.ok(evoRow, '口播开头落在这一层');
|
|
375
444
|
state.transact(evoReadScope, {}, (draft) => {
|
|
376
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
445
|
+
const row = draft.entries.find((entry) => entry.id === evoRow.id);
|
|
377
446
|
row.origin = 'project_evolution';
|
|
378
447
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
379
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution' } };
|
|
448
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', source: 'evolution', specDigest: 'seeded' } };
|
|
380
449
|
return { changed: true };
|
|
381
450
|
});
|
|
382
451
|
await store.setTaskDefault(evoReadScope, { seriesId: '口播' });
|
|
@@ -411,14 +480,14 @@ assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }), '');
|
|
|
411
480
|
const evoManage = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
412
481
|
assert.equal(evoManage.status, 200);
|
|
413
482
|
assert.equal(evoManage.body.rules.length, 1);
|
|
414
|
-
assert.equal(evoManage.body.rules[0].id,
|
|
483
|
+
assert.equal(evoManage.body.rules[0].id, evoRow.id);
|
|
415
484
|
assert.ok(evoManage.body.payload.bytes > 0);
|
|
416
485
|
const evoDeleteStale = await call('/lite-memory/evolution', {
|
|
417
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id:
|
|
486
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision - 1,
|
|
418
487
|
});
|
|
419
488
|
assert.equal(evoDeleteStale.status, 409);
|
|
420
489
|
const evoDelete = await call('/lite-memory/evolution', {
|
|
421
|
-
sessionId: 'session-evo-read', learningAction: 'delete', id:
|
|
490
|
+
sessionId: 'session-evo-read', learningAction: 'delete', id: evoRow.id, expectedRevision: evoManage.body.rules[0].revision,
|
|
422
491
|
});
|
|
423
492
|
assert.equal(evoDelete.status, 200);
|
|
424
493
|
assert.equal(evoDelete.body.ok, true);
|
|
@@ -428,44 +497,46 @@ const manageSeed = await defs.get('memory_save').execute(
|
|
|
428
497
|
{ title: '口播收尾', summary: '口播收尾留一句行动', body: '结尾给一句具体行动。' },
|
|
429
498
|
{ agent: evoAgent },
|
|
430
499
|
);
|
|
500
|
+
const manageRow = store.loadIndex(evoReadScope).entries.find((entry) => entry.title === manageSeed.title);
|
|
501
|
+
assert.ok(manageRow, '口播收尾落在这一层');
|
|
431
502
|
state.transact(evoReadScope, {}, (draft) => {
|
|
432
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
503
|
+
const row = draft.entries.find((entry) => entry.id === manageRow.id);
|
|
433
504
|
row.origin = 'project_evolution';
|
|
434
505
|
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
435
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution' } };
|
|
506
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution', specDigest: 'seeded' } };
|
|
436
507
|
return { changed: true };
|
|
437
508
|
});
|
|
438
509
|
const manageSnap = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
439
|
-
const manageRule = manageSnap.body.rules.find((rule) => rule.id ===
|
|
510
|
+
const manageRule = manageSnap.body.rules.find((rule) => rule.id === manageRow.id);
|
|
440
511
|
assert.ok(manageRule);
|
|
441
512
|
assert.equal(manageRule.state, 'pending');
|
|
442
513
|
const manageEdit = await call('/lite-memory/evolution', {
|
|
443
|
-
sessionId: 'session-evo-read', learningAction: 'edit', id:
|
|
514
|
+
sessionId: 'session-evo-read', learningAction: 'edit', id: manageRow.id,
|
|
444
515
|
expectedRevision: manageRule.revision, body: '结尾给一句具体动作。',
|
|
445
516
|
});
|
|
446
517
|
assert.equal(manageEdit.status, 200);
|
|
447
518
|
assert.equal(manageEdit.body.ok, true);
|
|
448
|
-
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);
|
|
449
520
|
assert.equal(afterManageEdit.state, 'pending');
|
|
450
521
|
assert.equal(afterManageEdit.body, '结尾给一句具体动作。');
|
|
451
522
|
const manageAdopt = await call('/lite-memory/evolution', {
|
|
452
|
-
sessionId: 'session-evo-read', learningAction: 'adopt', id:
|
|
523
|
+
sessionId: 'session-evo-read', learningAction: 'adopt', id: manageRow.id, expectedRevision: afterManageEdit.revision,
|
|
453
524
|
});
|
|
454
525
|
assert.equal(manageAdopt.status, 200);
|
|
455
|
-
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');
|
|
456
527
|
const managePause = await call('/lite-memory/evolution', {
|
|
457
|
-
sessionId: 'session-evo-read', learningAction: 'pause', ids: [
|
|
528
|
+
sessionId: 'session-evo-read', learningAction: 'pause', ids: [manageRow.id],
|
|
458
529
|
});
|
|
459
530
|
assert.equal(managePause.status, 200);
|
|
460
|
-
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');
|
|
461
532
|
const manageResume = await call('/lite-memory/evolution', {
|
|
462
|
-
sessionId: 'session-evo-read', learningAction: 'resume', id:
|
|
533
|
+
sessionId: 'session-evo-read', learningAction: 'resume', id: manageRow.id,
|
|
463
534
|
});
|
|
464
535
|
assert.equal(manageResume.status, 200);
|
|
465
|
-
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);
|
|
466
537
|
assert.equal(manageResumed.state, 'trial');
|
|
467
538
|
const manageDiscard = await call('/lite-memory/evolution', {
|
|
468
|
-
sessionId: 'session-evo-read', learningAction: 'discard', id:
|
|
539
|
+
sessionId: 'session-evo-read', learningAction: 'discard', id: manageRow.id, expectedRevision: manageResumed.revision,
|
|
469
540
|
});
|
|
470
541
|
assert.equal(manageDiscard.status, 200);
|
|
471
542
|
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.length, 0);
|
|
@@ -479,10 +550,12 @@ const closedSeed = await defs.get('memory_save').execute(
|
|
|
479
550
|
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
480
551
|
{ agent: closedAgent },
|
|
481
552
|
);
|
|
553
|
+
const closedRow = store.loadIndex(closedScope).entries.find((entry) => entry.title === closedSeed.title);
|
|
554
|
+
assert.ok(closedRow, '关闭前的样例落在这一层');
|
|
482
555
|
state.transact(closedScope, {}, (draft) => {
|
|
483
|
-
const row = draft.entries.find((entry) => entry.id ===
|
|
556
|
+
const row = draft.entries.find((entry) => entry.id === closedRow.id);
|
|
484
557
|
row.origin = 'project_evolution';
|
|
485
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active' } };
|
|
558
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active', specDigest: 'seeded' } };
|
|
486
559
|
return { changed: true };
|
|
487
560
|
});
|
|
488
561
|
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-closed', enabled: true })).status, 200);
|
|
@@ -500,7 +573,7 @@ const closedRevision = closedSnapshot.body.rules[0].revision;
|
|
|
500
573
|
for (const action of ['adopt', 'edit', 'delete', 'pause', 'resume', 'pin', 'undo']) {
|
|
501
574
|
const payload = { sessionId: 'session-closed', learningAction: action, id: closedSeed.id, expectedRevision: closedRevision, body: '改过的正文' };
|
|
502
575
|
if (action === 'pin') payload.pinned = true;
|
|
503
|
-
if (action === 'pause') payload.ids = [
|
|
576
|
+
if (action === 'pause') payload.ids = [closedRow.id];
|
|
504
577
|
const result = await call('/lite-memory/evolution', payload);
|
|
505
578
|
assert.equal(result.status, 409, action);
|
|
506
579
|
assert.equal(result.body.code, 'memory_disabled', action);
|
|
@@ -511,7 +584,7 @@ const closedTool = await defs.get('memory_save').execute(
|
|
|
511
584
|
);
|
|
512
585
|
assert.equal(closedTool.changed, false);
|
|
513
586
|
assert.equal(closedTool.reason, 'memory_disabled');
|
|
514
|
-
assert.equal(closedTool.id,
|
|
587
|
+
assert.equal(closedTool.id, undefined);
|
|
515
588
|
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
516
589
|
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
517
590
|
assert.equal(reopened.status, 200);
|
|
@@ -524,20 +597,25 @@ const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(arg
|
|
|
524
597
|
const savedMeta = metaOf('memory_save', {}, saved);
|
|
525
598
|
assert.equal(savedMeta.kind, 'memory-save');
|
|
526
599
|
assert.equal(savedMeta.recordKind, saved.kind);
|
|
527
|
-
assert.equal(savedMeta.id,
|
|
600
|
+
assert.equal(savedMeta.id, undefined);
|
|
528
601
|
assert.equal(savedMeta.title, '发布顺序');
|
|
529
602
|
assert.equal(savedMeta.changed, true);
|
|
530
603
|
assert.ok(['created', 'updated'].includes(savedMeta.mode));
|
|
531
604
|
assert.equal('path' in savedMeta, false);
|
|
532
605
|
const searchMeta = metaOf('memory_search', {}, globalSearch);
|
|
533
|
-
assert.deepEqual(Object.keys(searchMeta.refs[0]).sort(), ['
|
|
534
|
-
|
|
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');
|
|
535
613
|
/** 引用要能区分偏好/约定/流程/资料,界面才不必猜标题文案。 */
|
|
536
|
-
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);
|
|
537
615
|
assert.equal(searchMeta.refs[0].when, '');
|
|
538
616
|
/** 出处行要说「N 天前更新」,只给时间不进正文。 */
|
|
539
617
|
assert.equal(typeof searchMeta.refs[0].updated, 'string');
|
|
540
|
-
const readValue = await exec('memory_read', {
|
|
618
|
+
const readValue = await exec('memory_read', { title: '回复用中文' });
|
|
541
619
|
const readMeta = metaOf('memory_read', {}, readValue);
|
|
542
620
|
assert.equal(readMeta.kind, 'memory-read');
|
|
543
621
|
assert.equal(readMeta.recordKind, readValue.kind);
|
|
@@ -553,10 +631,10 @@ if (suggestMeta.asked === true) assert.match(suggestMeta.id, /^sug_[a-z0-9]+$/);
|
|
|
553
631
|
// ── 撤销带着当时的版本:过期就冲突,不覆盖后来的修改 ─────────────────────────
|
|
554
632
|
const guarded = await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本' });
|
|
555
633
|
await exec('memory_save', { title: '版本守卫', summary: '撤销要带当时版本', body: '第二版正文' });
|
|
556
|
-
const stale = await call('/lite-memory/undo', { op: 'undo',
|
|
634
|
+
const stale = await call('/lite-memory/undo', { op: 'undo', title: '版本守卫', revision: 1, cwd });
|
|
557
635
|
assert.equal(stale.status, 409);
|
|
558
|
-
const fresh = store.loadIndex(store.scopeOf(cwd)).entries.find((item) => item.
|
|
559
|
-
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 });
|
|
560
638
|
assert.equal(accepted.status, 200);
|
|
561
639
|
|
|
562
640
|
// ── 第二组:确认前后语义一致、比较口径统一 ──────────────────────────────────
|
|
@@ -597,9 +675,9 @@ const narrow = await exec2('memory_save', {
|
|
|
597
675
|
conditions: [{ key: 'targetOs', value: 'windows' }],
|
|
598
676
|
});
|
|
599
677
|
assert.equal(narrow.changed, true);
|
|
600
|
-
const narrowed = await exec2('memory_save', {
|
|
678
|
+
const narrowed = await exec2('memory_save', { title: 'windows 上跑构建', summary: 'windows 构建用这条命令' });
|
|
601
679
|
assert.equal(narrowed.changed, true);
|
|
602
|
-
const narrowRecord = projectEntries().find((item) => item.
|
|
680
|
+
const narrowRecord = projectEntries().find((item) => item.title === 'windows 上跑构建');
|
|
603
681
|
assert.equal(narrowRecord.applicability, 'conditional');
|
|
604
682
|
assert.deepEqual(narrowRecord.conditions, [{ key: 'targetOs', value: 'windows' }]);
|
|
605
683
|
|
|
@@ -624,11 +702,11 @@ const boxB = await exec2('memory_save', {
|
|
|
624
702
|
conditions: [{ key: 'machine', value: 'box-b' }],
|
|
625
703
|
});
|
|
626
704
|
assert.equal(boxB.changed, true);
|
|
627
|
-
assert.notEqual(boxB.id, boxA.id);
|
|
628
705
|
const bothBoxes = projectEntries().filter((item) => item.title === '冒烟顺序' && item.status === 'active');
|
|
629
706
|
assert.equal(bothBoxes.length, 2);
|
|
630
707
|
assert.deepEqual(bothBoxes.map((item) => item.conditions[0].value).sort(), ['box-a', 'box-b']);
|
|
631
|
-
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, '先建号再登录');
|
|
632
710
|
|
|
633
711
|
/** 同一主题、条件一致但正文不同:读取要报冲突,不能悄悄挑一条当现行规范。 */
|
|
634
712
|
const slotA = await exec2('memory_save', {
|
|
@@ -649,7 +727,9 @@ const slotB = await exec2('memory_save', {
|
|
|
649
727
|
topicKey: '发版检查',
|
|
650
728
|
resolution: 'distinct',
|
|
651
729
|
});
|
|
652
|
-
|
|
730
|
+
const slotEntries = projectEntries().filter((item) => item.title === '发版检查项');
|
|
731
|
+
assert.equal(slotEntries.length, 2);
|
|
732
|
+
assert.notEqual(slotEntries[0].id, slotEntries[1].id);
|
|
653
733
|
const resolvedSlots = store.visibleRules(projectScope, {}).resolved;
|
|
654
734
|
const slotRow = [...resolvedSlots.values()].find((row) => row.key === '发版检查');
|
|
655
735
|
assert.ok(slotRow);
|
|
@@ -657,7 +737,7 @@ assert.equal(slotRow.status, 'conflict');
|
|
|
657
737
|
|
|
658
738
|
/** 工具参数里的 links / selection.items / topicKey:null 是合法输入,不能被 schema 挡掉或被校验器拒绝。 */
|
|
659
739
|
assert.equal(saveParams.links.items.type, 'object');
|
|
660
|
-
assert.deepEqual(saveParams.links.items.required, ['
|
|
740
|
+
assert.deepEqual(saveParams.links.items.required, ['title']);
|
|
661
741
|
assert.equal(saveParams.selection.properties.items.type, 'array');
|
|
662
742
|
assert.deepEqual(saveParams.topicKey.type, ['string', 'null']);
|
|
663
743
|
const linkedA = await exec2('memory_save', { title: '关联目标甲', summary: '用来当关联目标', body: '甲', scope: 'project' });
|
|
@@ -667,13 +747,21 @@ const linkedB = await exec2('memory_save', {
|
|
|
667
747
|
body: '乙',
|
|
668
748
|
scope: 'project',
|
|
669
749
|
resolution: 'distinct',
|
|
670
|
-
links: [{ type: 'related',
|
|
750
|
+
links: [{ type: 'related', title: linkedA.title }],
|
|
671
751
|
});
|
|
672
752
|
assert.equal(linkedB.changed, true, JSON.stringify(linkedB));
|
|
673
|
-
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);
|
|
674
756
|
assert.equal(linkedRecord.links.length, 1);
|
|
675
|
-
assert.equal(linkedRecord.links[0].id,
|
|
757
|
+
assert.equal(linkedRecord.links[0].id, linkedTarget.id);
|
|
676
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, /没有「从来没有存过的标题」这条记忆/);
|
|
677
765
|
|
|
678
766
|
|
|
679
767
|
// ── 项目实验:正常回合结束后走一次短整理,只写后台结果 ─────────────────────────
|
|
@@ -699,7 +787,7 @@ llmImpl = {
|
|
|
699
787
|
? { target: ref, feedbackType: 'execution_miss', currentText: '交付前先核对开头是否先给结论。', evidence: ['u0'], persistence: 'project_candidate' }
|
|
700
788
|
: orgMode === 'sample'
|
|
701
789
|
? { target: ref, feedbackType: 'artifact_accept', evidence: ['u0'], persistence: 'project_candidate' }
|
|
702
|
-
: { 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' };
|
|
703
791
|
yield { type: 'text-delta', text: JSON.stringify({ changes: [change] }) };
|
|
704
792
|
yield { type: 'usage', usage: { inputTokens: 12, outputTokens: 6 } };
|
|
705
793
|
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
@@ -813,11 +901,12 @@ assert.equal(ov.body.ok, true);
|
|
|
813
901
|
assert.equal(ov.body.current.key, store.scopeOf(ovCwd).key);
|
|
814
902
|
assert.equal(ov.body.current.label, path.basename(ovCwd));
|
|
815
903
|
assert.equal(ov.body.current.evolution.enabled, false);
|
|
904
|
+
assert.equal(ov.body.current.recall, null, '没发生过自动召回时不给判断依据');
|
|
816
905
|
assert.equal(ov.body.selected.key, ov.body.current.key);
|
|
817
906
|
assert.equal(ov.body.selected.kind, 'project');
|
|
818
907
|
assert.equal(ov.body.selected.evolution.enabled, false);
|
|
819
908
|
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
820
|
-
assert.ok(ov.body.selected.records.some((row) => row.
|
|
909
|
+
assert.ok(ov.body.selected.records.some((row) => row.title === '开场先给结论'));
|
|
821
910
|
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
822
911
|
assert.equal(ov.body.selected.stats.total, 2);
|
|
823
912
|
assert.equal(ov.body.selected.stats.tags, 3);
|
|
@@ -840,16 +929,62 @@ assert.equal(ovGlobal.status, 200);
|
|
|
840
929
|
assert.equal(ovGlobal.body.selected.kind, 'global');
|
|
841
930
|
assert.ok(ovGlobal.body.selected.records.some((row) => row.title === '观察全局'));
|
|
842
931
|
|
|
843
|
-
//
|
|
844
|
-
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 });
|
|
845
979
|
assert.equal(ovBody.status, 200);
|
|
846
|
-
assert.equal(
|
|
980
|
+
assert.equal(fs.readFileSync(ovStateFile, 'utf8'), ovStateBefore, '面板看正文不写状态');
|
|
981
|
+
assert.equal(ovBody.body.body, '开场 2 句内给结论,结尾留 3 秒;本地端口 49631。');
|
|
847
982
|
assert.equal(ovBody.body.scopeLabel, path.basename(ovCwd));
|
|
848
|
-
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 });
|
|
849
984
|
assert.equal(ovOtherBody.status, 200);
|
|
850
985
|
assert.equal(ovOtherBody.body.scopeLabel, path.basename(ovOtherCwd));
|
|
851
|
-
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview',
|
|
852
|
-
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);
|
|
853
988
|
assert.equal((await call('/lite-memory/overview', {})).status, 400);
|
|
854
989
|
assert.equal((await call('/lite-memory/overview', { sessionId: 'session-overview' }, { origin: 'http://evil.example' })).status, 403);
|
|
855
990
|
const ovUnknownCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-unknown-'));
|
|
@@ -863,27 +998,27 @@ assert.equal(fs.existsSync(path.join(store.scopeOf(ovUnknownCwd).dir, 'state.jso
|
|
|
863
998
|
|
|
864
999
|
// 实验条目也画进图里,但只有面板能看见;普通读取面仍然拿不到
|
|
865
1000
|
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
866
|
-
const row = draft.entries.find((entry) => entry.
|
|
1001
|
+
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
867
1002
|
row.origin = 'project_evolution';
|
|
868
|
-
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution' } };
|
|
1003
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution', specDigest: 'seeded' } };
|
|
869
1004
|
return { changed: true };
|
|
870
1005
|
});
|
|
871
1006
|
const ovExperimental = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
872
1007
|
assert.equal(ovExperimental.body.selected.stats.experimental, 1);
|
|
873
|
-
assert.equal(ovExperimental.body.selected.records.find((row) => row.
|
|
874
|
-
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);
|
|
875
1010
|
|
|
876
1011
|
// 归档不是死数字:总数诚实,列表单独取,正文还能按 id 打开
|
|
877
1012
|
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
878
|
-
const row = draft.entries.find((entry) => entry.
|
|
1013
|
+
const row = draft.entries.find((entry) => (entry.current || {}).title === '封面留白');
|
|
879
1014
|
row.status = 'archived';
|
|
880
1015
|
return { changed: true };
|
|
881
1016
|
});
|
|
882
1017
|
const ovArchived = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
883
1018
|
assert.equal(ovArchived.body.selected.stats.archived, 1);
|
|
884
|
-
assert.equal(ovArchived.body.selected.records.some((row) => row.
|
|
885
|
-
assert.equal(ovArchived.body.selected.archived.some((row) => row.
|
|
886
|
-
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 });
|
|
887
1022
|
assert.equal(ovArchivedBody.status, 200);
|
|
888
1023
|
assert.equal(ovArchivedBody.body.status, 'archived');
|
|
889
1024
|
assert.equal(ovArchivedBody.body.body, '右侧留白。');
|