dsh-lite-memory 0.11.1 → 0.15.0
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 +44 -18
- package/anchor.js +110 -13
- package/client.js +1078 -117
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +579 -78
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +107 -8
- package/store.js +302 -29
- package/test/anchor.test.mjs +221 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +278 -0
- package/test/evolution-read.test.mjs +63 -8
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +11 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +25 -3
- package/view.js +16 -1
package/test/anchor.test.mjs
CHANGED
|
@@ -213,3 +213,224 @@ test('R18 自由措辞不能把未注册栏目变成已绑定', () => {
|
|
|
213
213
|
assert.equal(routed.anchor.status, 'unbound');
|
|
214
214
|
assert.equal(routed.anchor.seriesId, undefined);
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
test('R12 无切换词的新任务撤销旧作品绑定,不继承栏目和规则', () => {
|
|
218
|
+
const sessionId = 'sess-r12';
|
|
219
|
+
let tasks = anchor.emptyTasks();
|
|
220
|
+
const first = anchor.routeTask(tasks, {
|
|
221
|
+
sessionId,
|
|
222
|
+
batchKey: 'rb_first',
|
|
223
|
+
messageIds: ['m1'],
|
|
224
|
+
query: '帮我把这期口播视频的开头改一下',
|
|
225
|
+
selection: { seriesId: '口播', taskKind: '口播视频' },
|
|
226
|
+
now: 1000,
|
|
227
|
+
});
|
|
228
|
+
assert.equal(first.anchor.status, 'proposed');
|
|
229
|
+
const bound = anchor.applySelection(first.tasks, { sessionId, selection: { seriesId: '口播' }, batchKey: 'rb_first', messageIds: ['m1'], now: 1500 });
|
|
230
|
+
assert.equal(bound.anchor.status, 'proposed');
|
|
231
|
+
const active = bound.tasks.sessions[sessionId];
|
|
232
|
+
active.status = 'active';
|
|
233
|
+
tasks = bound.tasks;
|
|
234
|
+
const next = anchor.routeTask(tasks, {
|
|
235
|
+
sessionId,
|
|
236
|
+
batchKey: 'rb_next',
|
|
237
|
+
messageIds: ['m2'],
|
|
238
|
+
query: '帮我排查 PostgreSQL 连接超时',
|
|
239
|
+
registeredSeries: ['口播'],
|
|
240
|
+
now: 2000,
|
|
241
|
+
});
|
|
242
|
+
assert.equal(next.anchor.status, 'unbound');
|
|
243
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
244
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
245
|
+
assert.ok(next.episode.previousEpisodeId);
|
|
246
|
+
|
|
247
|
+
/** 新任务已撤销旧绑定:旧栏目规则不能再自动进入这一步。 */
|
|
248
|
+
const read = anchor.decideRead({
|
|
249
|
+
enabled: true,
|
|
250
|
+
identityConfirmed: true,
|
|
251
|
+
anchor: next.anchor,
|
|
252
|
+
ranges: [{ seriesId: '口播', taskKind: '口播视频', reason: '旧任务' }],
|
|
253
|
+
});
|
|
254
|
+
assert.equal(read.kind, 'index');
|
|
255
|
+
assert.equal(read.rules, undefined);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('R13 继续改/接着做仍继承同一任务', () => {
|
|
259
|
+
let tasks = anchor.emptyTasks();
|
|
260
|
+
const first = anchor.routeTask(tasks, {
|
|
261
|
+
sessionId: 'sess-r13',
|
|
262
|
+
batchKey: 'rb_a',
|
|
263
|
+
messageIds: ['m1'],
|
|
264
|
+
query: '帮我把这期口播视频的开头改一下',
|
|
265
|
+
selection: { seriesId: '口播' },
|
|
266
|
+
now: 1000,
|
|
267
|
+
});
|
|
268
|
+
tasks = first.tasks;
|
|
269
|
+
const next = anchor.routeTask(tasks, {
|
|
270
|
+
sessionId: 'sess-r13',
|
|
271
|
+
batchKey: 'rb_b',
|
|
272
|
+
messageIds: ['m2'],
|
|
273
|
+
query: '继续改,节奏再快一点',
|
|
274
|
+
now: 2000,
|
|
275
|
+
});
|
|
276
|
+
assert.equal(next.anchor.status, 'continuable');
|
|
277
|
+
assert.equal(next.decision.kind, 'continuation');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('R14 无候选栏目时返回 none,不再要求选栏目', () => {
|
|
281
|
+
const read = anchor.decideRead({ enabled: true, identityConfirmed: true, anchor: null, ranges: [] });
|
|
282
|
+
assert.equal(read.kind, 'none');
|
|
283
|
+
assert.equal(read.reason, 'no_range');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
/** 已绑定的口播任务:用于验证换任务时旧范围确实被撤销。 */
|
|
287
|
+
function activeVideoSession() {
|
|
288
|
+
const first = anchor.routeTask(anchor.emptyTasks(), {
|
|
289
|
+
sessionId: 'sess-switch',
|
|
290
|
+
batchKey: 'rb_video',
|
|
291
|
+
messageIds: ['m1'],
|
|
292
|
+
query: '帮我把这期口播视频的开头改一下',
|
|
293
|
+
selection: { seriesId: '口播', taskKind: '口播视频' },
|
|
294
|
+
now: 1000,
|
|
295
|
+
});
|
|
296
|
+
const anchorState = first.tasks.sessions['sess-switch'];
|
|
297
|
+
anchorState.status = 'active';
|
|
298
|
+
return first.tasks;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
test('R20 带「继续」但出现新对象时,先撤销旧范围', () => {
|
|
302
|
+
const tasks = activeVideoSession();
|
|
303
|
+
const next = anchor.routeTask(tasks, {
|
|
304
|
+
sessionId: 'sess-switch',
|
|
305
|
+
batchKey: 'rb_pg_continue',
|
|
306
|
+
messageIds: ['m2'],
|
|
307
|
+
query: '接下来继续排查 PostgreSQL 连接超时',
|
|
308
|
+
registeredSeries: ['口播'],
|
|
309
|
+
now: 2000,
|
|
310
|
+
});
|
|
311
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
312
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
313
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
314
|
+
assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('R21 带「这个」但出现新对象时,不沿用旧范围', () => {
|
|
318
|
+
const tasks = activeVideoSession();
|
|
319
|
+
const next = anchor.routeTask(tasks, {
|
|
320
|
+
sessionId: 'sess-switch',
|
|
321
|
+
batchKey: 'rb_pg_this',
|
|
322
|
+
messageIds: ['m2'],
|
|
323
|
+
query: '现在帮我排查这个 PostgreSQL 连接超时',
|
|
324
|
+
registeredSeries: ['口播'],
|
|
325
|
+
now: 2000,
|
|
326
|
+
});
|
|
327
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
328
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
329
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('R22 真正的续改保留原任务范围', () => {
|
|
333
|
+
const tasks = activeVideoSession();
|
|
334
|
+
const next = anchor.routeTask(tasks, {
|
|
335
|
+
sessionId: 'sess-switch',
|
|
336
|
+
batchKey: 'rb_video_continue',
|
|
337
|
+
messageIds: ['m2'],
|
|
338
|
+
query: '继续改刚才那版脚本,开头再直接一点',
|
|
339
|
+
registeredSeries: ['口播'],
|
|
340
|
+
now: 2000,
|
|
341
|
+
});
|
|
342
|
+
assert.equal(next.decision.kind, 'continuation');
|
|
343
|
+
assert.equal(next.anchor.seriesId, '口播');
|
|
344
|
+
assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('R23 只指代旧稿的局部修改不被当成换任务', () => {
|
|
348
|
+
const tasks = activeVideoSession();
|
|
349
|
+
const next = anchor.routeTask(tasks, {
|
|
350
|
+
sessionId: 'sess-switch',
|
|
351
|
+
batchKey: 'rb_video_tweak',
|
|
352
|
+
messageIds: ['m2'],
|
|
353
|
+
query: '现在把开头改短一点',
|
|
354
|
+
registeredSeries: ['口播'],
|
|
355
|
+
now: 2000,
|
|
356
|
+
});
|
|
357
|
+
assert.equal(next.decision.kind, 'focus');
|
|
358
|
+
assert.equal(next.anchor.seriesId, '口播');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('R24 继续做新领域的中文任务也算换任务', () => {
|
|
362
|
+
const tasks = activeVideoSession();
|
|
363
|
+
const next = anchor.routeTask(tasks, {
|
|
364
|
+
sessionId: 'sess-switch',
|
|
365
|
+
batchKey: 'rb_research',
|
|
366
|
+
messageIds: ['m2'],
|
|
367
|
+
query: '继续看看竞品定价',
|
|
368
|
+
registeredSeries: ['口播'],
|
|
369
|
+
now: 2000,
|
|
370
|
+
});
|
|
371
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
372
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('R25 单独说「继续」不沿用旧任务范围', () => {
|
|
376
|
+
const tasks = activeVideoSession();
|
|
377
|
+
const previous = tasks.sessions['sess-switch'];
|
|
378
|
+
const next = anchor.routeTask(tasks, {
|
|
379
|
+
sessionId: 'sess-switch',
|
|
380
|
+
batchKey: 'rb_weak_continue',
|
|
381
|
+
messageIds: ['m2'],
|
|
382
|
+
query: '继续',
|
|
383
|
+
registeredSeries: ['口播'],
|
|
384
|
+
now: 2000,
|
|
385
|
+
});
|
|
386
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
387
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
388
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
389
|
+
assert.equal(next.episode.previousEpisodeId, previous.episodeId);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('R26 单独说「这个」不沿用旧任务范围', () => {
|
|
393
|
+
const tasks = activeVideoSession();
|
|
394
|
+
const next = anchor.routeTask(tasks, {
|
|
395
|
+
sessionId: 'sess-switch',
|
|
396
|
+
batchKey: 'rb_weak_this',
|
|
397
|
+
messageIds: ['m2'],
|
|
398
|
+
query: '这个',
|
|
399
|
+
registeredSeries: ['口播'],
|
|
400
|
+
now: 2000,
|
|
401
|
+
});
|
|
402
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
403
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
404
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('R27 单独说「之前」不沿用旧任务范围', () => {
|
|
408
|
+
const tasks = activeVideoSession();
|
|
409
|
+
const next = anchor.routeTask(tasks, {
|
|
410
|
+
sessionId: 'sess-switch',
|
|
411
|
+
batchKey: 'rb_weak_before',
|
|
412
|
+
messageIds: ['m2'],
|
|
413
|
+
query: '之前',
|
|
414
|
+
registeredSeries: ['口播'],
|
|
415
|
+
now: 2000,
|
|
416
|
+
});
|
|
417
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
418
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
419
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('R28 指代加动作的续接仍保留旧任务范围', () => {
|
|
423
|
+
const tasks = activeVideoSession();
|
|
424
|
+
const previous = tasks.sessions['sess-switch'];
|
|
425
|
+
const next = anchor.routeTask(tasks, {
|
|
426
|
+
sessionId: 'sess-switch',
|
|
427
|
+
batchKey: 'rb_strong_continue',
|
|
428
|
+
messageIds: ['m2'],
|
|
429
|
+
query: '把刚才那个再调一下',
|
|
430
|
+
registeredSeries: ['口播'],
|
|
431
|
+
now: 2000,
|
|
432
|
+
});
|
|
433
|
+
assert.notEqual(next.decision.kind, 'unbound');
|
|
434
|
+
assert.equal(next.anchor.episodeId, previous.episodeId);
|
|
435
|
+
assert.equal(next.anchor.seriesId, '口播');
|
|
436
|
+
});
|
package/test/client.test.mjs
CHANGED
|
@@ -20,7 +20,47 @@ assert.ok(evolution, '项目开关没有注册');
|
|
|
20
20
|
assert.equal(evolution.name, 'conversation.session.header.utilities');
|
|
21
21
|
assert.equal(evolution.order, 30);
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// 记忆面板占整框浮层,不挤在会话列里。
|
|
24
|
+
const panel = registrations.find((item) => item.id === 'lite-memory:panel');
|
|
25
|
+
assert.ok(panel, '记忆面板没有注册');
|
|
26
|
+
assert.equal(panel.name, 'shell.overlay');
|
|
27
|
+
assert.equal(panel.order, 30);
|
|
28
|
+
|
|
29
|
+
const { metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf } = seen;
|
|
30
|
+
|
|
31
|
+
// 图谱只按真实数据画:共享标签成枢纽,显式关联成边,位置由 id 决定。
|
|
32
|
+
const graph = graphOf([
|
|
33
|
+
{ id: 'mem_aaaaaaaa', title: 'A', tags: ['发布'], topicKey: '上线', experimental: false, links: [{ id: 'mem_bbbbbbbb' }] },
|
|
34
|
+
{ id: 'mem_bbbbbbbb', title: 'B', tags: ['发布'], topicKey: '上线', experimental: true, links: [] },
|
|
35
|
+
{ id: 'mem_cccccccc', title: 'C', tags: ['环境'], topicKey: '', experimental: false, links: [] },
|
|
36
|
+
]);
|
|
37
|
+
assert.equal(graph.nodes.filter((node) => node.kind === 'memory').length, 3);
|
|
38
|
+
assert.equal(graph.nodes.some((node) => node.id === 'tag:发布'), true);
|
|
39
|
+
assert.equal(graph.nodes.some((node) => node.id === 'tag:环境'), true);
|
|
40
|
+
assert.equal(graph.nodes.some((node) => node.id === 'topic:上线'), true);
|
|
41
|
+
assert.equal(graph.edges.filter((edge) => edge.type === 'tag').length, 3);
|
|
42
|
+
assert.equal(graph.edges.filter((edge) => edge.type === 'link').length, 1);
|
|
43
|
+
assert.deepEqual(graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x, graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }]).nodes[0].x);
|
|
44
|
+
const pair = graphOf([{ id: 'mem_aaaaaaaa', title: 'A' }, { id: 'mem_bbbbbbbb', title: 'B' }]);
|
|
45
|
+
assert.notEqual(pair.nodes[0].x, pair.nodes[1].x);
|
|
46
|
+
assert.notEqual(pair.nodes[0].y, pair.nodes[1].y);
|
|
47
|
+
|
|
48
|
+
// 经验图谱只按共享的适用条件相连,不画没有依据的关系。
|
|
49
|
+
const rules = [
|
|
50
|
+
{ id: 'ev_aaaaaaaa', title: 'A', state: 'active', conditions: [{ key: 'seriesId', value: '口播' }] },
|
|
51
|
+
{ id: 'ev_bbbbbbbb', title: 'B', state: 'trial', conditions: [{ key: 'seriesId', value: '口播' }, { key: 'phase', value: '初稿' }] },
|
|
52
|
+
{ id: 'ev_cccccccc', title: 'C', state: 'pending', conditions: [{ key: 'phase', value: '初稿' }] },
|
|
53
|
+
];
|
|
54
|
+
const ruleGraph = ruleGraphOf(rules);
|
|
55
|
+
assert.equal(ruleGraph.nodes.filter((node) => node.kind === 'rule').length, 3);
|
|
56
|
+
assert.equal(ruleGraph.nodes.some((node) => node.id === 'condition:seriesId:口播'), true);
|
|
57
|
+
assert.equal(ruleGraph.nodes.some((node) => node.id === 'condition:phase:初稿'), true);
|
|
58
|
+
assert.equal(ruleGraph.edges.length, 4);
|
|
59
|
+
assert.equal(ruleGraph.edges.every((edge) => edge.type === 'condition'), true);
|
|
60
|
+
assert.equal(ruleGraph.nodes.some((node) => node.kind === 'tag' || node.kind === 'topic'), false);
|
|
61
|
+
assert.deepEqual(ruleGraphOf(rules).nodes.map((node) => [node.id, node.x, node.y]), ruleGraph.nodes.map((node) => [node.id, node.x, node.y]));
|
|
62
|
+
assert.equal(ruleGraphOf([{ id: 'ev_aaaaaaaa', title: 'A', state: 'active', conditions: [] }]).edges.length, 0);
|
|
63
|
+
|
|
24
64
|
const settled = (meta, extra = {}) => ({ kind: 'tool-result', meta, isError: false, content: [], ...extra });
|
|
25
65
|
|
|
26
66
|
// 元数据只从落定结果里取,错误块和旧块一律不认。
|
|
@@ -53,20 +93,22 @@ assert.equal(refs.length, 2);
|
|
|
53
93
|
assert.equal(refs[1].status, 'archived');
|
|
54
94
|
assert.equal(refs[0].exposure, 'reference');
|
|
55
95
|
|
|
56
|
-
// 一轮里 PTC 子结果也算;同 id
|
|
96
|
+
// 一轮里 PTC 子结果也算;同 id 同用途只留一条,位置不变、内容取最新。
|
|
57
97
|
const nodes = [
|
|
58
98
|
{ root: settled({ kind: 'memory-search', refs: [
|
|
59
99
|
{ id: 'mem_aaaaaaaa', title: 'A', scope: 'project', revision: 1, status: 'ok' },
|
|
60
100
|
] }) },
|
|
61
101
|
{ root: settled({ kind: 'memory-read', id: 'mem_aaaaaaaa', title: 'A2', scope: 'project', revision: 2, status: 'ok' }, { subCalls: [settled({ kind: 'memory-read', id: 'mem_aaaaaaaa', title: 'A3', scope: 'project', revision: 2, status: 'ok' })] }) },
|
|
102
|
+
{ root: settled({ kind: 'memory-read', id: 'mem_bbbbbbbb', title: 'B', scope: 'global', revision: 1, status: 'ok' }) },
|
|
62
103
|
];
|
|
63
104
|
const turn = dedupe(citationsOfTurn(nodes));
|
|
64
105
|
assert.equal(turn.length, 2);
|
|
65
|
-
assert.equal(turn[0].
|
|
66
|
-
assert.equal(turn[
|
|
67
|
-
assert.equal(turn[
|
|
106
|
+
assert.equal(turn[0].id, 'mem_aaaaaaaa');
|
|
107
|
+
assert.equal(turn[0].revision, 2);
|
|
108
|
+
assert.equal(turn[0].title, 'A3');
|
|
109
|
+
assert.equal(turn[1].id, 'mem_bbbbbbbb');
|
|
68
110
|
|
|
69
111
|
// 旧行没有元数据:什么也不产出,绝不从文案猜 id。
|
|
70
112
|
assert.deepEqual(citationsOfTurn([{ root: null }, { root: { kind: 'tool-result', content: [{ type: 'text', text: 'memory · saved "A" mem_aaaaaaaa' }] } }]), []);
|
|
71
113
|
|
|
72
|
-
console.log('client.test.mjs:
|
|
114
|
+
console.log('client.test.mjs: 11 passed');
|
package/test/contract.test.mjs
CHANGED
|
@@ -108,13 +108,13 @@ assert.equal(phrased.scope, 'project');
|
|
|
108
108
|
assert.equal(store.loadIndex(store.globalScope()).entries.some((item) => item.title === '以后都用这个一次性脚本'), false);
|
|
109
109
|
|
|
110
110
|
assert.equal(typeof saved.id, 'string');
|
|
111
|
-
assert.match(render('memory_save', {}, saved), /^memory · saved "发布顺序" · mem_[a-z0-9]
|
|
111
|
+
assert.match(render('memory_save', {}, saved), /^memory · saved \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
112
112
|
|
|
113
113
|
const merged = await exec('memory_save', { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n第二步' });
|
|
114
114
|
assert.equal(merged.merged, true);
|
|
115
115
|
assert.equal(merged.id, saved.id);
|
|
116
116
|
assert.equal(merged.revision, 2);
|
|
117
|
-
assert.match(render('memory_save', {}, merged), /^memory · merged "发布顺序" · mem_[a-z0-9]
|
|
117
|
+
assert.match(render('memory_save', {}, merged), /^memory · merged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
118
118
|
|
|
119
119
|
const patch = await exec('memory_save', { id: saved.id, title: '发布顺序', summary: '发版前要按顺序做的事', when: '发版当天' });
|
|
120
120
|
assert.equal(patch.when, '发版当天');
|
|
@@ -125,7 +125,7 @@ const again = await exec('memory_save', { title: '发布顺序', summary: '发
|
|
|
125
125
|
assert.equal(again.changed, false);
|
|
126
126
|
assert.equal(again.id, saved.id);
|
|
127
127
|
assert.equal(again.revision, 3);
|
|
128
|
-
assert.match(render('memory_save', {}, again), /^memory · unchanged "发布顺序" · mem_[a-z0-9]
|
|
128
|
+
assert.match(render('memory_save', {}, again), /^memory · unchanged \[[^\]]+\] "发布顺序" · mem_[a-z0-9]+$/);
|
|
129
129
|
|
|
130
130
|
// ── similar title asks before overwriting ────────────────────────────────────
|
|
131
131
|
const probe = await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '另一条' });
|
|
@@ -147,16 +147,20 @@ const hits = await exec('memory_search', { query: '发布流程' });
|
|
|
147
147
|
assert.ok(hits.count >= 1);
|
|
148
148
|
assert.ok(hits.chars > 0);
|
|
149
149
|
const hitText = render('memory_search', {}, hits);
|
|
150
|
-
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- mem_`));
|
|
151
|
-
assert.
|
|
150
|
+
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] mem_`));
|
|
151
|
+
assert.doesNotMatch(hitText, /第 .* 版/);
|
|
152
|
+
/** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
|
|
152
153
|
const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
|
|
153
154
|
assert.equal(miss.count, 0);
|
|
154
155
|
assert.equal(render('memory_search', {}, miss), 'memory · 没有命中');
|
|
155
156
|
|
|
156
157
|
const read = await exec('memory_read', { id: saved.id });
|
|
157
|
-
assert.equal(read.
|
|
158
|
-
assert.equal(read.
|
|
159
|
-
|
|
158
|
+
assert.equal(read.status, 'needs_context');
|
|
159
|
+
assert.equal(read.body, '');
|
|
160
|
+
const plain = await exec('memory_read', { id: distinct.id });
|
|
161
|
+
assert.equal(plain.body, '另一条');
|
|
162
|
+
assert.equal(plain.revision, 1);
|
|
163
|
+
assert.match(render('memory_read', {}, plain), new RegExp(`^memory · \\[[^\\]]+\\] 「发布顺序是啥」 · ${distinct.id}`));
|
|
160
164
|
|
|
161
165
|
// 没点明归档还是彻底忘掉时不动盘,先返回待确认
|
|
162
166
|
const undecided = await exec('memory_forget', { id: distinct.id });
|
|
@@ -165,7 +169,7 @@ assert.match(render('memory_forget', {}, undecided), new RegExp(`^memory · 先
|
|
|
165
169
|
assert.ok(store.locate(store.scopeOf(cwd), distinct.id));
|
|
166
170
|
const archived = await exec('memory_forget', { id: distinct.id, mode: 'archive' });
|
|
167
171
|
assert.equal(archived.id, distinct.id);
|
|
168
|
-
assert.match(render('memory_forget', {}, archived), new RegExp(`^memory · archived "发布顺序是啥" · ${distinct.id},可找回$`));
|
|
172
|
+
assert.match(render('memory_forget', {}, archived), new RegExp(`^memory · archived \\[[^\\]]+\\] "发布顺序是啥" · ${distinct.id},可找回$`));
|
|
169
173
|
assert.equal(await exec('memory_save', { title: '发布顺序是啥', summary: '发版前要按顺序做的事', body: '第三条' }).then((v) => v.merged), false);
|
|
170
174
|
|
|
171
175
|
// ── suggestion → keep → replay → reject ──────────────────────────────────────
|
|
@@ -227,10 +231,10 @@ assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 40
|
|
|
227
231
|
// ── global layer ─────────────────────────────────────────────────────────────
|
|
228
232
|
const global = await exec('memory_save', { title: '回复用中文', summary: '用户要求回复用中文', scope: 'global' });
|
|
229
233
|
assert.equal(global.scope, 'global');
|
|
230
|
-
assert.match(render('memory_save', {}, global), /^memory · saved \[
|
|
234
|
+
assert.match(render('memory_save', {}, global), /^memory · saved \[global\] "回复用中文" · mem_[a-z0-9]+$/);
|
|
231
235
|
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
232
236
|
assert.equal(globalSearch.count, 1);
|
|
233
|
-
assert.match(render('memory_search', {}, globalSearch), /\[
|
|
237
|
+
assert.match(render('memory_search', {}, globalSearch), /\[global\] mem_/);
|
|
234
238
|
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
235
239
|
const stats = globalThis.__liteMemoryStats(cwd);
|
|
236
240
|
assert.ok(stats.count >= 1);
|
|
@@ -415,6 +419,102 @@ const evoDelete = await call('/lite-memory/evolution', {
|
|
|
415
419
|
assert.equal(evoDelete.status, 200);
|
|
416
420
|
assert.equal(evoDelete.body.ok, true);
|
|
417
421
|
|
|
422
|
+
// 管理面成功路径:候选可修改、采纳、暂停、恢复、放弃,每次返回后能继续操作
|
|
423
|
+
const manageSeed = await defs.get('memory_save').execute(
|
|
424
|
+
{ title: '口播收尾', summary: '口播收尾留一句行动', body: '结尾给一句具体行动。' },
|
|
425
|
+
{ agent: evoAgent },
|
|
426
|
+
);
|
|
427
|
+
state.transact(evoReadScope, {}, (draft) => {
|
|
428
|
+
const row = draft.entries.find((entry) => entry.id === manageSeed.id);
|
|
429
|
+
row.origin = 'project_evolution';
|
|
430
|
+
row.current.conditions = [{ key: 'seriesId', value: '口播' }];
|
|
431
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'pending', source: 'evolution' } };
|
|
432
|
+
return { changed: true };
|
|
433
|
+
});
|
|
434
|
+
const manageSnap = await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' });
|
|
435
|
+
const manageRule = manageSnap.body.rules.find((rule) => rule.id === manageSeed.id);
|
|
436
|
+
assert.ok(manageRule);
|
|
437
|
+
assert.equal(manageRule.state, 'pending');
|
|
438
|
+
const manageEdit = await call('/lite-memory/evolution', {
|
|
439
|
+
sessionId: 'session-evo-read', learningAction: 'edit', id: manageSeed.id,
|
|
440
|
+
expectedRevision: manageRule.revision, body: '结尾给一句具体动作。',
|
|
441
|
+
});
|
|
442
|
+
assert.equal(manageEdit.status, 200);
|
|
443
|
+
assert.equal(manageEdit.body.ok, true);
|
|
444
|
+
const afterManageEdit = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id);
|
|
445
|
+
assert.equal(afterManageEdit.state, 'pending');
|
|
446
|
+
assert.equal(afterManageEdit.body, '结尾给一句具体动作。');
|
|
447
|
+
const manageAdopt = await call('/lite-memory/evolution', {
|
|
448
|
+
sessionId: 'session-evo-read', learningAction: 'adopt', id: manageSeed.id, expectedRevision: afterManageEdit.revision,
|
|
449
|
+
});
|
|
450
|
+
assert.equal(manageAdopt.status, 200);
|
|
451
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id).state, 'active');
|
|
452
|
+
const managePause = await call('/lite-memory/evolution', {
|
|
453
|
+
sessionId: 'session-evo-read', learningAction: 'pause', ids: [manageSeed.id],
|
|
454
|
+
});
|
|
455
|
+
assert.equal(managePause.status, 200);
|
|
456
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id).state, 'paused');
|
|
457
|
+
const manageResume = await call('/lite-memory/evolution', {
|
|
458
|
+
sessionId: 'session-evo-read', learningAction: 'resume', id: manageSeed.id,
|
|
459
|
+
});
|
|
460
|
+
assert.equal(manageResume.status, 200);
|
|
461
|
+
const manageResumed = (await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.find((rule) => rule.id === manageSeed.id);
|
|
462
|
+
assert.equal(manageResumed.state, 'trial');
|
|
463
|
+
const manageDiscard = await call('/lite-memory/evolution', {
|
|
464
|
+
sessionId: 'session-evo-read', learningAction: 'discard', id: manageSeed.id, expectedRevision: manageResumed.revision,
|
|
465
|
+
});
|
|
466
|
+
assert.equal(manageDiscard.status, 200);
|
|
467
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-evo-read', learningAction: 'snapshot' })).body.rules.length, 0);
|
|
468
|
+
|
|
469
|
+
// ── 关闭项目记忆后:面板只读,管理面写操作全部拒绝,工具也不落盘 ─────────────
|
|
470
|
+
const closedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-closed-'));
|
|
471
|
+
sessionCwds.set('session-closed', closedCwd);
|
|
472
|
+
const closedScope = store.scopeOf(closedCwd);
|
|
473
|
+
const closedAgent = { id: 'session-closed', cwd: closedCwd, session: { id: 'session-closed', header: { cwd: closedCwd } } };
|
|
474
|
+
const closedSeed = await defs.get('memory_save').execute(
|
|
475
|
+
{ title: '口播开头', summary: '口播开头三句内给结论', body: '开场三句内给结论。' },
|
|
476
|
+
{ agent: closedAgent },
|
|
477
|
+
);
|
|
478
|
+
state.transact(closedScope, {}, (draft) => {
|
|
479
|
+
const row = draft.entries.find((entry) => entry.id === closedSeed.id);
|
|
480
|
+
row.origin = 'project_evolution';
|
|
481
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'active' } };
|
|
482
|
+
return { changed: true };
|
|
483
|
+
});
|
|
484
|
+
assert.equal((await call('/lite-memory/evolution', { sessionId: 'session-closed', enabled: true })).status, 200);
|
|
485
|
+
const closedOff = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: false, operationId: 'op_close_contract' });
|
|
486
|
+
assert.equal(closedOff.status, 200);
|
|
487
|
+
assert.equal(closedOff.body.enabled, false);
|
|
488
|
+
const closedFile = path.join(closedScope.dir, 'state.json');
|
|
489
|
+
const closedBefore = fs.readFileSync(closedFile);
|
|
490
|
+
const closedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
491
|
+
assert.equal(closedSnapshot.status, 200);
|
|
492
|
+
assert.equal(closedSnapshot.body.enabled, false);
|
|
493
|
+
assert.equal(closedSnapshot.body.rules.length, 1);
|
|
494
|
+
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
495
|
+
const closedRevision = closedSnapshot.body.rules[0].revision;
|
|
496
|
+
for (const action of ['adopt', 'edit', 'delete', 'pause', 'resume', 'pin', 'undo']) {
|
|
497
|
+
const payload = { sessionId: 'session-closed', learningAction: action, id: closedSeed.id, expectedRevision: closedRevision, body: '改过的正文' };
|
|
498
|
+
if (action === 'pin') payload.pinned = true;
|
|
499
|
+
if (action === 'pause') payload.ids = [closedSeed.id];
|
|
500
|
+
const result = await call('/lite-memory/evolution', payload);
|
|
501
|
+
assert.equal(result.status, 409, action);
|
|
502
|
+
assert.equal(result.body.code, 'memory_disabled', action);
|
|
503
|
+
}
|
|
504
|
+
const closedTool = await defs.get('memory_save').execute(
|
|
505
|
+
{ title: '关闭后写入', summary: '应当拒绝', body: '不该落盘' },
|
|
506
|
+
{ agent: closedAgent },
|
|
507
|
+
);
|
|
508
|
+
assert.equal(closedTool.changed, false);
|
|
509
|
+
assert.equal(closedTool.reason, 'memory_disabled');
|
|
510
|
+
assert.equal(closedTool.id, '');
|
|
511
|
+
assert.equal(Buffer.compare(closedBefore, fs.readFileSync(closedFile)), 0);
|
|
512
|
+
const reopened = await call('/lite-memory/control', { sessionId: 'session-closed', enabled: true, operationId: 'op_reopen_contract' });
|
|
513
|
+
assert.equal(reopened.status, 200);
|
|
514
|
+
const reopenedSnapshot = await call('/lite-memory/evolution', { sessionId: 'session-closed', learningAction: 'snapshot' });
|
|
515
|
+
assert.equal(reopenedSnapshot.body.enabled, false);
|
|
516
|
+
assert.equal(reopenedSnapshot.body.rules.length, 1);
|
|
517
|
+
|
|
418
518
|
// ── 界面只吃持久元数据,不猜文案 ─────────────────────────────────────────────
|
|
419
519
|
const metaOf = (name, args, value) => defs.get(name).output.presentationMeta(args, value);
|
|
420
520
|
const savedMeta = metaOf('memory_save', {}, saved);
|
|
@@ -674,4 +774,107 @@ assert.equal(orgEconomy2.knownCalls, 3);
|
|
|
674
774
|
assert.equal(orgEconomy2.inputTokens, 36);
|
|
675
775
|
llmImpl = null;
|
|
676
776
|
|
|
777
|
+
// ── 记忆面板总览:只读枚举各项目层,正文按 scopeKey 单读,不泄露内部路径 ──────
|
|
778
|
+
const ovCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-'));
|
|
779
|
+
const ovOtherCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-other-'));
|
|
780
|
+
sessionCwds.set('session-overview', ovCwd);
|
|
781
|
+
sessionCwds.set('session-overview-other', ovOtherCwd);
|
|
782
|
+
const ovAgent = { id: 'session-overview', cwd: ovCwd, session: { id: 'session-overview', header: { cwd: ovCwd } } };
|
|
783
|
+
const ovFirst = await defs.get('memory_save').execute(
|
|
784
|
+
{ title: '开场先给结论', summary: '口播开头先给结论', body: '开场三句内给结论。', tags: ['口播', '开头'], links: [] },
|
|
785
|
+
{ agent: ovAgent },
|
|
786
|
+
);
|
|
787
|
+
const ovSecond = await defs.get('memory_save').execute(
|
|
788
|
+
{ title: '封面留白', summary: '封面别放满', body: '右侧留白。', tags: ['封面'] },
|
|
789
|
+
{ agent: ovAgent },
|
|
790
|
+
);
|
|
791
|
+
await defs.get('memory_save').execute(
|
|
792
|
+
{ title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scope: 'global' },
|
|
793
|
+
{ agent: ovAgent },
|
|
794
|
+
);
|
|
795
|
+
const ovOther = await defs.get('memory_save').execute(
|
|
796
|
+
{ title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
|
|
797
|
+
{ agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
|
|
798
|
+
);
|
|
799
|
+
const ov = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
800
|
+
assert.equal(ov.status, 200);
|
|
801
|
+
assert.equal(ov.body.ok, true);
|
|
802
|
+
assert.equal(ov.body.current.key, store.scopeOf(ovCwd).key);
|
|
803
|
+
assert.equal(ov.body.current.label, path.basename(ovCwd));
|
|
804
|
+
assert.equal(ov.body.current.evolution.enabled, false);
|
|
805
|
+
assert.equal(ov.body.selected.key, ov.body.current.key);
|
|
806
|
+
assert.equal(ov.body.selected.kind, 'project');
|
|
807
|
+
assert.equal(ov.body.selected.evolution.enabled, false);
|
|
808
|
+
assert.ok(Array.isArray(ov.body.selected.evolution.rules));
|
|
809
|
+
assert.ok(ov.body.selected.records.some((row) => row.id === ovFirst.id && row.title === '开场先给结论'));
|
|
810
|
+
assert.equal(ov.body.selected.records.some((row) => 'body' in row), false, '列表不带正文');
|
|
811
|
+
assert.equal(ov.body.selected.stats.total, 2);
|
|
812
|
+
assert.equal(ov.body.selected.stats.tags, 3);
|
|
813
|
+
assert.equal(JSON.stringify(ov.body).includes('"dir"'), false, '不暴露内部目录');
|
|
814
|
+
assert.equal(JSON.stringify(ov.body).includes(process.env.DSH_HOME), false);
|
|
815
|
+
const ovKeys = ov.body.scopes.map((row) => row.key);
|
|
816
|
+
assert.ok(ovKeys.includes(ov.body.current.key));
|
|
817
|
+
assert.ok(ovKeys.includes(store.scopeOf(ovOtherCwd).key));
|
|
818
|
+
assert.ok(ovKeys.includes('global'), '全局层总在列表里');
|
|
819
|
+
|
|
820
|
+
// 切到别的项目只是换一面看,不写那个项目
|
|
821
|
+
const ovOtherView = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
822
|
+
assert.equal(ovOtherView.status, 200);
|
|
823
|
+
assert.equal(ovOtherView.body.selected.key, store.scopeOf(ovOtherCwd).key);
|
|
824
|
+
assert.deepEqual(ovOtherView.body.selected.records.map((row) => row.title), ['别的项目']);
|
|
825
|
+
assert.equal(ovOtherView.body.selected.evolution.enabled, false, '别的项目也要给只读实验状态');
|
|
826
|
+
assert.equal(ovOtherView.body.current.key, ov.body.current.key);
|
|
827
|
+
const ovGlobal = await call('/lite-memory/overview', { sessionId: 'session-overview', scopeKey: 'global' });
|
|
828
|
+
assert.equal(ovGlobal.status, 200);
|
|
829
|
+
assert.equal(ovGlobal.body.selected.kind, 'global');
|
|
830
|
+
assert.ok(ovGlobal.body.selected.records.some((row) => row.title === '观察全局'));
|
|
831
|
+
|
|
832
|
+
// 详情按服务端枚举过的 scopeKey 取;内部路径、未知键一律不放行
|
|
833
|
+
const ovBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: ov.body.current.key });
|
|
834
|
+
assert.equal(ovBody.status, 200);
|
|
835
|
+
assert.equal(ovBody.body.body, '开场三句内给结论。');
|
|
836
|
+
assert.equal(ovBody.body.scopeLabel, path.basename(ovCwd));
|
|
837
|
+
const ovOtherBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovOther.id, scopeKey: store.scopeOf(ovOtherCwd).key });
|
|
838
|
+
assert.equal(ovOtherBody.status, 200);
|
|
839
|
+
assert.equal(ovOtherBody.body.scopeLabel, path.basename(ovOtherCwd));
|
|
840
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: 'nope' })).status, 404);
|
|
841
|
+
assert.equal((await call('/lite-memory/body', { sessionId: 'session-overview', id: ovFirst.id, scopeKey: '../x' })).status, 404);
|
|
842
|
+
assert.equal((await call('/lite-memory/overview', {})).status, 400);
|
|
843
|
+
assert.equal((await call('/lite-memory/overview', { sessionId: 'session-overview' }, { origin: 'http://evil.example' })).status, 403);
|
|
844
|
+
const ovUnknownCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-overview-unknown-'));
|
|
845
|
+
sessionCwds.set('session-overview-unknown', ovUnknownCwd);
|
|
846
|
+
const ovUnconfirmedSaved = process.env.PATH;
|
|
847
|
+
process.env.PATH = '';
|
|
848
|
+
const ovUnconfirmed = await call('/lite-memory/overview', { sessionId: 'session-overview-unknown' });
|
|
849
|
+
process.env.PATH = ovUnconfirmedSaved;
|
|
850
|
+
assert.equal(ovUnconfirmed.status, 409);
|
|
851
|
+
assert.equal(fs.existsSync(path.join(store.scopeOf(ovUnknownCwd).dir, 'state.json')), false);
|
|
852
|
+
|
|
853
|
+
// 实验条目也画进图里,但只有面板能看见;普通读取面仍然拿不到
|
|
854
|
+
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
855
|
+
const row = draft.entries.find((entry) => entry.id === ovSecond.id);
|
|
856
|
+
row.origin = 'project_evolution';
|
|
857
|
+
row.maintenance = { ...(row.maintenance || {}), learning: { state: 'trial', source: 'evolution' } };
|
|
858
|
+
return { changed: true };
|
|
859
|
+
});
|
|
860
|
+
const ovExperimental = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
861
|
+
assert.equal(ovExperimental.body.selected.stats.experimental, 1);
|
|
862
|
+
assert.equal(ovExperimental.body.selected.records.find((row) => row.id === ovSecond.id).experimental, true);
|
|
863
|
+
assert.equal(store.loadIndex(store.scopeOf(ovCwd), { allowExperimental: true }).entries.some((row) => row.id === ovSecond.id), true);
|
|
864
|
+
|
|
865
|
+
// 归档不是死数字:总数诚实,列表单独取,正文还能按 id 打开
|
|
866
|
+
state.transact(store.scopeOf(ovCwd), {}, (draft) => {
|
|
867
|
+
const row = draft.entries.find((entry) => entry.id === ovSecond.id);
|
|
868
|
+
row.status = 'archived';
|
|
869
|
+
return { changed: true };
|
|
870
|
+
});
|
|
871
|
+
const ovArchived = await call('/lite-memory/overview', { sessionId: 'session-overview' });
|
|
872
|
+
assert.equal(ovArchived.body.selected.stats.archived, 1);
|
|
873
|
+
assert.equal(ovArchived.body.selected.records.some((row) => row.id === ovSecond.id), false);
|
|
874
|
+
assert.equal(ovArchived.body.selected.archived.some((row) => row.id === ovSecond.id), true);
|
|
875
|
+
const ovArchivedBody = await call('/lite-memory/body', { sessionId: 'session-overview', id: ovSecond.id, scopeKey: ovArchived.body.selected.key });
|
|
876
|
+
assert.equal(ovArchivedBody.status, 200);
|
|
877
|
+
assert.equal(ovArchivedBody.body.status, 'archived');
|
|
878
|
+
assert.equal(ovArchivedBody.body.body, '右侧留白。');
|
|
879
|
+
|
|
677
880
|
console.log('contract.test.mjs: all passed');
|