dsh-lite-memory 0.18.1 → 0.18.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -13
- package/TRIGGERS.md +1 -1
- package/client.js +184 -34
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +347 -123
- package/learning.js +65 -6
- package/package.json +3 -3
- package/state.js +47 -6
- package/store.js +180 -18
- package/test/client.test.mjs +47 -25
- package/test/contract.test.mjs +221 -86
- package/test/control.test.mjs +2 -2
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +10 -10
- package/test/ha.test.mjs +55 -5
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +67 -9
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +85 -12
package/test/learning.test.mjs
CHANGED
|
@@ -962,6 +962,86 @@ test('L03b 明确长期要求整理为空时也留下规则', () => {
|
|
|
962
962
|
assert.equal(learning.pendingChangeFrom({ text: ' ' }), null);
|
|
963
963
|
});
|
|
964
964
|
|
|
965
|
+
test('L03c 兜底不把没说范围的要求抬成全项目规则', () => {
|
|
966
|
+
const vague = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true } });
|
|
967
|
+
assert.equal(vague.applicability, 'unresolved');
|
|
968
|
+
const allLines = learning.pendingChangeFrom({ text: '以后所有口播都先给结论。', cue: { explicitLongTerm: true } });
|
|
969
|
+
assert.equal(allLines.applicability, 'all_in_scope', '「所有口播」按项目层全范围收集');
|
|
970
|
+
const always = learning.pendingChangeFrom({ text: '以后一律先给结论。', cue: { explicitLongTerm: true } });
|
|
971
|
+
assert.equal(always.applicability, 'all_in_scope', '「一律」按项目层全范围收集');
|
|
972
|
+
assert.deepEqual(vague.conditions, []);
|
|
973
|
+
const scoped = learning.pendingChangeFrom({ text: '以后口播开头先给结论。', cue: { explicitLongTerm: true, taskKind: 'video' } });
|
|
974
|
+
assert.equal(scoped.applicability, 'conditional');
|
|
975
|
+
assert.deepEqual(scoped.conditions, [{ key: 'taskKind', value: 'video' }]);
|
|
976
|
+
const global = learning.pendingChangeFrom({ text: '以后这个项目默认用 pnpm。', cue: { explicitLongTerm: true } });
|
|
977
|
+
assert.equal(global.applicability, 'all_in_scope');
|
|
978
|
+
assert.deepEqual(global.conditions, []);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
|
|
982
|
+
const human = (id, text) => ({ id, source: { kind: 'user' }, content: [{ type: 'text', text }] });
|
|
983
|
+
const anchor = { episodeId: 'ep_x', status: 'unbound', updatedAt: '2026-01-01T00:00:00.000Z' };
|
|
984
|
+
const narrow = learning.candidateFrom({ messages: [human('m1', '以后开头先给结论')], anchor, projectId: 'p' });
|
|
985
|
+
assert.equal(narrow.expectedTarget.applicability, 'unresolved');
|
|
986
|
+
const wide = learning.candidateFrom({ messages: [human('m2', '以后这个项目默认用 pnpm')], anchor, projectId: 'p' });
|
|
987
|
+
assert.equal(wide.expectedTarget.applicability, 'all_in_scope');
|
|
988
|
+
|
|
989
|
+
const refs = { feedback: { u0: 'm1' }, rules: {} };
|
|
990
|
+
const base = { target: 'new', feedbackType: 'spec_change', topicKey: '开头', currentText: '开头先给结论', evidence: ['u0'] };
|
|
991
|
+
const needsScope = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget });
|
|
992
|
+
assert.equal(needsScope.ok, false);
|
|
993
|
+
assert.match(needsScope.reason, /不能.*全项目/);
|
|
994
|
+
assert.equal(learning.validateChange({ ...base, applicability: 'unresolved' }, { refs, rules: [], ranges: RANGES, expectedTarget: narrow.expectedTarget }).ok, true);
|
|
995
|
+
assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
|
|
996
|
+
|
|
997
|
+
const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
|
|
998
|
+
const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
999
|
+
assert.equal(episodeWide.ok, false, '作品规则不能抬成全项目');
|
|
1000
|
+
const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1001
|
+
assert.equal(droppedKind.ok, false, '不能删掉收集时的任务范围');
|
|
1002
|
+
const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1003
|
+
assert.equal(keptKind.ok, true, keptKind.reason);
|
|
1004
|
+
|
|
1005
|
+
const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
|
|
1006
|
+
assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
|
|
1007
|
+
assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
|
|
1011
|
+
const rules = [];
|
|
1012
|
+
for (let index = 0; index < 32; index += 1) {
|
|
1013
|
+
rules.push({
|
|
1014
|
+
id: `mem_${String(index).padStart(2, '0')}`,
|
|
1015
|
+
state: 'active',
|
|
1016
|
+
updated: `2026-01-${String((index % 28) + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
1017
|
+
title: index === 31 ? '字幕字号' : `无关规则${index}`,
|
|
1018
|
+
summary: '',
|
|
1019
|
+
body: index === 31 ? '字幕固定 32 号' : '',
|
|
1020
|
+
conditions: [],
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const selected = learning.selectRules(rules, { query: '字幕太小了,改大一点', dimensions: ['字幕'], limit: 6 });
|
|
1024
|
+
assert.equal(selected.length, 6);
|
|
1025
|
+
assert.ok(selected.some((row) => row.id === 'mem_31'), '相关但排在最后的规则要进候选');
|
|
1026
|
+
assert.equal(learning.selectRules(rules, { limit: 32 }).length, 32, '候选池上限是 32 条,不是先砍到 6 条');
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test('L03f 整理器规则先按状态和最近使用排序再取 8 条', () => {
|
|
1030
|
+
const rules = [];
|
|
1031
|
+
for (let index = 0; index < 12; index += 1) {
|
|
1032
|
+
rules.push({
|
|
1033
|
+
id: `mem_${String(index).padStart(2, '0')}`,
|
|
1034
|
+
state: index === 11 ? 'active' : index === 10 ? 'trial' : 'paused',
|
|
1035
|
+
updatedAt: `2026-01-${String(index + 1).padStart(2, '0')}T00:00:00.000Z`,
|
|
1036
|
+
title: `规则${index}`,
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const ordered = learning.orderOrganizerRules(rules);
|
|
1040
|
+
assert.equal(ordered.length, learning.LIMITS.organizerRules);
|
|
1041
|
+
assert.equal(ordered[0].id, 'mem_11', '生效规则优先于暂停规则');
|
|
1042
|
+
assert.equal(ordered[1].id, 'mem_10', '试用规则排在暂停之前');
|
|
1043
|
+
});
|
|
1044
|
+
|
|
965
1045
|
test('L04 先算目标状态再查配额,过期候选可恢复', () => {
|
|
966
1046
|
const base = { existing: null, evidence: { episodeId: 'e1', messageIds: ['m1'], sourceExcerpt: '以后都用参数化查询' }, pending: 12, rules: 0, now: 1000 };
|
|
967
1047
|
const active = learning.admitChange({ ...base, change: NEW_CHANGE, explicitLongTerm: true });
|
package/test/palace.test.mjs
CHANGED
|
@@ -90,8 +90,10 @@ const walk = (node, out = [], expand = false) => {
|
|
|
90
90
|
};
|
|
91
91
|
const exhibit = walk(tree, [], true).find((node) => node.type === 'button' && node.props.title === 'A');
|
|
92
92
|
assert.ok(exhibit, '展品应是原生按钮');
|
|
93
|
+
const beforeClick = JSON.stringify(tree);
|
|
93
94
|
exhibit.props.onClick();
|
|
94
95
|
assert.equal(picked, 'mem_a');
|
|
96
|
+
assert.equal(JSON.stringify(tree), beforeClick, '点开展品只换详情,不改宫殿里的记录');
|
|
95
97
|
|
|
96
98
|
// 首帧列表视图不挂载宫殿;空数据时给下一步提示。
|
|
97
99
|
const firstPaint = MemoryPanel({ sessionId: 'demo', onClose: () => {} });
|
package/test/recall.test.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
@@ -8,8 +9,9 @@ const { apply } = await import('../index.js');
|
|
|
8
9
|
const store = await import('../store.js');
|
|
9
10
|
|
|
10
11
|
const HEADER = '已有记忆可能相关';
|
|
11
|
-
const CARD_LINE = /^- \[[^\]]+\]
|
|
12
|
+
const CARD_LINE = /^- \[[^\]]+\] 「.+?」(?:|.*)?$/;
|
|
12
13
|
const CARD_LINE_M = new RegExp(CARD_LINE.source, 'm');
|
|
14
|
+
const INTERNAL_ID = /mem_[a-z0-9]{6,}/;
|
|
13
15
|
|
|
14
16
|
const effect = (fn) => {
|
|
15
17
|
const dispose = fn();
|
|
@@ -84,6 +86,17 @@ const save = (scope, fields) => {
|
|
|
84
86
|
return { ...result, id: result.record?.id ?? result.id };
|
|
85
87
|
};
|
|
86
88
|
|
|
89
|
+
/** 有效期这类时间不能靠等待制造:直接改热状态,并同步重算校验和。 */
|
|
90
|
+
const patchState = (scope, id, patch) => {
|
|
91
|
+
const file = path.join(scope.dir, 'state.json');
|
|
92
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
93
|
+
const entry = raw.payload.entries.find((row) => row.id === id);
|
|
94
|
+
assert.ok(entry, `state 里没有 ${id}`);
|
|
95
|
+
Object.assign(entry, patch);
|
|
96
|
+
raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
|
|
97
|
+
fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
98
|
+
};
|
|
99
|
+
|
|
87
100
|
const cwdA = mkdir('a');
|
|
88
101
|
const scopeA = store.scopeOf(cwdA);
|
|
89
102
|
const global = store.globalScope();
|
|
@@ -277,8 +290,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
277
290
|
const fourth = await bootA.exec('memory_read', { id: bigBodies[3].id }, agent);
|
|
278
291
|
assert.equal(fourth.status, 'budget_exhausted');
|
|
279
292
|
assert.equal(fourth.body || '', '');
|
|
280
|
-
assert.equal(
|
|
281
|
-
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 本请求的记忆额度已用完');
|
|
293
|
+
assert.equal(bootA.render('memory_read', {}, fourth), 'memory · 这条消息已停止读记忆,下一条消息再读');
|
|
282
294
|
const win = bootA.debug.windowOf(agent);
|
|
283
295
|
assert.ok(win.bytes <= 8192);
|
|
284
296
|
assert.equal(win.bodies, 3);
|
|
@@ -332,10 +344,11 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
332
344
|
const agent = makeAgent('agent-purge', cwdD);
|
|
333
345
|
const sig = signal();
|
|
334
346
|
await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('插件改完怎么让它生效?')));
|
|
335
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
336
|
-
const purged = await bootA.exec('memory_forget', {
|
|
347
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/);
|
|
348
|
+
const purged = await bootA.exec('memory_forget', { title: '插件改完要重启应用', mode: 'purge' }, agent);
|
|
337
349
|
assert.equal(purged.mode, 'purged');
|
|
338
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
350
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /插件改完要重启应用/, '已提交的旧快照不回头改写');
|
|
351
|
+
assert.doesNotMatch(bootA.provider.text({ agent, signal: sig }), /mem_[a-z0-9]{6,}/, '召回卡从不带内部编号');
|
|
339
352
|
const sig2 = signal();
|
|
340
353
|
await bootA.preStep({ agent, signal: sig2, turn: 't2' }, enter(human('插件改完怎么让它生效?')));
|
|
341
354
|
const after = bootA.provider.text({ agent, signal: sig2 });
|
|
@@ -359,7 +372,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
359
372
|
const agent = makeAgent('agent-global-purge', cwdA);
|
|
360
373
|
const sig = signal();
|
|
361
374
|
await bootA.preStep({ agent, signal: sig, turn: 't1' }, enter(human('发布前跑文案检查')));
|
|
362
|
-
assert.match(bootA.provider.text({ agent, signal: sig }),
|
|
375
|
+
assert.match(bootA.provider.text({ agent, signal: sig }), /全局发布前文案核对/);
|
|
363
376
|
const purged = store.forget(global, globalNote.id, { mode: 'purge' });
|
|
364
377
|
assert.equal(purged.mode, 'purged');
|
|
365
378
|
/** 同一 turn 的续步没有新真人消息:不重新问人,但盘上变了必须换掉快照。 */
|
|
@@ -387,12 +400,25 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
387
400
|
if (row.status === 'budget_exhausted') exhausted = row;
|
|
388
401
|
}
|
|
389
402
|
assert.ok(exhausted, '超过额度后拒绝读正文');
|
|
390
|
-
assert.equal(exhausted.id,
|
|
403
|
+
assert.equal(exhausted.id, undefined);
|
|
391
404
|
assert.ok(win.bytes <= 8192);
|
|
392
405
|
assert.ok(win.bodies <= 3);
|
|
393
406
|
assert.ok(win.reads <= 8);
|
|
394
407
|
}
|
|
395
408
|
|
|
409
|
+
// A21:自动注入把这一轮用得差不多,模型按标题来读正文不该被连带挡掉。
|
|
410
|
+
{
|
|
411
|
+
const agent = makeAgent('agent-read-after-auto', cwdA);
|
|
412
|
+
await prepare(bootA, agent, '大正文样本怎么用');
|
|
413
|
+
const win = bootA.debug.windowOf(agent);
|
|
414
|
+
assert.ok(win, '自动召回开了额度窗口');
|
|
415
|
+
/** 模拟自动注入已经把这轮的 8192 B 用满。 */
|
|
416
|
+
win.bytes = 8192;
|
|
417
|
+
const row = await bootA.exec('memory_read', { title: '大正文样本1' }, agent);
|
|
418
|
+
assert.equal(row.status, 'ok', '自动注入用满之后仍能读正文');
|
|
419
|
+
assert.ok(Buffer.byteLength(row.body) > 900);
|
|
420
|
+
}
|
|
421
|
+
|
|
396
422
|
// A18:归档/旧正文不进正常检索;只有明确查历史才列归档。
|
|
397
423
|
{
|
|
398
424
|
const cwdE = mkdir('e');
|
|
@@ -410,7 +436,7 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
410
436
|
const oldBody = save(scopeE, { title: '口端口令', tags: ['端口'], summary: '端口以最新为准', body: '第一版:端口是 5000' });
|
|
411
437
|
store.upsert(scopeE, { id: oldBody.id, title: '口端口令', summary: '端口以最新为准', body: '第二版:端口是 49661' });
|
|
412
438
|
const current = await bootA.exec('memory_search', { query: '口端口令', history: true }, agent);
|
|
413
|
-
assert.ok(current.refs.some((ref) => ref.
|
|
439
|
+
assert.ok(current.refs.some((ref) => ref.title === '口端口令' && ref.revision === 2), '检索只给当前版本');
|
|
414
440
|
const latest = await bootA.exec('memory_read', { id: oldBody.id }, agent);
|
|
415
441
|
assert.match(latest.body, /第二版/);
|
|
416
442
|
assert.doesNotMatch(latest.body, /第一版/);
|
|
@@ -436,6 +462,38 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
436
462
|
assert.ok(win.bytes <= 8192);
|
|
437
463
|
}
|
|
438
464
|
|
|
465
|
+
// 生命周期退出默认召回:被取代、过期的条目不再自动注入,也不再进手动检索。
|
|
466
|
+
{
|
|
467
|
+
const runtime = boot();
|
|
468
|
+
const cwdLife = mkdir('lifecycle');
|
|
469
|
+
const scopeLife = store.scopeOf(cwdLife);
|
|
470
|
+
const oldDeploy = save(scopeLife, {
|
|
471
|
+
title: '旧部署目标', summary: '部署到 staging,旧流程。', body: '旧流程只发 staging。', resolution: 'distinct',
|
|
472
|
+
});
|
|
473
|
+
const newDeploy = save(scopeLife, {
|
|
474
|
+
title: '新部署目标', summary: '部署到 production,先跑测试。', body: '新流程先跑测试再发 production。', resolution: 'distinct',
|
|
475
|
+
supersedes: oldDeploy.id,
|
|
476
|
+
});
|
|
477
|
+
const stalePort = save(scopeLife, {
|
|
478
|
+
title: '临时调试端口', summary: '本地服务跑在 18080。', body: '旧端口已经不用了。', resolution: 'distinct',
|
|
479
|
+
});
|
|
480
|
+
patchState(scopeLife, stalePort.id, {
|
|
481
|
+
validFrom: new Date(Date.now() - 7200_000).toISOString(),
|
|
482
|
+
validUntil: new Date(Date.now() - 3600_000).toISOString(),
|
|
483
|
+
});
|
|
484
|
+
const agentLife = makeAgent('agent-lifecycle', cwdLife);
|
|
485
|
+
const lifeSignal = await prepare(runtime, agentLife, '部署目标怎么走,临时调试端口是多少');
|
|
486
|
+
const text = runtime.provider.text({ agent: agentLife, signal: lifeSignal });
|
|
487
|
+
assert.match(text, /新部署目标/);
|
|
488
|
+
assert.doesNotMatch(text, /旧部署目标|旧流程只发 staging/, '被取代的条目退出自动召回');
|
|
489
|
+
assert.doesNotMatch(text, /临时调试端口|旧端口已经不用了/, '过期的条目退出自动召回');
|
|
490
|
+
const found = await runtime.exec('memory_search', { query: '部署目标' }, agentLife);
|
|
491
|
+
assert.equal(found.refs.some((ref) => ref.title === '旧部署目标'), false);
|
|
492
|
+
assert.equal(found.refs.some((ref) => ref.title === '新部署目标'), true);
|
|
493
|
+
const port = await runtime.exec('memory_search', { query: '临时调试端口' }, agentLife);
|
|
494
|
+
assert.equal(port.refs.some((ref) => ref.title === '临时调试端口'), false, '过期条目也不进默认检索');
|
|
495
|
+
}
|
|
496
|
+
|
|
439
497
|
// A20:没有命中是正常空结果,不是额度用尽。
|
|
440
498
|
{
|
|
441
499
|
const agent = makeAgent('agent-empty-search', cwdA);
|
package/test/state.test.mjs
CHANGED
|
@@ -84,6 +84,34 @@ test('2 版状态可读,下一次写入升级为 4 版并保留记录', () =>
|
|
|
84
84
|
assert.equal(parsed.payload.entries[0].current.body, '升级后的正文');
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test('D04b 旧实验规则缺作用域指纹时转待复核,正文与来源保留', () => {
|
|
88
|
+
const scope = fresh('legacy-rules');
|
|
89
|
+
transact(scope, { expectCreate: true }, (draft) => {
|
|
90
|
+
addNote('旧规则', '旧正文')(draft);
|
|
91
|
+
const entry = draft.entries[0];
|
|
92
|
+
entry.origin = 'project_evolution';
|
|
93
|
+
entry.maintenance.sourceRefs = [{ sourceType: 'human_feedback', sourceExcerpt: '以后都这样' }];
|
|
94
|
+
entry.maintenance.learning = { state: 'trial', trialUntil: '2026-01-01T00:00:00.000Z' };
|
|
95
|
+
addNote('新规则', '新正文')(draft);
|
|
96
|
+
const freshRule = draft.entries[1];
|
|
97
|
+
freshRule.origin = 'project_evolution';
|
|
98
|
+
freshRule.maintenance.learning = { state: 'active', specDigest: 'digest' };
|
|
99
|
+
return { changed: true };
|
|
100
|
+
});
|
|
101
|
+
const first = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
|
|
102
|
+
assert.equal(first.changed, true);
|
|
103
|
+
const entries = first.state.entries;
|
|
104
|
+
assert.equal(entries[0].maintenance.reviewState, 'legacy_unreviewed');
|
|
105
|
+
assert.equal(entries[0].maintenance.legacyReview.reason, 'scope_unverified');
|
|
106
|
+
assert.equal(entries[0].current.body, '旧正文');
|
|
107
|
+
assert.equal(entries[0].maintenance.sourceRefs[0].sourceExcerpt, '以后都这样');
|
|
108
|
+
assert.equal(entries[0].maintenance.learning.state, 'paused');
|
|
109
|
+
assert.equal(entries[0].maintenance.learning.blockedReason, 'needs_scope_review');
|
|
110
|
+
assert.equal(entries[1].maintenance.reviewState, 'active');
|
|
111
|
+
const second = transact(scope, {}, (draft) => state.applyEvolution(draft, true));
|
|
112
|
+
assert.equal(second.changed, false, '已复核的不再重复改写');
|
|
113
|
+
});
|
|
114
|
+
|
|
87
115
|
test('D04 旧程序拒绝写新格式,不抹掉实验标记', () => {
|
|
88
116
|
const scope = fresh('d04-format');
|
|
89
117
|
transact(scope, { expectCreate: true }, (draft) => {
|
|
@@ -190,7 +218,9 @@ test('S01 仓库根与子目录共用一份记忆', () => {
|
|
|
190
218
|
const atSub = scopeOf(sub);
|
|
191
219
|
assert.equal(atSub.key, atRoot.key);
|
|
192
220
|
assert.equal(atSub.dir, atRoot.dir);
|
|
193
|
-
|
|
221
|
+
/* Windows reports the temp root as an 8.3 short name on one side and the long
|
|
222
|
+
name on the other; compare what both of them resolve to. */
|
|
223
|
+
assert.equal(fs.realpathSync(resolveRoot(sub).root), fs.realpathSync(repo));
|
|
194
224
|
});
|
|
195
225
|
|
|
196
226
|
test('S02 符号链接、非 Git 目录、嵌套仓库各归各的', () => {
|
|
@@ -600,5 +630,31 @@ test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢
|
|
|
600
630
|
assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
|
|
601
631
|
});
|
|
602
632
|
|
|
633
|
+
test('D05 学习开关关掉再开,不重置已用次数和整理记录', () => {
|
|
634
|
+
const scope = fresh('d05-learning-budget');
|
|
635
|
+
const at = new Date().toISOString();
|
|
636
|
+
transact(scope, {}, (draft) => {
|
|
637
|
+
const evo = draft.experimental.projectEvolution;
|
|
638
|
+
evo.learning = {
|
|
639
|
+
attempts: { ep_seed: { count: 2, lastAt: at } },
|
|
640
|
+
recent: [at],
|
|
641
|
+
receipts: [{ operationId: 'op_seed', at, inputTokens: 10, outputTokens: 5 }],
|
|
642
|
+
};
|
|
643
|
+
return state.applyEvolution(draft, true);
|
|
644
|
+
});
|
|
645
|
+
const opened = load(scope).state.experimental.projectEvolution;
|
|
646
|
+
assert.equal(opened.enabled, true);
|
|
647
|
+
assert.deepEqual(opened.learning.recent, [], '打开时清掉在途观察');
|
|
648
|
+
assert.equal(opened.learning.receipts.length, 1, '打开不能清整理回执');
|
|
649
|
+
assert.equal(opened.learning.attempts.ep_seed.count, 2, '打开不能清已用次数');
|
|
650
|
+
transact(scope, {}, (draft) => state.applyEvolution(draft, false));
|
|
651
|
+
transact(scope, {}, (draft) => state.applyEvolution(draft, true));
|
|
652
|
+
const again = load(scope).state.experimental.projectEvolution;
|
|
653
|
+
assert.equal(again.enabled, true);
|
|
654
|
+
assert.equal(again.learning.receipts.length, 1, '关掉再开不重置整理回执');
|
|
655
|
+
assert.equal(again.learning.attempts.ep_seed.count, 2, '关掉再开不重置已用次数');
|
|
656
|
+
assert.deepEqual(again.learning.recent, []);
|
|
657
|
+
});
|
|
658
|
+
|
|
603
659
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
604
660
|
console.log(`state.test.mjs: ${passed} passed`);
|
package/test/store.test.mjs
CHANGED
|
@@ -10,7 +10,7 @@ const state = await import('../state.js');
|
|
|
10
10
|
const { upsert, search, readEntry, forget, undoChange, acceptCandidate, rejectCandidate,
|
|
11
11
|
suggestionGate, noteSuggestion, findSuggestion, renderIndex, normalizeDraft, loadIndex,
|
|
12
12
|
scopeOf, globalScope, LIMITS, recordCitations, listCitations, readCitationBody,
|
|
13
|
-
evolutionStatus, setEvolution, saveIndex } = store;
|
|
13
|
+
evolutionStatus, setEvolution, saveIndex, readCitationBodyByTitle } = store;
|
|
14
14
|
|
|
15
15
|
let passed = 0;
|
|
16
16
|
const test = (name, fn) => {
|
|
@@ -219,8 +219,8 @@ test('search finds Chinese queries, when clauses and reports counts', () => {
|
|
|
219
219
|
const when = search(scope, 'CI 容器');
|
|
220
220
|
assert.ok(when.some((item) => item.title === '文案检查'));
|
|
221
221
|
const rendered = renderIndex(hits);
|
|
222
|
-
assert.match(rendered.text, /^-
|
|
223
|
-
assert.
|
|
222
|
+
assert.match(rendered.text, /^- 「发布顺序」/);
|
|
223
|
+
assert.doesNotMatch(rendered.text, /mem_[a-z0-9]+/);
|
|
224
224
|
assert.doesNotMatch(rendered.text, /第 .* 版/);
|
|
225
225
|
assert.ok(rendered.chars <= 400);
|
|
226
226
|
assert.deepEqual(search(scope, '完全无关的主题词'), []);
|
|
@@ -236,7 +236,8 @@ test('archived or 90-day-stale entries render without a summary', () => {
|
|
|
236
236
|
assert.equal(hits.length, 1);
|
|
237
237
|
const rendered = renderIndex(hits);
|
|
238
238
|
assert.ok(!rendered.text.includes('|'), rendered.text);
|
|
239
|
-
assert.equal(rendered.text.split('\n')[0].includes(item.record.id),
|
|
239
|
+
assert.equal(rendered.text.split('\n')[0].includes(item.record.id), false, '索引行只给标题');
|
|
240
|
+
assert.match(rendered.text.split('\n')[0], /「旧条目」/);
|
|
240
241
|
});
|
|
241
242
|
|
|
242
243
|
test('search and read leave hits and lastUsed alone', () => {
|
|
@@ -485,7 +486,8 @@ test('projects and the global layer stay separate', () => {
|
|
|
485
486
|
assert.equal(global.length, 1);
|
|
486
487
|
assert.equal(global[0].scope, 'global');
|
|
487
488
|
const rendered = renderIndex(global);
|
|
488
|
-
assert.match(rendered.text, /^- \[global\]
|
|
489
|
+
assert.match(rendered.text, /^- \[global\] 「全局条目」/);
|
|
490
|
+
assert.doesNotMatch(rendered.text, /mem_[a-z0-9]{6,}/);
|
|
489
491
|
const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
|
|
490
492
|
assert.equal(removed, null);
|
|
491
493
|
});
|
|
@@ -543,6 +545,16 @@ test('citation rows follow the current title and layer', () => {
|
|
|
543
545
|
assert.equal(listCitations('session-fresh').items[0].scope, 'global');
|
|
544
546
|
});
|
|
545
547
|
|
|
548
|
+
test('citation body by title picks the requested layer', () => {
|
|
549
|
+
const scope = fresh('cite-layer');
|
|
550
|
+
const local = upsert(scope, { title: '同名规则', summary: '项目层版本', body: '项目正文' }).record;
|
|
551
|
+
const wide = upsert(globalScope(), { title: '同名规则', summary: '全局层版本', body: '全局正文' }).record;
|
|
552
|
+
recordCitations(scope, 'session-layer', [{ ...local, scope: 'project' }, { ...wide, scope: 'global' }]);
|
|
553
|
+
assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'project' }).body, /项目正文/);
|
|
554
|
+
assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'global' }).body, /全局正文/);
|
|
555
|
+
assert.equal(readCitationBodyByTitle('session-layer', '没有这条', { scope: 'global' }), null);
|
|
556
|
+
});
|
|
557
|
+
|
|
546
558
|
test('old citation session files are pruned', () => {
|
|
547
559
|
const scope = fresh('cite-prune');
|
|
548
560
|
recordCitations(scope, 'session-old', [{ id: 'mem_000000000001' }]);
|
|
@@ -815,6 +827,38 @@ test('项目实验默认关闭;打开才放行实验读取,关闭暂停试
|
|
|
815
827
|
assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
|
|
816
828
|
});
|
|
817
829
|
|
|
830
|
+
test('旧实验规则缺作用域指纹时,打开开关只转待复核并保留正文来源', () => {
|
|
831
|
+
const scope = fresh('evolution-legacy-quarantine');
|
|
832
|
+
upsert(scope, { title: '旧实验规则', summary: '旧规则的作用域没冻结过', body: '以前留下的正文' });
|
|
833
|
+
const target = stateOf(scope).entries.find((entry) => entry.current.title === '旧实验规则');
|
|
834
|
+
assert.ok(target);
|
|
835
|
+
state.transact(scope, {}, (draft) => {
|
|
836
|
+
const row = draft.entries.find((entry) => entry.id === target.id);
|
|
837
|
+
row.origin = 'project_evolution';
|
|
838
|
+
row.maintenance.learning = { state: 'trial', trialUntil: '2099-01-01T00:00:00.000Z' };
|
|
839
|
+
row.maintenance.sourceRefs = [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }];
|
|
840
|
+
return { changed: true };
|
|
841
|
+
});
|
|
842
|
+
const on = setEvolution(scope, true, { operationId: 'op-legacy-on' });
|
|
843
|
+
assert.equal(on.ok, true);
|
|
844
|
+
assert.equal(on.changed, true);
|
|
845
|
+
const row = stateOf(scope).entries.find((entry) => entry.id === target.id);
|
|
846
|
+
assert.equal(row.current.body, '以前留下的正文');
|
|
847
|
+
assert.deepEqual(row.maintenance.sourceRefs, [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }]);
|
|
848
|
+
assert.equal(row.maintenance.reviewState, 'legacy_unreviewed');
|
|
849
|
+
assert.equal(row.maintenance.learning.state, 'paused');
|
|
850
|
+
assert.equal(row.maintenance.learning.blockedReason, 'needs_scope_review');
|
|
851
|
+
assert.equal(search(scope, '旧实验规则', 4).some((entry) => entry.id === target.id), false);
|
|
852
|
+
assert.equal(search(scope, '旧实验规则', 4, { allowExperimental: true }).some((entry) => entry.id === target.id), false);
|
|
853
|
+
const snap = store.learningSnapshot(scope);
|
|
854
|
+
const shown = snap.rules.find((rule) => rule.title === '旧实验规则');
|
|
855
|
+
assert.ok(shown, '待复核规则仍要出现在面板里');
|
|
856
|
+
assert.equal(shown.reviewState, 'legacy_unreviewed');
|
|
857
|
+
assert.equal(shown.state, 'paused');
|
|
858
|
+
const again = setEvolution(scope, true);
|
|
859
|
+
assert.equal(again.changed, false, '重复打开不再产生写入');
|
|
860
|
+
});
|
|
861
|
+
|
|
818
862
|
test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不变', () => {
|
|
819
863
|
const scope = fresh('evolution-policy');
|
|
820
864
|
upsert(scope, { title: '普通规则', summary: '普通规则', body: '正文' });
|
package/test/view.test.mjs
CHANGED
|
@@ -121,6 +121,9 @@ test('触发词进检索与自动召回', () => {
|
|
|
121
121
|
};
|
|
122
122
|
assert.ok(view.matchScore(record, '帮我把视频贴给我看看').score >= view.MATCH_FLOOR);
|
|
123
123
|
assert.ok(view.matchScore(record, '完全无关的主题词').score < view.MATCH_FLOOR);
|
|
124
|
+
/** 单字重合不算相关:「这」出现在「这个」里,不能让整句沾边就被召回。 */
|
|
125
|
+
const unrelated = { id: 'mem_cccccccccccc', title: '以后都用这个一次性脚本', summary: '这个脚本只跑一次', when: '', aliases: [], tags: [], triggers: [], status: 'active' };
|
|
126
|
+
assert.equal(view.matchScore(unrelated, '把这段视频的字幕导出来').score, 0);
|
|
124
127
|
const ranked = view.rank([record], '帮我把视频贴给我看看', { limit: 4, context: ctxOf(null, {}) });
|
|
125
128
|
assert.equal(ranked.length, 1);
|
|
126
129
|
assert.equal(ranked[0].record.id, record.id);
|
package/view.js
CHANGED
|
@@ -234,6 +234,10 @@ export function contextWithTask(context = {}, task = {}) {
|
|
|
234
234
|
export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
|
|
235
235
|
if (!record) return { ok: false, status: 'gone', reason: 'gone' };
|
|
236
236
|
if (record.status === 'archived') return { ok: false, status: 'gone', reason: 'archived' };
|
|
237
|
+
/** 生命周期与手动检索共用一条:被取代或过期的条目不再像现行规则一样进入召回和正文读取。 */
|
|
238
|
+
if (record.supersededBy) return { ok: false, status: 'gone', reason: 'superseded' };
|
|
239
|
+
const validUntil = Date.parse(record.validUntil || '');
|
|
240
|
+
if (Number.isFinite(validUntil) && validUntil <= now) return { ok: false, status: 'gone', reason: 'expired' };
|
|
237
241
|
const pid = runtime && runtime.projectId;
|
|
238
242
|
if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
|
|
239
243
|
return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };
|
|
@@ -305,20 +309,48 @@ function wordsOf(text) {
|
|
|
305
309
|
} catch {
|
|
306
310
|
for (const word of plainText.split(/[^\p{L}\p{N}]+/u)) if (word && !STOPWORDS.has(word)) out.push(word);
|
|
307
311
|
}
|
|
308
|
-
|
|
312
|
+
/** 本机 ICU 缺中文词典时整句会被切成单字;补上二元组,别让「这」这种单字当整词。 */
|
|
313
|
+
if (!/[a-z]/i.test(plainText) && out.some((word) => [...word].length === 1)) {
|
|
314
|
+
const chars = plainText.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
315
|
+
for (let i = 0; i + 1 < chars.length; i += 1) {
|
|
316
|
+
const gram = chars.slice(i, i + 2);
|
|
317
|
+
if (!STOPWORDS.has(gram)) out.push(gram);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return out.slice(0, 48);
|
|
309
321
|
}
|
|
310
322
|
|
|
311
323
|
/** 纯英文查询按整词判断:git 不该命中 digital。自动召回和手动搜索共用这一条。 */
|
|
312
324
|
export const asciiWordsOf = (text) => plain(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
|
|
313
325
|
export const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
|
|
314
326
|
|
|
315
|
-
const WEIGHT = { title: 3, keywords: 4, summary: 1.5, when: 1 };
|
|
327
|
+
const WEIGHT = { title: 3, keywords: 4, summary: 1.5, when: 1, coverage: 1.6 };
|
|
328
|
+
|
|
329
|
+
/** 最长公共连续片段:四个字以上连着重合,是「在说同一件事」的强证据。 */
|
|
330
|
+
function longestRun(text, raw) {
|
|
331
|
+
/** 只比前 120 个字:够判断「是不是同一句话」,也把每次比对的代价压在常数级。 */
|
|
332
|
+
const left = plain(text, 120).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
333
|
+
const right = plain(raw, 120).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
334
|
+
if (!left || !right) return 0;
|
|
335
|
+
let best = 0;
|
|
336
|
+
let prev = new Array(right.length + 1).fill(0);
|
|
337
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
338
|
+
const row = new Array(right.length + 1).fill(0);
|
|
339
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
340
|
+
if (left[i - 1] !== right[j - 1]) continue;
|
|
341
|
+
row[j] = prev[j - 1] + 1;
|
|
342
|
+
if (row[j] > best) best = row[j];
|
|
343
|
+
}
|
|
344
|
+
prev = row;
|
|
345
|
+
}
|
|
346
|
+
return best;
|
|
347
|
+
}
|
|
316
348
|
|
|
317
349
|
/**
|
|
318
350
|
* 轻量匹配:标题 / 旧标题 / 关键词 / 摘要各算各的分,先过阈值再排序。
|
|
319
351
|
* 排序固定用 id 兜底,同一批数据每次顺序一致。
|
|
320
352
|
*/
|
|
321
|
-
export function matchScore(record, query) {
|
|
353
|
+
export function matchScore(record, query, corpus = null) {
|
|
322
354
|
const raw = plain(query, 400);
|
|
323
355
|
if (!raw) return { score: 0, hints: [] };
|
|
324
356
|
if (new RegExp(`\\b${String(record.id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(raw)) {
|
|
@@ -357,16 +389,46 @@ export function matchScore(record, query) {
|
|
|
357
389
|
score += WEIGHT.keywords;
|
|
358
390
|
hints.push('trigger');
|
|
359
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* 整词命中按字段取一次最大值:一个字/一个词重合只说明沾边,
|
|
394
|
+
* 每个词各加一次会把分数堆到阈值以上(「这」曾让无关记忆被自动召回)。
|
|
395
|
+
*/
|
|
396
|
+
/**
|
|
397
|
+
* 语料里到处都有的词(「触发」「文件」「项目」)只算沾边:
|
|
398
|
+
* 一条记忆靠这种通用词挤进提示位,真相关的那条就被挤出去了。
|
|
399
|
+
*/
|
|
400
|
+
const common = (text) => {
|
|
401
|
+
if (!corpus || !corpus.df || !corpus.total) return false;
|
|
402
|
+
const df = corpus.df.get(text) || 0;
|
|
403
|
+
return df > Math.max(3, corpus.total * 0.15);
|
|
404
|
+
};
|
|
405
|
+
let titleWordHit = false;
|
|
406
|
+
let keywordWordHit = false;
|
|
407
|
+
/** 标题里同时命中的不同实词:两个以上独立概念都落在标题,比只沾一个词更说明在说同一件事。 */
|
|
408
|
+
const titleWords = new Set();
|
|
360
409
|
for (const word of words) {
|
|
410
|
+
if ([...word].length < 2) continue;
|
|
361
411
|
const plainWord = word.toLowerCase();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
412
|
+
if (common(plainWord)) continue;
|
|
413
|
+
if (keywordTexts.some((text) => plain(text, 400).toLowerCase().includes(plainWord))) { keywordWordHit = true; continue; }
|
|
414
|
+
if (plain(record.title, 400).toLowerCase().includes(plainWord)
|
|
415
|
+
|| (record.aliases || []).some((name) => plain(name, 400).toLowerCase().includes(plainWord))) {
|
|
416
|
+
titleWordHit = true;
|
|
417
|
+
titleWords.add(plainWord);
|
|
368
418
|
}
|
|
369
419
|
}
|
|
420
|
+
if (!keywordWordHit && titleWordHit) { score += WEIGHT.title; hints.push('title'); }
|
|
421
|
+
if (keywordWordHit) { score += WEIGHT.keywords; hints.push('keyword'); }
|
|
422
|
+
if (titleWords.size >= 2) { score += WEIGHT.coverage; hints.push('coverage'); }
|
|
423
|
+
/** 四字以上的连续重合直接算强命中:句子换了说法但词还连着,就该被想起。 */
|
|
424
|
+
/** 分已经够的就不必算连续片段;这一步只是补「换了说法但词连着」的情况。 */
|
|
425
|
+
const run = score >= MATCH_FLOOR ? 0 : Math.max(0, ...[record.title, (record.aliases || []).join(' '), record.summary, record.when]
|
|
426
|
+
.filter(Boolean)
|
|
427
|
+
.map((text) => longestRun(text, raw)));
|
|
428
|
+
if (run >= 4) {
|
|
429
|
+
score += WEIGHT.title;
|
|
430
|
+
hints.push('phrase');
|
|
431
|
+
}
|
|
370
432
|
const summaryScore = hit(record.summary, WEIGHT.summary);
|
|
371
433
|
if (summaryScore > 0) {
|
|
372
434
|
score += summaryScore;
|
|
@@ -381,7 +443,7 @@ export function matchScore(record, query) {
|
|
|
381
443
|
.filter(Boolean).join('\n').toLowerCase();
|
|
382
444
|
if (!ascii.some((word) => wordIn(pool, word))) return { score: 0, hints: [] };
|
|
383
445
|
}
|
|
384
|
-
return { score, hints };
|
|
446
|
+
return { score, hints: [...new Set(hints)] };
|
|
385
447
|
}
|
|
386
448
|
|
|
387
449
|
export const MATCH_FLOOR = 2.4;
|
|
@@ -394,6 +456,17 @@ export function rank(records, query, options = {}) {
|
|
|
394
456
|
const limit = Number.isInteger(options.limit) ? options.limit : 4;
|
|
395
457
|
const context = options.context || {};
|
|
396
458
|
const rows = [];
|
|
459
|
+
/** 先数一遍词出现在多少条记忆里:通用词降权,只在这批候选内统计,不额外读盘。 */
|
|
460
|
+
const corpus = { df: new Map(), total: 0 };
|
|
461
|
+
for (const record of records || []) {
|
|
462
|
+
if (!record) continue;
|
|
463
|
+
corpus.total += 1;
|
|
464
|
+
const seen = new Set();
|
|
465
|
+
for (const word of wordsOf([record.title, (record.aliases || []).join(' '), (record.tags || []).join(' '), (record.triggers || []).join(' '), record.summary, record.when].filter(Boolean).join(' '))) {
|
|
466
|
+
seen.add(word.toLowerCase());
|
|
467
|
+
}
|
|
468
|
+
for (const word of seen) corpus.df.set(word, (corpus.df.get(word) || 0) + 1);
|
|
469
|
+
}
|
|
397
470
|
for (const record of records || []) {
|
|
398
471
|
if (!record || record.status === 'archived') continue;
|
|
399
472
|
if (options.requireApplicable && record.reviewState === 'legacy_unreviewed') continue;
|
|
@@ -406,7 +479,7 @@ export function rank(records, query, options = {}) {
|
|
|
406
479
|
status = options.requireApplicable ? statusOf(record, context) : 'ok';
|
|
407
480
|
if (options.requireApplicable && status !== 'ok') continue;
|
|
408
481
|
}
|
|
409
|
-
const matched = matchScore(record, query);
|
|
482
|
+
const matched = matchScore(record, query, corpus);
|
|
410
483
|
if (matched.score < MATCH_FLOOR) continue;
|
|
411
484
|
/** 当前任务明确的话题/工作类型优先,但不覆盖查询本身的字面命中。 */
|
|
412
485
|
const task = options.task || {};
|
|
@@ -594,7 +667,7 @@ export function newestUserQuery(messages) {
|
|
|
594
667
|
export function projectionText(view) {
|
|
595
668
|
if (!view) return '';
|
|
596
669
|
const rows = [];
|
|
597
|
-
rows.push(`${view.title ||
|
|
670
|
+
rows.push(`${view.title || '这条记忆'}${view.when ? `(${view.when})` : ''}`);
|
|
598
671
|
if (view.conditions && view.conditions.length) rows.push(`条件:${view.conditions.map((item) => `${item.key}=${item.value}`).join(',')}`);
|
|
599
672
|
if (view.selection) rows.push(`仅对 ${view.selection} 生效`);
|
|
600
673
|
if (view.state === 'needs_context') rows.push('适用范围待确认');
|