dsh-lite-memory 0.11.1 → 0.16.2
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 +152 -13
- package/client.js +1083 -120
- 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/docs/RELEASE-0.16.0.md +10 -0
- package/index.js +704 -102
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +112 -11
- package/store.js +343 -44
- package/test/anchor.test.mjs +311 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +297 -0
- package/test/evolution-read.test.mjs +196 -11
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +25 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +98 -8
- package/view.js +16 -1
|
@@ -102,7 +102,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
102
102
|
assert.equal(runtime.debug.windowOf(agent).experiment, null);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/* L02
|
|
105
|
+
/* L02:开关打开但没有任何栏目,不注入任何领域问题,也不给空上下文。 */
|
|
106
106
|
{
|
|
107
107
|
const dir = mkdir('clarify');
|
|
108
108
|
const scope = scopeOf(dir);
|
|
@@ -112,30 +112,50 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
112
112
|
const sig = signal();
|
|
113
113
|
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
|
|
114
114
|
const text = runtime.text(agent, sig);
|
|
115
|
-
assert.
|
|
116
|
-
assert.
|
|
115
|
+
assert.equal(text, '');
|
|
116
|
+
assert.doesNotMatch(text, /栏目|Vlog|口播|视频/);
|
|
117
|
+
const experiment = runtime.debug.windowOf(agent).experiment;
|
|
118
|
+
assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/* L03:未绑定任务只给栏目索引,不超过三条;短规则不进正文。 */
|
|
120
122
|
{
|
|
121
123
|
const dir = mkdir('index');
|
|
122
124
|
const scope = scopeOf(dir);
|
|
123
|
-
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '
|
|
124
|
-
seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '
|
|
125
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
|
|
126
|
+
seedRule(scope, { title: '口播收尾', body: '结尾只留一个行动。', conditions: [{ key: 'seriesId', value: '口播视频' }] });
|
|
125
127
|
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
126
128
|
enable(scope);
|
|
127
129
|
const runtime = boot();
|
|
128
130
|
const agent = makeAgent('a-index', dir);
|
|
129
131
|
const sig = signal();
|
|
130
|
-
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('
|
|
132
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('视频开头怎么改')));
|
|
131
133
|
const text = runtime.text(agent, sig);
|
|
132
|
-
assert.match(text,
|
|
134
|
+
assert.match(text, /这些记忆范围可能适用/);
|
|
135
|
+
assert.doesNotMatch(text, /Vlog/);
|
|
133
136
|
assert.doesNotMatch(text, /开场三句内给结论/);
|
|
134
137
|
const listed = text.split('\n').filter((line) => line.startsWith('- '));
|
|
135
138
|
assert.equal(listed.length <= 3, true);
|
|
136
139
|
assert.equal(Buffer.byteLength(text) <= 600 + 160, true, 'index stays small');
|
|
137
140
|
}
|
|
138
141
|
|
|
142
|
+
/* L03b:项目有视频栏目,本轮是修代码:无关栏目与选择提示都不进上下文。 */
|
|
143
|
+
{
|
|
144
|
+
const dir = mkdir('index-unrelated');
|
|
145
|
+
const scope = scopeOf(dir);
|
|
146
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
147
|
+
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
148
|
+
enable(scope);
|
|
149
|
+
const runtime = boot();
|
|
150
|
+
const agent = makeAgent('a-index-unrelated', dir);
|
|
151
|
+
const sig = signal();
|
|
152
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('修一下 dsh-unlag 的重新载入问题')));
|
|
153
|
+
const injected = runtime.text(agent, sig);
|
|
154
|
+
assert.equal(injected, '');
|
|
155
|
+
const experiment = runtime.debug.windowOf(agent).experiment;
|
|
156
|
+
assert.equal(experiment ? experiment.injectedIds.length : 0, 0);
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/* L04:项目默认绑定后给规则正文,带 id 与版本。 */
|
|
140
160
|
{
|
|
141
161
|
const dir = mkdir('default');
|
|
@@ -150,7 +170,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
150
170
|
assert.match(text, /项目实验规则/);
|
|
151
171
|
assert.match(text, /先给结论再解释/);
|
|
152
172
|
assert.ok(text.includes(id));
|
|
153
|
-
assert.
|
|
173
|
+
assert.doesNotMatch(text, /第 .* 版/);
|
|
154
174
|
}
|
|
155
175
|
|
|
156
176
|
/* L05:长规则只给用途与 memory_read 指针,不把正文塞进上下文。 */
|
|
@@ -247,6 +267,22 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
247
267
|
assert.ok(current, 'switch leaves an unbound episode');
|
|
248
268
|
}
|
|
249
269
|
|
|
270
|
+
/* L09b:明确改为排查数据库后,旧视频规则和栏目提示都不再注入。 */
|
|
271
|
+
{
|
|
272
|
+
const dir = mkdir('switch-db');
|
|
273
|
+
const scope = scopeOf(dir);
|
|
274
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
275
|
+
enable(scope, { seriesId: '口播' });
|
|
276
|
+
const runtime = boot();
|
|
277
|
+
const agent = makeAgent('a-switch-db', dir);
|
|
278
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期口播开头')));
|
|
279
|
+
const sig = signal();
|
|
280
|
+
await runtime.preStep({ agent, signal: sig, turn: 't2' }, enter(human('这期不做了,改为排查 PostgreSQL 慢查询')));
|
|
281
|
+
const text = runtime.text(agent, sig);
|
|
282
|
+
assert.equal(text.includes('开场三句内给结论'), false);
|
|
283
|
+
assert.equal(/栏目|点名/.test(text), false);
|
|
284
|
+
}
|
|
285
|
+
|
|
250
286
|
/* L10:没有本请求上下文时,普通检索看不到实验来源。 */
|
|
251
287
|
{
|
|
252
288
|
const dir = mkdir('nocx');
|
|
@@ -260,7 +296,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
260
296
|
assert.equal((value.refs || []).some((ref) => ref.id === id), false);
|
|
261
297
|
}
|
|
262
298
|
|
|
263
|
-
/* L11
|
|
299
|
+
/* L11:真人原话点名栏目就直接生效;工具绑定仍只认这句原话。 */
|
|
264
300
|
{
|
|
265
301
|
const dir = mkdir('bind');
|
|
266
302
|
const scope = scopeOf(dir);
|
|
@@ -269,7 +305,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
269
305
|
const runtime = boot();
|
|
270
306
|
const agent = makeAgent('a-bind', dir);
|
|
271
307
|
const sig = signal();
|
|
272
|
-
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('
|
|
308
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么开头')));
|
|
273
309
|
const held = runtime.debug.windowOf(agent).experiment;
|
|
274
310
|
assert.ok(held && held.contextId, 'context leaked');
|
|
275
311
|
const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
|
|
@@ -277,10 +313,30 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
277
313
|
assert.ok((value.text || '').includes('口播'));
|
|
278
314
|
const episodes = Object.values(store.taskState(scope).episodes);
|
|
279
315
|
assert.equal(episodes.length, 1);
|
|
280
|
-
assert.equal(episodes[0].status, '
|
|
316
|
+
assert.equal(episodes[0].status, 'active');
|
|
281
317
|
assert.match(runtime.text(agent, sig), /开场三句内给结论/);
|
|
282
318
|
}
|
|
283
319
|
|
|
320
|
+
/* L11b:模型自己写 query 不能建绑定;只认本轮真人原话。 */
|
|
321
|
+
{
|
|
322
|
+
const dir = mkdir('bind-model');
|
|
323
|
+
const scope = scopeOf(dir);
|
|
324
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
325
|
+
enable(scope);
|
|
326
|
+
const runtime = boot();
|
|
327
|
+
const agent = makeAgent('a-bind-model', dir);
|
|
328
|
+
const sig = signal();
|
|
329
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期怎么开头')));
|
|
330
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
331
|
+
const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
|
|
332
|
+
assert.equal(value.status, 'needs_context');
|
|
333
|
+
assert.equal((value.text || '').includes('口播'), false);
|
|
334
|
+
assert.equal((value.text || '').includes('开场三句内给结论'), false);
|
|
335
|
+
const episodes = Object.values(store.taskState(scope).episodes);
|
|
336
|
+
assert.equal(episodes.some((episode) => episode.status === 'proposed'), false);
|
|
337
|
+
assert.equal(runtime.text(agent, sig).includes('开场三句内给结论'), false);
|
|
338
|
+
}
|
|
339
|
+
|
|
284
340
|
/* L12:跨项目上下文直接拒绝。 */
|
|
285
341
|
{
|
|
286
342
|
const dirA = mkdir('cross-a');
|
|
@@ -390,4 +446,133 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
390
446
|
assert.equal(text.includes('实验规则:发版前先备份'), false);
|
|
391
447
|
}
|
|
392
448
|
|
|
449
|
+
/* L18:工具绑定零命中/多命中时只回一句读取状态,不要求用户点名栏目。 */
|
|
450
|
+
{
|
|
451
|
+
const dir = mkdir('bind-none');
|
|
452
|
+
const scope = scopeOf(dir);
|
|
453
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
454
|
+
seedRule(scope, { title: 'Vlog 节奏', body: '每段不超过二十秒。', conditions: [{ key: 'seriesId', value: 'Vlog' }] });
|
|
455
|
+
enable(scope);
|
|
456
|
+
const runtime = boot();
|
|
457
|
+
const agent = makeAgent('a-bind-none', dir);
|
|
458
|
+
await runtime.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('这期怎么开头')));
|
|
459
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
460
|
+
const none = await runtime.exec('memory_search', { query: '修一下构建脚本', contextId: held.contextId, bindForRequest: true }, agent);
|
|
461
|
+
assert.equal(none.status, 'needs_context');
|
|
462
|
+
assert.doesNotMatch(none.text, /点名|先问|栏目/);
|
|
463
|
+
const many = await runtime.exec('memory_search', { query: '口播和 Vlog 都要', contextId: held.contextId, bindForRequest: true }, agent);
|
|
464
|
+
assert.equal(many.status, 'needs_context');
|
|
465
|
+
assert.doesNotMatch(many.text, /点名|先问|栏目/);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* L19:试用到期后不再自动注入,和检索、读取用同一档可用性。 */
|
|
469
|
+
{
|
|
470
|
+
const dir = mkdir('trial-expired');
|
|
471
|
+
const scope = scopeOf(dir);
|
|
472
|
+
const id = seedRule(scope, {
|
|
473
|
+
title: '过期口播规则',
|
|
474
|
+
body: '这条试用已经到期,不该自动出现。',
|
|
475
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
476
|
+
learning: { state: 'trial', trialUntil: new Date(Date.now() - 60_000).toISOString() },
|
|
477
|
+
});
|
|
478
|
+
enable(scope, { seriesId: '口播' });
|
|
479
|
+
const runtime = boot();
|
|
480
|
+
const agent = makeAgent('a-trial-expired', dir);
|
|
481
|
+
const sig = signal();
|
|
482
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
483
|
+
const text = runtime.text(agent, sig);
|
|
484
|
+
assert.equal(text.includes('这条试用已经到期,不该自动出现。'), false);
|
|
485
|
+
const held = runtime.debug.windowOf(agent).experiment;
|
|
486
|
+
assert.deepEqual(held ? held.injectedIds : [], []);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* L19b:准备时还在试用期、请求中途到期,同一请求也不能再输出正文。 */
|
|
490
|
+
{
|
|
491
|
+
const dir = mkdir('trial-expiring');
|
|
492
|
+
const scope = scopeOf(dir);
|
|
493
|
+
seedRule(scope, {
|
|
494
|
+
title: '快到期口播规则',
|
|
495
|
+
body: '这条试用马上到期,过期后同一请求也不能再出现。',
|
|
496
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
497
|
+
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
498
|
+
});
|
|
499
|
+
enable(scope, { seriesId: '口播' });
|
|
500
|
+
const runtime = boot();
|
|
501
|
+
const agent = makeAgent('a-trial-expiring', dir);
|
|
502
|
+
const sig = signal();
|
|
503
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
504
|
+
assert.match(runtime.text(agent, sig), /马上到期/);
|
|
505
|
+
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
506
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/* L19c:一条试用到期只撤这一条,不把同请求里其他可用规则一起清掉。 */
|
|
510
|
+
{
|
|
511
|
+
const dir = mkdir('trial-parts');
|
|
512
|
+
const scope = scopeOf(dir);
|
|
513
|
+
seedRule(scope, {
|
|
514
|
+
title: '快到期的主题规则',
|
|
515
|
+
body: '这条试用马上到期。',
|
|
516
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
517
|
+
learning: { state: 'trial', trialUntil: new Date(Date.now() + 700).toISOString() },
|
|
518
|
+
topicKey: 'theme',
|
|
519
|
+
});
|
|
520
|
+
seedRule(scope, {
|
|
521
|
+
title: '长期口播开头',
|
|
522
|
+
body: '开场三句内给结论。',
|
|
523
|
+
conditions: [{ key: 'seriesId', value: '口播' }],
|
|
524
|
+
learning: { state: 'active' },
|
|
525
|
+
topicKey: 'opening',
|
|
526
|
+
});
|
|
527
|
+
enable(scope, { seriesId: '口播' });
|
|
528
|
+
const runtime = boot();
|
|
529
|
+
const agent = makeAgent('a-trial-parts', dir);
|
|
530
|
+
const sig = signal();
|
|
531
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
532
|
+
const first = runtime.text(agent, sig);
|
|
533
|
+
assert.match(first, /快到期的主题规则/);
|
|
534
|
+
assert.match(first, /长期口播开头/);
|
|
535
|
+
await new Promise((resolve) => setTimeout(resolve, 900));
|
|
536
|
+
const later = runtime.text(agent, sig);
|
|
537
|
+
assert.doesNotMatch(later, /快到期的主题规则/);
|
|
538
|
+
assert.match(later, /长期口播开头/, '无关的试用到期不该清掉别的规则');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/* L20:自进化关掉后,已经准备好的实验正文立刻不再输出;重开也不复活。 */
|
|
542
|
+
{
|
|
543
|
+
const dir = mkdir('off-after-prepare');
|
|
544
|
+
const scope = scopeOf(dir);
|
|
545
|
+
seedRule(scope, { title: '口播开头', body: '开场三句内给结论。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
546
|
+
enable(scope, { seriesId: '口播' });
|
|
547
|
+
const runtime = boot();
|
|
548
|
+
const agent = makeAgent('a-off-after-prepare', dir);
|
|
549
|
+
const sig = signal();
|
|
550
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么做')));
|
|
551
|
+
assert.match(runtime.text(agent, sig), /开场三句内给结论/);
|
|
552
|
+
assert.equal(store.setEvolution(scope, false, { operationId: 'evo-off-after-prepare' }).ok, true);
|
|
553
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
554
|
+
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-on-again' }).ok, true);
|
|
555
|
+
assert.equal(runtime.text(agent, sig), '');
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/* L21:检索没有可用命中、但有条件未确认的候选时,只说状态,不把标题递给模型。 */
|
|
559
|
+
{
|
|
560
|
+
const dir = mkdir('search-withheld');
|
|
561
|
+
const scope = scopeOf(dir);
|
|
562
|
+
save(scope, {
|
|
563
|
+
title: '发布前检查清单',
|
|
564
|
+
body: '按清单逐项确认。',
|
|
565
|
+
summary: '发布前逐项确认',
|
|
566
|
+
applicability: 'unresolved',
|
|
567
|
+
});
|
|
568
|
+
const runtime = boot();
|
|
569
|
+
const agent = makeAgent('a-search-withheld', dir);
|
|
570
|
+
const value = await runtime.exec('memory_search', { query: '发布前检查' }, agent);
|
|
571
|
+
assert.equal(value.count, 0);
|
|
572
|
+
assert.equal(value.status, 'needs_context');
|
|
573
|
+
assert.equal(value.reasonCode, 'applicability_unresolved');
|
|
574
|
+
assert.equal(value.text.includes('发布前检查清单'), false);
|
|
575
|
+
assert.match(value.text, /适用条件还没确认/);
|
|
576
|
+
}
|
|
577
|
+
|
|
393
578
|
console.log('evolution-read.test.mjs: all passed');
|
package/test/learning.test.mjs
CHANGED
|
@@ -180,7 +180,9 @@ test('B01 调用输入不超过 8 KiB 且只有本地引用', () => {
|
|
|
180
180
|
ranges: { taskKinds: ['video'], series: ['口播'] },
|
|
181
181
|
});
|
|
182
182
|
assert.ok(input.bytes <= 8192, `bytes=${input.bytes}`);
|
|
183
|
-
assert.equal(input.rules.length,
|
|
183
|
+
assert.equal(input.rules.length >= 1, true);
|
|
184
|
+
assert.equal(input.rules.length <= 6, true);
|
|
185
|
+
assert.equal(input.prompt.includes(long), false);
|
|
184
186
|
assert.equal(input.refs.feedback.u0, 'm1');
|
|
185
187
|
assert.equal(input.refs.rules.r0, 'mem_0');
|
|
186
188
|
assert.equal(input.messages.length, 1);
|
|
@@ -470,7 +472,7 @@ test('P02 两个独立作品后升为已生效,单次只到试用', () => {
|
|
|
470
472
|
const version = ruleOf(scope, id).revision;
|
|
471
473
|
const rules = [{ ref: 'r0', id, revision: version, conditions: [{ key: 'seriesId', value: '口播' }] }];
|
|
472
474
|
const refs = { feedback: { u0: 'b1' }, rules: { r0: id } };
|
|
473
|
-
const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText:
|
|
475
|
+
const check = parsed({ target: 'r0', feedbackType: 'spec_change', currentText: NEW_CHANGE.currentText, evidence: ['u0'] }, { rules, refs });
|
|
474
476
|
const second = store.commitLearning(scope, {
|
|
475
477
|
operationId: 'learn_promote',
|
|
476
478
|
epoch: begin(scope, two).epoch,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-'));
|
|
7
|
+
const store = await import('../store.js');
|
|
8
|
+
|
|
9
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-lexical-scope-'));
|
|
10
|
+
const scope = store.scopeOf(dir);
|
|
11
|
+
const add = (title, extra = {}) => store.upsert(scope, {
|
|
12
|
+
applicability: 'all_in_scope',
|
|
13
|
+
body: `正文:${title}`,
|
|
14
|
+
summary: extra.summary || `${title}的摘要`,
|
|
15
|
+
tags: extra.tags || [],
|
|
16
|
+
resolution: 'distinct',
|
|
17
|
+
title,
|
|
18
|
+
});
|
|
19
|
+
add('digital signal 调试', { tags: ['音频'] });
|
|
20
|
+
add('怎么做发布检查', { tags: ['发布'] });
|
|
21
|
+
add('git 提交前跑检查', { tags: ['git'] });
|
|
22
|
+
|
|
23
|
+
/* 英文按整词:git 不能因为共享 gi/it 命中 digital。 */
|
|
24
|
+
assert.equal(store.search(scope, 'git', 5).some((row) => row.title.includes('digital')), false);
|
|
25
|
+
assert.equal(store.search(scope, 'digital', 5).some((row) => row.title.includes('digital')), true);
|
|
26
|
+
|
|
27
|
+
/* 只重合「怎么」这类高频词不算命中。 */
|
|
28
|
+
assert.equal(store.search(scope, '这个怎么弄', 5).length, 0);
|
|
29
|
+
|
|
30
|
+
/* 实词仍然命中。 */
|
|
31
|
+
assert.equal(store.search(scope, '发布检查', 5).some((row) => row.title.includes('怎么做发布检查')), true);
|
|
32
|
+
|
|
33
|
+
console.log('lexical.test.mjs: all passed');
|
package/test/recall.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const { apply } = await import('../index.js');
|
|
|
8
8
|
const store = await import('../store.js');
|
|
9
9
|
|
|
10
10
|
const HEADER = '已有记忆可能相关';
|
|
11
|
-
const CARD_LINE = /^- 「.+?」.* · mem_[a-z0-9]+
|
|
11
|
+
const CARD_LINE = /^- \[[^\]]+\] 「.+?」.* · mem_[a-z0-9]+(?:|.*)?$/;
|
|
12
12
|
const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
|
|
13
13
|
|
|
14
14
|
const effect = (fn) => {
|
|
@@ -122,7 +122,7 @@ for (let i = 0; i < 9; i += 1) {
|
|
|
122
122
|
summary: '读取预算测试',
|
|
123
123
|
tags: ['大正文'],
|
|
124
124
|
resolution: 'distinct',
|
|
125
|
-
body: `样本${i + 1}:${'正文内容'.repeat(
|
|
125
|
+
body: `样本${i + 1}:${'正文内容'.repeat(80)}`,
|
|
126
126
|
}));
|
|
127
127
|
}
|
|
128
128
|
|
|
@@ -142,7 +142,7 @@ assert.ok(Buffer.byteLength(preparedText) <= 1200, 'snapshot stays inside card b
|
|
|
142
142
|
const cards = bootA.provider.text({ agent: agentA, signal: signalA }).split('\n').slice(1);
|
|
143
143
|
assert.ok(cards.length <= 3);
|
|
144
144
|
for (const line of cards) assert.match(line, CARD_LINE);
|
|
145
|
-
assert.
|
|
145
|
+
assert.doesNotMatch(preparedText, /第 .* 版/);
|
|
146
146
|
assert.equal(bootA.provider.text({ agent: agentA, signal: signalA }), preparedText, 'frozen between calls');
|
|
147
147
|
|
|
148
148
|
// A03:provider 纯内存,不写盘、不动 hits。
|
|
@@ -265,7 +265,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
265
265
|
await bootA.preStep({ agent, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
|
|
266
266
|
const first = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
|
|
267
267
|
assert.equal(first.status, 'ok');
|
|
268
|
-
assert.ok(first.body
|
|
268
|
+
assert.ok(Buffer.byteLength(first.body) > 900);
|
|
269
269
|
const repeat = await bootA.exec('memory_read', { id: bigBodies[0].id }, agent);
|
|
270
270
|
assert.equal(repeat.repeat, true);
|
|
271
271
|
assert.equal(repeat.body, '');
|
|
@@ -273,13 +273,15 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
273
273
|
const second = await bootA.exec('memory_read', { id: bigBodies[1].id }, agent);
|
|
274
274
|
assert.equal(second.status, 'ok');
|
|
275
275
|
const third = await bootA.exec('memory_read', { id: bigBodies[2].id }, agent);
|
|
276
|
-
assert.equal(third.status, '
|
|
277
|
-
|
|
278
|
-
assert.equal(
|
|
279
|
-
assert.equal(
|
|
276
|
+
assert.equal(third.status, 'ok');
|
|
277
|
+
const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
|
|
278
|
+
assert.equal(fourth.status, 'budget_exhausted');
|
|
279
|
+
assert.equal(fourth.body || '', '');
|
|
280
|
+
assert.equal(fourth.id, '');
|
|
281
|
+
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
|
|
280
282
|
const win = bootA.debug.windowOf(agent);
|
|
281
283
|
assert.ok(win.bytes <= 8192);
|
|
282
|
-
assert.equal(win.bodies,
|
|
284
|
+
assert.equal(win.bodies, 3);
|
|
283
285
|
|
|
284
286
|
const agentReads = makeAgent('agent-reads', cwdA);
|
|
285
287
|
await bootA.preStep({ agent: agentReads, signal: signal(), turn: 't1' }, enter(human('大正文样本怎么用')));
|
|
@@ -445,3 +447,17 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
445
447
|
}
|
|
446
448
|
|
|
447
449
|
console.log('recall.test.mjs: ok');
|
|
450
|
+
|
|
451
|
+
// A20:条件未确认的条目,model 侧读正文只拿状态,不拿标题;面板仍可查。
|
|
452
|
+
{
|
|
453
|
+
const agent = makeAgent('agent-withheld-read', cwdA);
|
|
454
|
+
await prepare(bootA, agent, '发布前要检查什么');
|
|
455
|
+
const row = await bootA.exec('memory_read', { id: unresolved.id }, agent);
|
|
456
|
+
assert.equal(row.body, '');
|
|
457
|
+
assert.equal(row.status, 'needs_context');
|
|
458
|
+
assert.doesNotMatch(JSON.stringify(row), /发布前要检查什么/);
|
|
459
|
+
assert.doesNotMatch(bootA.render('memory_read', { id: unresolved.id }, row), /发布前要检查什么/);
|
|
460
|
+
const links = await bootA.exec('memory_read', { id: unresolved.id, view: 'links' }, agent);
|
|
461
|
+
assert.equal(links.status, 'needs_context');
|
|
462
|
+
assert.doesNotMatch(JSON.stringify(links), /发布前要检查什么/);
|
|
463
|
+
}
|
package/test/state.test.mjs
CHANGED
|
@@ -210,6 +210,27 @@ test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
|
|
|
210
210
|
assert.notEqual(scopeOf(nested).key, scopeOf(repo).key);
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
test('S03 配置的项目根只覆盖自己的子树,不吞别的目录', () => {
|
|
214
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-config-'));
|
|
215
|
+
dirs.push(root);
|
|
216
|
+
const sub = path.join(root, 'packages', 'app');
|
|
217
|
+
fs.mkdirSync(sub, { recursive: true });
|
|
218
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-outside-'));
|
|
219
|
+
dirs.push(outside);
|
|
220
|
+
const config = path.join(memoryRoot(), 'config.json');
|
|
221
|
+
fs.mkdirSync(path.dirname(config), { recursive: true });
|
|
222
|
+
fs.writeFileSync(config, JSON.stringify({ projectRoot: root }));
|
|
223
|
+
try {
|
|
224
|
+
assert.equal(resolveRoot(sub).root, fs.realpathSync(root));
|
|
225
|
+
assert.equal(resolveRoot(sub).source, 'configured');
|
|
226
|
+
assert.equal(scopeOf(sub).key, scopeOf(root).key);
|
|
227
|
+
assert.notEqual(resolveRoot(outside).source, 'configured');
|
|
228
|
+
assert.notEqual(scopeOf(outside).key, scopeOf(root).key);
|
|
229
|
+
} finally {
|
|
230
|
+
fs.rmSync(config, { force: true });
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
213
234
|
test('P04 Git 查询失败不冒充已确认工程,不用 cwd 兜底学习', () => {
|
|
214
235
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lm-state-gitfail-'));
|
|
215
236
|
dirs.push(dir);
|
package/test/store.test.mjs
CHANGED
|
@@ -40,6 +40,33 @@ const rawState = (scope) => fs.readFileSync(path.join(scope.dir, 'state.json'),
|
|
|
40
40
|
const countOf = (scope) => loadIndex(scope).entries.length;
|
|
41
41
|
const suggestionsOf = (scope) => loadIndex(scope).suggestions;
|
|
42
42
|
|
|
43
|
+
test('over-specific body is stored, not rejected for naming concrete values', () => {
|
|
44
|
+
const scope = fresh('specific');
|
|
45
|
+
const saved = upsert(scope, {
|
|
46
|
+
title: '自绘菜单',
|
|
47
|
+
summary: '菜单用应用自绘,不用系统原生',
|
|
48
|
+
body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
|
|
49
|
+
});
|
|
50
|
+
assert.equal(saved.record.title, '自绘菜单');
|
|
51
|
+
assert.ok(saved.record.body.includes('z-index'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('a 500-character body is accepted', () => {
|
|
55
|
+
const scope = fresh('toolong');
|
|
56
|
+
const saved = upsert(scope, { title: '长正文', summary: '长一点也要存下来', body: '记'.repeat(500) });
|
|
57
|
+
assert.equal(saved.record.title, '长正文');
|
|
58
|
+
assert.equal(saved.record.body.length, 500);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('正文正好到 4096 字节可以存,元数据不算进正文上限', () => {
|
|
62
|
+
const scope = fresh('body-cap');
|
|
63
|
+
const body = `${'测'.repeat(1365)}a`;
|
|
64
|
+
const saved = upsert(scope, { title: '正文到顶', summary: '正文刚好到上限', body });
|
|
65
|
+
assert.equal(saved.record.body, body);
|
|
66
|
+
assert.ok(saved.record.bytes > LIMITS.entryBytes);
|
|
67
|
+
assert.throws(() => upsert(scope, { title: '超一点', summary: '正文超一点', body: `${body}a` }), /正文 4097 字节/);
|
|
68
|
+
});
|
|
69
|
+
|
|
43
70
|
test('create then same title merges with full old body kept', () => {
|
|
44
71
|
const scope = fresh('create');
|
|
45
72
|
const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
|
|
@@ -126,16 +153,20 @@ test('oversized body is rejected, old data untouched', () => {
|
|
|
126
153
|
assert.equal(loadIndex(scope).entries[0].revision, 1);
|
|
127
154
|
});
|
|
128
155
|
|
|
129
|
-
test('over-long title
|
|
156
|
+
test('over-long title is an error; over-long summary is truncated but the full text is saved', () => {
|
|
130
157
|
const scope = fresh('length');
|
|
131
158
|
assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
|
|
132
|
-
|
|
133
|
-
|
|
159
|
+
const long = '长'.repeat(LIMITS.summaryChars + 1);
|
|
160
|
+
const saved = upsert(scope, { title: '长摘要', summary: long });
|
|
161
|
+
assert.equal(saved.record.summary.length, LIMITS.summaryChars);
|
|
162
|
+
assert.equal(readEntry(scope, saved.record.id).body, long);
|
|
163
|
+
assert.equal(saved.draft.fullTextInBody, true);
|
|
164
|
+
assert.equal(saved.draft.summaryShortened, true);
|
|
134
165
|
});
|
|
135
166
|
|
|
136
|
-
test('imperative summary
|
|
167
|
+
test('imperative summary is kept; credentials are still rejected', () => {
|
|
137
168
|
const scope = fresh('validate');
|
|
138
|
-
assert.
|
|
169
|
+
assert.equal(upsert(scope, { title: '回复语言', summary: '请用中文回复' }).record.summary, '请用中文回复');
|
|
139
170
|
assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
|
|
140
171
|
assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
|
|
141
172
|
assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
|
|
@@ -185,7 +216,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
|
|
|
185
216
|
assert.ok(when.some((item) => item.title === '文案检查'));
|
|
186
217
|
const rendered = renderIndex(hits);
|
|
187
218
|
assert.match(rendered.text, /^- mem_[a-z0-9]+ 发布顺序/);
|
|
188
|
-
assert.match(rendered.text,
|
|
219
|
+
assert.match(rendered.text, /mem_[a-z0-9]+ 发布顺序/);
|
|
220
|
+
assert.doesNotMatch(rendered.text, /第 .* 版/);
|
|
189
221
|
assert.ok(rendered.chars <= 400);
|
|
190
222
|
assert.deepEqual(search(scope, '完全无关的主题词'), []);
|
|
191
223
|
});
|
|
@@ -448,7 +480,7 @@ test('projects and the global layer stay separate', () => {
|
|
|
448
480
|
assert.equal(global.length, 1);
|
|
449
481
|
assert.equal(global[0].scope, 'global');
|
|
450
482
|
const rendered = renderIndex(global);
|
|
451
|
-
assert.match(rendered.text, /^- \[
|
|
483
|
+
assert.match(rendered.text, /^- \[global\] mem_/);
|
|
452
484
|
const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
|
|
453
485
|
assert.equal(removed, null);
|
|
454
486
|
});
|
|
@@ -731,7 +763,8 @@ test('实验来源条目默认不进入检索、正文与关联,只有管理
|
|
|
731
763
|
});
|
|
732
764
|
assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
|
|
733
765
|
assert.equal(readEntry(scope, target.id), null);
|
|
734
|
-
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id),
|
|
766
|
+
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
|
|
767
|
+
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
|
|
735
768
|
assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
|
|
736
769
|
assert.ok(readEntry(scope, normal.record.id));
|
|
737
770
|
});
|
|
@@ -794,5 +827,62 @@ test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不
|
|
|
794
827
|
assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
|
|
795
828
|
});
|
|
796
829
|
|
|
830
|
+
test('当前不适用的高相关条目不挤掉后面真能用的条目', () => {
|
|
831
|
+
const scope = fresh('usable-rank');
|
|
832
|
+
for (let index = 0; index < 40; index += 1) {
|
|
833
|
+
upsert(scope, {
|
|
834
|
+
title: `旧规则${index}`,
|
|
835
|
+
summary: `旧规则${index}`,
|
|
836
|
+
body: `口播检查项${index}的正文`,
|
|
837
|
+
applicability: 'unresolved',
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const usable = upsert(scope, {
|
|
841
|
+
title: '口播检查项可用',
|
|
842
|
+
summary: '口播检查项可用',
|
|
843
|
+
body: '口播检查项的可用正文',
|
|
844
|
+
applicability: 'all_in_scope',
|
|
845
|
+
});
|
|
846
|
+
const usableId = usable.record?.id ?? usable.id;
|
|
847
|
+
const hits = search(scope, '口播检查项', 3);
|
|
848
|
+
assert.equal(hits.some((row) => row.id === usableId), true);
|
|
849
|
+
});
|
|
850
|
+
|
|
797
851
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
852
|
+
|
|
853
|
+
test('R24 省略正文的更新不覆盖已有正文(按 id 与同名都成立)', () => {
|
|
854
|
+
const scope = fresh('omit-body');
|
|
855
|
+
const first = upsert(scope, { title: '连接池改法', summary: '旧摘要', body: '旧正文:先看连接数。' });
|
|
856
|
+
const id = first.record.id;
|
|
857
|
+
upsert(scope, { id, title: '连接池改法', summary: '新摘要' });
|
|
858
|
+
assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', 'id 更新省略正文时保留原正文');
|
|
859
|
+
upsert(scope, { title: '连接池改法', summary: '再新一点' });
|
|
860
|
+
assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', '同名更新省略正文时保留原正文');
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
test('R25 长摘要改动即使只差第 61 个字符也算改过,全文仍保留', () => {
|
|
864
|
+
const scope = fresh('long-summary-change');
|
|
865
|
+
const head = '摘要主体'.repeat(15);
|
|
866
|
+
const first = upsert(scope, { title: '长摘要规则', summary: `${head}甲` });
|
|
867
|
+
const id = first.record.id;
|
|
868
|
+
assert.equal(first.record.summary.length, LIMITS.summaryChars);
|
|
869
|
+
assert.match(bodyOf(scope, id), /甲$/);
|
|
870
|
+
const again = upsert(scope, { id, title: '长摘要规则', summary: `${head}乙` });
|
|
871
|
+
assert.equal(again.noop, false, '第 61 个字符变化不能当作没改');
|
|
872
|
+
const body = bodyOf(scope, id);
|
|
873
|
+
assert.match(body, /甲/);
|
|
874
|
+
assert.match(body, /乙/, '新的完整摘要也落盘');
|
|
875
|
+
assert.equal(again.record.summary.length, LIMITS.summaryChars);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test('R26 显式长正文加短摘要时补上摘要原文,不把两者混成一条', () => {
|
|
879
|
+
const scope = fresh('body-plus-summary');
|
|
880
|
+
const long = '现场记录'.repeat(20);
|
|
881
|
+
const saved = upsert(scope, { title: '带正文的长摘要', summary: long, body: '显式正文:先复现再改。' });
|
|
882
|
+
const body = bodyOf(scope, saved.record.id);
|
|
883
|
+
assert.match(body, /^显式正文:先复现再改。/);
|
|
884
|
+
assert.match(body, /摘要原文:/);
|
|
885
|
+
assert.match(body, /现场记录/);
|
|
886
|
+
});
|
|
887
|
+
|
|
798
888
|
console.log(`store.test.mjs: ${passed} passed`);
|
package/view.js
CHANGED
|
@@ -222,6 +222,21 @@ export function canAutoRecall(record, context) {
|
|
|
222
222
|
return statusOf(record, context) === 'ok';
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* 学习规则是否还在有效期内:paused/pending 和被时间淘汰的 trial 都不能当依据。
|
|
227
|
+
* 只对实验来源生效;普通记忆不看这一层。
|
|
228
|
+
*/
|
|
229
|
+
export function experimentalUsable(record, now = Date.now()) {
|
|
230
|
+
if (!record) return false;
|
|
231
|
+
const learning = record.learning && typeof record.learning === 'object' ? record.learning : null;
|
|
232
|
+
if (!learning && record.origin !== 'project_evolution') return true;
|
|
233
|
+
if (!learning) return false;
|
|
234
|
+
if (learning.state === 'active') return true;
|
|
235
|
+
if (learning.state !== 'trial') return false;
|
|
236
|
+
const until = Date.parse(String(learning.trialUntil || ''));
|
|
237
|
+
return !Number.isFinite(until) || until > Number(now);
|
|
238
|
+
}
|
|
239
|
+
|
|
225
240
|
function gramsOf(text) {
|
|
226
241
|
const plainText = plain(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
227
242
|
const set = new Set();
|
|
@@ -511,7 +526,7 @@ export function newestUserQuery(messages) {
|
|
|
511
526
|
export function projectionText(view) {
|
|
512
527
|
if (!view) return '';
|
|
513
528
|
const rows = [];
|
|
514
|
-
rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}
|
|
529
|
+
rows.push(`${view.title || view.id}${view.when ? `(${view.when})` : ''} · ${view.id}`);
|
|
515
530
|
if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
|
|
516
531
|
if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
|
|
517
532
|
if (view.state === 'needs_context') rows.push('适用范围待确认');
|