dsh-lite-memory 0.15.0 → 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 +4 -4
- package/anchor.js +55 -13
- package/client.js +8 -6
- package/docs/RELEASE-0.16.0.md +10 -0
- package/index.js +138 -37
- package/package.json +1 -1
- package/state.js +7 -5
- package/store.js +60 -34
- package/test/anchor.test.mjs +90 -0
- package/test/control.test.mjs +19 -0
- package/test/evolution-read.test.mjs +133 -3
- package/test/recall.test.mjs +14 -0
- package/test/store.test.mjs +87 -19
package/store.js
CHANGED
|
@@ -27,7 +27,6 @@ const SECRET_PATTERNS = [
|
|
|
27
27
|
];
|
|
28
28
|
|
|
29
29
|
/** A summary is a noun phrase. An imperative turns a note into an instruction. */
|
|
30
|
-
const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
|
|
31
30
|
|
|
32
31
|
const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
|
|
33
32
|
|
|
@@ -276,19 +275,9 @@ function cleanTitle(value) {
|
|
|
276
275
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
277
276
|
}
|
|
278
277
|
|
|
279
|
-
/** 正文里的实现参数(尺寸/层级/字号)不算记忆:数到三次就要求重写成原则。 */
|
|
280
|
-
const UI_SPEC_PATTERNS = [
|
|
281
|
-
/\bz-index\s*:\s*-?\d+/gi,
|
|
282
|
-
/\b(?:padding|margin|gap|font-size|line-height|border-radius|width|height|min-width|max-width|min-height|max-height|box-shadow)\s*:[^;\n]{0,32}\d/gi,
|
|
283
|
-
/\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi,
|
|
284
|
-
/\bmin\(\s*\d/gi,
|
|
285
|
-
];
|
|
286
|
-
|
|
287
278
|
export function validSummary(summary) {
|
|
288
|
-
const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
|
|
279
|
+
const text = String(summary ?? '').replace(/\s+/g, ' ').trim().slice(0, LIMITS.summaryChars);
|
|
289
280
|
if (!text) return { ok: false, reason: '摘要为空' };
|
|
290
|
-
if (text.length > LIMITS.summaryChars) return { ok: false, reason: `摘要超过 ${LIMITS.summaryChars} 字,先写短些` };
|
|
291
|
-
if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
|
|
292
281
|
return { ok: true, text };
|
|
293
282
|
}
|
|
294
283
|
|
|
@@ -308,25 +297,32 @@ export function normalizeDraft(source, options = {}) {
|
|
|
308
297
|
const mayOmitTitle = Boolean(id);
|
|
309
298
|
if (!title && !mayOmitTitle) throw new Error('标题为空');
|
|
310
299
|
let summary = '';
|
|
300
|
+
let summaryShortened = false;
|
|
311
301
|
const rawSummary = String(draft.summary ?? '').replace(/\s+/g, ' ').trim();
|
|
312
302
|
if (rawSummary) {
|
|
313
303
|
const check = validSummary(rawSummary);
|
|
314
304
|
if (!check.ok) throw new Error(check.reason);
|
|
315
305
|
summary = check.text;
|
|
306
|
+
summaryShortened = summary.length < rawSummary.length;
|
|
316
307
|
} else if (has('summary')) {
|
|
317
308
|
throw new Error('摘要为空');
|
|
318
309
|
}
|
|
319
310
|
/** 更新(带 id)与候选草稿允许省略摘要;省略表示保持原值,不拿标题补。 */
|
|
320
311
|
const mayOmitSummary = options.requireSummary === false || Boolean(id);
|
|
321
312
|
if (!summary && !mayOmitSummary) throw new Error('摘要为空');
|
|
322
|
-
|
|
323
|
-
|
|
313
|
+
/**
|
|
314
|
+
* 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
|
|
315
|
+
* 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
|
|
316
|
+
*/
|
|
317
|
+
const bodyExplicit = has('body');
|
|
318
|
+
const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
|
|
319
|
+
const autoBody = !bodyExplicit && summaryShortened ? rawSummary : '';
|
|
320
|
+
const checked = bodyExplicit ? body : autoBody;
|
|
321
|
+
const bodyBytes = Buffer.byteLength(checked);
|
|
324
322
|
if (bodyBytes > LIMITS.entryBytes) {
|
|
325
|
-
throw new Error(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const concrete = UI_SPEC_PATTERNS.reduce((count, pattern) => count + (body.match(pattern) || []).length, 0);
|
|
329
|
-
if (concrete >= 3) throw new Error('正文太具体:记忆只留原则和触发条件,像素、字号、层级这类实现参数留在代码或文档里');
|
|
323
|
+
throw new Error(autoBody
|
|
324
|
+
? `摘要 ${rawSummary.length} 字,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
|
|
325
|
+
: `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
330
326
|
}
|
|
331
327
|
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
332
328
|
if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
|
|
@@ -349,6 +345,10 @@ export function normalizeDraft(source, options = {}) {
|
|
|
349
345
|
summary,
|
|
350
346
|
when,
|
|
351
347
|
body,
|
|
348
|
+
summaryShortened,
|
|
349
|
+
summaryRaw: rawSummary,
|
|
350
|
+
autoBody,
|
|
351
|
+
fullTextInBody: false,
|
|
352
352
|
kind: kind || 'note',
|
|
353
353
|
tags,
|
|
354
354
|
triggers,
|
|
@@ -361,7 +361,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
361
361
|
title: has('title') && Boolean(title),
|
|
362
362
|
summary: has('summary'),
|
|
363
363
|
when: has('when'),
|
|
364
|
-
body:
|
|
364
|
+
body: bodyExplicit,
|
|
365
365
|
kind: has('kind'),
|
|
366
366
|
tags: has('tags'),
|
|
367
367
|
triggers: triggers !== undefined,
|
|
@@ -651,6 +651,14 @@ function rulesOf(draft, body, index, foreign) {
|
|
|
651
651
|
return rules;
|
|
652
652
|
}
|
|
653
653
|
|
|
654
|
+
/** 长摘要的尾部不能丢:正文里没有这段时补在末尾,已有则不动。 */
|
|
655
|
+
function bodyWithSummaryTail(body, draft) {
|
|
656
|
+
const raw = String(draft.summaryRaw || '');
|
|
657
|
+
const current = String(body || '');
|
|
658
|
+
if (!raw || !draft.summaryShortened || current.includes(raw)) return { body: current, added: false };
|
|
659
|
+
return { body: current ? `${current}\n\n摘要原文:${raw}` : raw, added: true };
|
|
660
|
+
}
|
|
661
|
+
|
|
654
662
|
function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
|
|
655
663
|
if (writeMode === 'replace_current') {
|
|
656
664
|
const next = {
|
|
@@ -664,8 +672,9 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
664
672
|
selection: draft.has.selection ? draft.selection : null,
|
|
665
673
|
links: draft.has.links ? draft.links : [],
|
|
666
674
|
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
667
|
-
body: draft.body,
|
|
675
|
+
body: bodyWithSummaryTail(draft.body, draft).body,
|
|
668
676
|
};
|
|
677
|
+
if (next.body !== draft.body) draft.fullTextInBody = true;
|
|
669
678
|
next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
670
679
|
next.conditions, next.selection, next.when);
|
|
671
680
|
assertLinksVisible(next.links, index, foreign);
|
|
@@ -743,6 +752,9 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
743
752
|
if (record) {
|
|
744
753
|
const oldBody = readBody(scope, record.id);
|
|
745
754
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
755
|
+
const tail = bodyWithSummaryTail(next.body, draft);
|
|
756
|
+
next.body = tail.body;
|
|
757
|
+
if (tail.added) draft.fullTextInBody = true;
|
|
746
758
|
const noop = next.title === record.title && next.summary === record.summary
|
|
747
759
|
&& next.when === (record.when || '') && next.kind === (record.kind || 'note')
|
|
748
760
|
&& JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
|
|
@@ -777,7 +789,11 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
777
789
|
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
|
|
778
790
|
}
|
|
779
791
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
780
|
-
const
|
|
792
|
+
const seed = draft.autoBody || draft.body;
|
|
793
|
+
const tail = bodyWithSummaryTail(seed, draft);
|
|
794
|
+
const createdBody = tail.body;
|
|
795
|
+
if (tail.added || draft.autoBody) draft.fullTextInBody = true;
|
|
796
|
+
const rules = rulesOf(draft, createdBody, index, foreign);
|
|
781
797
|
const created = {
|
|
782
798
|
id: newId(),
|
|
783
799
|
title: draft.title,
|
|
@@ -791,7 +807,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
791
807
|
selection: rules.selection,
|
|
792
808
|
links: rules.links,
|
|
793
809
|
topicKey: rules.topicKey,
|
|
794
|
-
body:
|
|
810
|
+
body: createdBody,
|
|
795
811
|
aliases: [],
|
|
796
812
|
created: now,
|
|
797
813
|
updated: now,
|
|
@@ -817,8 +833,9 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
817
833
|
* 结果经 index.__archived 回填,调用方在工具结果里点名。
|
|
818
834
|
*/
|
|
819
835
|
function assertFits(scope, index, record) {
|
|
820
|
-
|
|
821
|
-
|
|
836
|
+
const bodyBytes = Buffer.byteLength(record.body || '');
|
|
837
|
+
if (bodyBytes > LIMITS.entryBytes) {
|
|
838
|
+
throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
|
|
822
839
|
}
|
|
823
840
|
const size = recordSize(record);
|
|
824
841
|
if (size > LIMITS.recordBytes) {
|
|
@@ -1073,8 +1090,11 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1073
1090
|
const group = resolved.get(view.topicSlot(item));
|
|
1074
1091
|
return Boolean(group && group.status === 'ok' && group.record && group.record.id !== item.id);
|
|
1075
1092
|
};
|
|
1076
|
-
const visible = rows.filter((row) => row.archived || !shadowed(row.item))
|
|
1077
|
-
|
|
1093
|
+
const visible = rows.filter((row) => row.archived || !shadowed(row.item))
|
|
1094
|
+
.map((row) => ({ ...row, usable: row.archived || view.statusOf(row.item, options.context || {}) === 'ok' }));
|
|
1095
|
+
/** 先按「本轮能不能用」分组再按相关度:高相关但当前不适用的条目不能挤掉后面真正能用的。 */
|
|
1096
|
+
visible.sort((a, b) => Number(b.usable) - Number(a.usable)
|
|
1097
|
+
|| b.score - a.score
|
|
1078
1098
|
|| Number(a.archived) - Number(b.archived)
|
|
1079
1099
|
|| String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
|
|
1080
1100
|
const chosen = visible.slice(0, limit);
|
|
@@ -1255,13 +1275,16 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1255
1275
|
const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
|
|
1256
1276
|
if (!record) continue;
|
|
1257
1277
|
if (!readableRecord(record, options, index)) continue;
|
|
1278
|
+
/** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
|
|
1279
|
+
if (view.statusOf(record, options.context || {}) !== 'ok') continue;
|
|
1280
|
+
if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
|
|
1258
1281
|
return {
|
|
1259
1282
|
id: record.id,
|
|
1260
1283
|
title: record.title,
|
|
1261
1284
|
summary: record.summary || '',
|
|
1262
1285
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1263
1286
|
revision: record.revision || 1,
|
|
1264
|
-
status:
|
|
1287
|
+
status: 'ok',
|
|
1265
1288
|
};
|
|
1266
1289
|
}
|
|
1267
1290
|
return null;
|
|
@@ -2643,7 +2666,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2643
2666
|
touched += 1;
|
|
2644
2667
|
continue;
|
|
2645
2668
|
}
|
|
2646
|
-
if (currentRules.length +
|
|
2669
|
+
if (currentRules.length + 1 > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
|
|
2647
2670
|
const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
|
|
2648
2671
|
if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
|
|
2649
2672
|
merged.learning = {
|
|
@@ -2906,15 +2929,18 @@ export function editLearningRule(scope, id, options = {}) {
|
|
|
2906
2929
|
const nextBody = body || flat.body;
|
|
2907
2930
|
if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
|
|
2908
2931
|
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2932
|
+
const bodyChanged = nextBody !== flat.body;
|
|
2909
2933
|
const learningState = { ...(flat.learning || {}) };
|
|
2910
2934
|
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2911
|
-
|
|
2912
|
-
|
|
2935
|
+
if (bodyChanged) {
|
|
2936
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2937
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2938
|
+
learningState.supportedEpisodeIds = [];
|
|
2939
|
+
learningState.evidence = [];
|
|
2940
|
+
}
|
|
2913
2941
|
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2914
|
-
learningState.supportedEpisodeIds = [];
|
|
2915
|
-
learningState.evidence = [];
|
|
2916
2942
|
learningState.updatedAt = at;
|
|
2917
|
-
if (learningState.state === 'active') {
|
|
2943
|
+
if (bodyChanged && learningState.state === 'active') {
|
|
2918
2944
|
learningState.state = 'trial';
|
|
2919
2945
|
learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
|
|
2920
2946
|
}
|
package/test/anchor.test.mjs
CHANGED
|
@@ -434,3 +434,93 @@ test('R28 指代加动作的续接仍保留旧任务范围', () => {
|
|
|
434
434
|
assert.equal(next.anchor.episodeId, previous.episodeId);
|
|
435
435
|
assert.equal(next.anchor.seriesId, '口播');
|
|
436
436
|
});
|
|
437
|
+
|
|
438
|
+
test('R29 带「继续改」但对象换成新的英文主题时撤销旧范围', () => {
|
|
439
|
+
const tasks = activeVideoSession();
|
|
440
|
+
const next = anchor.routeTask(tasks, {
|
|
441
|
+
sessionId: 'sess-switch',
|
|
442
|
+
batchKey: 'rb_switch_pg_continue',
|
|
443
|
+
messageIds: ['m2'],
|
|
444
|
+
query: '继续改 PostgreSQL 连接池配置',
|
|
445
|
+
registeredSeries: ['口播'],
|
|
446
|
+
now: 2000,
|
|
447
|
+
});
|
|
448
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
449
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
450
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('R30 带「调整一下」但对象换了时撤销旧范围', () => {
|
|
454
|
+
const tasks = activeVideoSession();
|
|
455
|
+
const next = anchor.routeTask(tasks, {
|
|
456
|
+
sessionId: 'sess-switch',
|
|
457
|
+
batchKey: 'rb_switch_pg_adjust',
|
|
458
|
+
messageIds: ['m2'],
|
|
459
|
+
query: '调整一下 PostgreSQL 连接池配置',
|
|
460
|
+
registeredSeries: ['口播'],
|
|
461
|
+
now: 2000,
|
|
462
|
+
});
|
|
463
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
464
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('R31 带「继续改」但明确指向新对象动作时撤销旧范围', () => {
|
|
468
|
+
const tasks = activeVideoSession();
|
|
469
|
+
const next = anchor.routeTask(tasks, {
|
|
470
|
+
sessionId: 'sess-switch',
|
|
471
|
+
batchKey: 'rb_switch_db_code',
|
|
472
|
+
messageIds: ['m2'],
|
|
473
|
+
query: '继续改数据库连接超时的处理代码',
|
|
474
|
+
registeredSeries: ['口播'],
|
|
475
|
+
now: 2000,
|
|
476
|
+
});
|
|
477
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
478
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
/** 复核复现:句首带「继续」不能成为沿用旧正文的通行证。 */
|
|
482
|
+
for (const [label, query] of [
|
|
483
|
+
['R32 继续改数据库连接池配置', '继续改数据库连接池配置'],
|
|
484
|
+
['R33 继续优化数据库连接池', '继续优化数据库连接池'],
|
|
485
|
+
['R34 继续写数据库故障报告', '继续写数据库故障报告'],
|
|
486
|
+
['R35 这个数据库连接池配置怎么改', '这个数据库连接池配置怎么改'],
|
|
487
|
+
]) {
|
|
488
|
+
test(`${label}:与旧任务没有对象交集时不继承旧范围`, () => {
|
|
489
|
+
const tasks = activeVideoSession();
|
|
490
|
+
const next = anchor.routeTask(tasks, {
|
|
491
|
+
sessionId: 'sess-switch',
|
|
492
|
+
batchKey: `rb_${label.slice(0, 3)}_db`,
|
|
493
|
+
messageIds: ['m2'],
|
|
494
|
+
query,
|
|
495
|
+
registeredSeries: ['口播'],
|
|
496
|
+
now: 2000,
|
|
497
|
+
});
|
|
498
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
499
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
500
|
+
assert.equal(next.anchor.seriesId, undefined);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
test('R41 旧稿提到但明确换到排查任务时不再绑定旧栏目', () => {
|
|
505
|
+
const tasks = activeVideoSession();
|
|
506
|
+
for (const query of ['排查 PostgreSQL 连接池故障,旧稿先放着', '这版数据库连接池配置怎么改']) {
|
|
507
|
+
const next = anchor.routeTask(tasks, {
|
|
508
|
+
sessionId: 'sess-switch',
|
|
509
|
+
batchKey: `rb_${query.length}`,
|
|
510
|
+
messageIds: ['m2'],
|
|
511
|
+
query,
|
|
512
|
+
registeredSeries: ['口播'],
|
|
513
|
+
now: 2000,
|
|
514
|
+
});
|
|
515
|
+
assert.equal(next.decision.kind, 'unbound', query);
|
|
516
|
+
assert.equal(next.anchor.seriesId, undefined, query);
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('R42 栏目只被提到、否定或搁置时都不算用户选中', () => {
|
|
521
|
+
assert.equal(anchor.seriesChosenBy('把口播开头改一下', '口播'), true);
|
|
522
|
+
assert.equal(anchor.seriesChosenBy('先不做口播了', '口播'), false);
|
|
523
|
+
assert.equal(anchor.seriesChosenBy('不要按口播的规矩来', '口播'), false);
|
|
524
|
+
assert.equal(anchor.seriesChosenBy('日志里提到口播吗', '口播'), false);
|
|
525
|
+
assert.equal(anchor.seriesChosenBy('先放着口播,改数据库', '口播'), false);
|
|
526
|
+
});
|
package/test/control.test.mjs
CHANGED
|
@@ -235,6 +235,25 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
|
|
|
235
235
|
assert.equal(afterAdopt.title, '口播结论前置');
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/* C07b:只改标题不动正文时保留生效状态与学习依据。 */
|
|
239
|
+
{
|
|
240
|
+
const dir = mkdir('title-only');
|
|
241
|
+
const scope = scopeOf(dir);
|
|
242
|
+
const id = seedRule(scope, '口播开头');
|
|
243
|
+
state.transact(scope, {}, (draft) => {
|
|
244
|
+
const row = draft.entries.find((entry) => entry.id === id);
|
|
245
|
+
row.maintenance.learning = { state: 'active', evidence: [{ text: '三次口播都先改开头' }] };
|
|
246
|
+
return { changed: true };
|
|
247
|
+
});
|
|
248
|
+
const first = store.learningSnapshot(scope).rules[0];
|
|
249
|
+
const edited = store.editLearningRule(scope, id, { expectedRevision: first.revision, title: '口播开头重命名' });
|
|
250
|
+
assert.equal(edited.ok, true);
|
|
251
|
+
const after = store.learningSnapshot(scope).rules[0];
|
|
252
|
+
assert.equal(after.state, 'active');
|
|
253
|
+
assert.equal(after.learning.evidence.length, 1);
|
|
254
|
+
assert.equal(after.title, '口播开头重命名');
|
|
255
|
+
}
|
|
256
|
+
|
|
238
257
|
/* C08:关闭保留已消耗额度与默认绑定,重开后仍按原额度限额。 */
|
|
239
258
|
{
|
|
240
259
|
const dir = mkdir('preserve-quota');
|
|
@@ -296,7 +296,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
296
296
|
assert.equal((value.refs || []).some((ref) => ref.id === id), false);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
/* L11
|
|
299
|
+
/* L11:真人原话点名栏目就直接生效;工具绑定仍只认这句原话。 */
|
|
300
300
|
{
|
|
301
301
|
const dir = mkdir('bind');
|
|
302
302
|
const scope = scopeOf(dir);
|
|
@@ -305,7 +305,7 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
305
305
|
const runtime = boot();
|
|
306
306
|
const agent = makeAgent('a-bind', dir);
|
|
307
307
|
const sig = signal();
|
|
308
|
-
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('
|
|
308
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播怎么开头')));
|
|
309
309
|
const held = runtime.debug.windowOf(agent).experiment;
|
|
310
310
|
assert.ok(held && held.contextId, 'context leaked');
|
|
311
311
|
const value = await runtime.exec('memory_search', { query: '口播', contextId: held.contextId, bindForRequest: true }, agent);
|
|
@@ -313,10 +313,30 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
313
313
|
assert.ok((value.text || '').includes('口播'));
|
|
314
314
|
const episodes = Object.values(store.taskState(scope).episodes);
|
|
315
315
|
assert.equal(episodes.length, 1);
|
|
316
|
-
assert.equal(episodes[0].status, '
|
|
316
|
+
assert.equal(episodes[0].status, 'active');
|
|
317
317
|
assert.match(runtime.text(agent, sig), /开场三句内给结论/);
|
|
318
318
|
}
|
|
319
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
|
+
|
|
320
340
|
/* L12:跨项目上下文直接拒绝。 */
|
|
321
341
|
{
|
|
322
342
|
const dirA = mkdir('cross-a');
|
|
@@ -445,4 +465,114 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
445
465
|
assert.doesNotMatch(many.text, /点名|先问|栏目/);
|
|
446
466
|
}
|
|
447
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
|
+
|
|
448
578
|
console.log('evolution-read.test.mjs: all passed');
|
package/test/recall.test.mjs
CHANGED
|
@@ -447,3 +447,17 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
447
447
|
}
|
|
448
448
|
|
|
449
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/store.test.mjs
CHANGED
|
@@ -40,24 +40,31 @@ 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 rejected
|
|
43
|
+
test('over-specific body is stored, not rejected for naming concrete values', () => {
|
|
44
44
|
const scope = fresh('specific');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
);
|
|
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'));
|
|
53
52
|
});
|
|
54
53
|
|
|
55
|
-
test('
|
|
54
|
+
test('a 500-character body is accepted', () => {
|
|
56
55
|
const scope = fresh('toolong');
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 字节/);
|
|
61
68
|
});
|
|
62
69
|
|
|
63
70
|
test('create then same title merges with full old body kept', () => {
|
|
@@ -146,16 +153,20 @@ test('oversized body is rejected, old data untouched', () => {
|
|
|
146
153
|
assert.equal(loadIndex(scope).entries[0].revision, 1);
|
|
147
154
|
});
|
|
148
155
|
|
|
149
|
-
test('over-long title
|
|
156
|
+
test('over-long title is an error; over-long summary is truncated but the full text is saved', () => {
|
|
150
157
|
const scope = fresh('length');
|
|
151
158
|
assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
154
165
|
});
|
|
155
166
|
|
|
156
|
-
test('imperative summary
|
|
167
|
+
test('imperative summary is kept; credentials are still rejected', () => {
|
|
157
168
|
const scope = fresh('validate');
|
|
158
|
-
assert.
|
|
169
|
+
assert.equal(upsert(scope, { title: '回复语言', summary: '请用中文回复' }).record.summary, '请用中文回复');
|
|
159
170
|
assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
|
|
160
171
|
assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
|
|
161
172
|
assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
|
|
@@ -816,5 +827,62 @@ test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不
|
|
|
816
827
|
assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
|
|
817
828
|
});
|
|
818
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
|
+
|
|
819
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
|
+
|
|
820
888
|
console.log(`store.test.mjs: ${passed} passed`);
|