dsh-lite-memory 0.18.33 → 0.18.35
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 -0
- package/bench/bench.mjs +26 -4
- package/docs/BENCH.md +13 -12
- package/docs/RELEASE-0.18.34.md +11 -0
- package/docs/RELEASE-0.18.35.md +11 -0
- package/index.js +173 -24
- package/learning.js +34 -8
- package/package.json +1 -1
- package/store.js +46 -30
- package/test/contract.test.mjs +15 -11
- package/test/crossing.test.mjs +35 -4
- package/test/layer-read.test.mjs +2 -2
- package/test/learning.test.mjs +87 -0
- package/test/recall.test.mjs +243 -2
- package/test/runtime-context.test.mjs +4 -0
- package/test/store.test.mjs +20 -4
- package/view.js +52 -11
package/test/recall.test.mjs
CHANGED
|
@@ -94,7 +94,10 @@ const patchState = (scope, id, patch) => {
|
|
|
94
94
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
95
|
const entry = raw.payload.entries.find((row) => row.id === id);
|
|
96
96
|
assert.ok(entry, `state 里没有 ${id}`);
|
|
97
|
-
Object.
|
|
97
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
98
|
+
entry[key] = value && typeof value === 'object' && !Array.isArray(value)
|
|
99
|
+
? { ...(entry[key] || {}), ...value } : value;
|
|
100
|
+
}
|
|
98
101
|
raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
|
|
99
102
|
fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
100
103
|
};
|
|
@@ -337,6 +340,28 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
337
340
|
assert.equal(bootA.debug.windowOf(agentReads).reads, 8);
|
|
338
341
|
}
|
|
339
342
|
|
|
343
|
+
// A11b:准备阶段不算使用;只有真正交付给模型的卡片才更新 lastUsed。
|
|
344
|
+
{
|
|
345
|
+
const cwdUse = mkdir('delivered');
|
|
346
|
+
const scopeUse = store.scopeOf(cwdUse);
|
|
347
|
+
const only = save(scopeUse, { title: '交付才算用过的记忆', tags: ['交付测试'], summary: '只看不交付不该改变使用时间' });
|
|
348
|
+
const agent = makeAgent('agent-delivered', cwdUse);
|
|
349
|
+
const runtime = boot();
|
|
350
|
+
const sig = signal();
|
|
351
|
+
patchState(scopeUse, only.record.id, { maintenance: { lastUsed: '2020-01-01T00:00:00.000Z' } });
|
|
352
|
+
const saved = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
353
|
+
assert.equal(saved.lastUsed, '2020-01-01T00:00:00.000Z', 'patch 生效');
|
|
354
|
+
await runtime.preStep({ agent, signal: sig, turn: 'u1' }, enter(human('交付测试怎么做')));
|
|
355
|
+
const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
356
|
+
assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
|
|
357
|
+
assert.match(runtime.provider.text({ agent, signal: sig }), /交付才算用过的记忆/);
|
|
358
|
+
const afterProvider = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
359
|
+
assert.equal(afterProvider.lastUsed, saved.lastUsed, 'provider 输出路径不写使用时间');
|
|
360
|
+
runtime.handlers.get('agent/turn-stopping').at(-1)({ turn: 'u1', signal: sig });
|
|
361
|
+
const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
362
|
+
assert.notEqual(delivered.lastUsed, saved.lastUsed, '回合收尾只给真正交付的卡片记使用');
|
|
363
|
+
}
|
|
364
|
+
|
|
340
365
|
// A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
|
|
341
366
|
{
|
|
342
367
|
const agent = makeAgent('agent-preserve', cwdA);
|
|
@@ -353,6 +378,58 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
353
378
|
assert.equal(win.bytes, used, 'same window does not re-allocate for already provided cards');
|
|
354
379
|
}
|
|
355
380
|
|
|
381
|
+
// A13b:轮次收尾清的是投影,不是任务;下一句「继续」仍要接上上一轮的原话。
|
|
382
|
+
{
|
|
383
|
+
const agent = makeAgent('agent-after-turn', cwdA);
|
|
384
|
+
const first = signal();
|
|
385
|
+
await bootA.preStep({ agent, signal: first, turn: 'n1' }, enter(human('插件改完怎么让它生效?')));
|
|
386
|
+
assert.ok(bootA.provider.text({ agent, signal: first }));
|
|
387
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'n1', signal: first });
|
|
388
|
+
assert.equal(bootA.provider.text({ agent, signal: first }), '');
|
|
389
|
+
const second = signal();
|
|
390
|
+
await bootA.preStep({ agent, signal: second, turn: 'n2' }, enter(human('继续')));
|
|
391
|
+
assert.match(bootA.provider.text({ agent, signal: second }), /插件改完先试热刷新/, '收尾后「继续」仍属原任务');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// A13c:新任务零召回也照样记下新查询;再说「继续」不能把旧任务的记忆带回来。
|
|
395
|
+
{
|
|
396
|
+
const agent = makeAgent('agent-zero-task', cwdA);
|
|
397
|
+
const first = signal();
|
|
398
|
+
await bootA.preStep({ agent, signal: first, turn: 'z1' }, enter(human('插件改完怎么让它生效?')));
|
|
399
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z1', signal: first });
|
|
400
|
+
const second = signal();
|
|
401
|
+
await bootA.preStep({ agent, signal: second, turn: 'z2' }, enter(human('帮我算一下 17 乘以 23 等于多少')));
|
|
402
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z2', signal: second });
|
|
403
|
+
const third = signal();
|
|
404
|
+
await bootA.preStep({ agent, signal: third, turn: 'z3' }, enter(human('继续')));
|
|
405
|
+
assert.doesNotMatch(bootA.provider.text({ agent, signal: third }), /插件改完先试热刷新/, '零召回的新任务不能被旧任务续上');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// A13d:工具续步没有新的真人原话,刚执行的动作可以换来一次有界补查。
|
|
409
|
+
{
|
|
410
|
+
const cwdStep = mkdir('tool-step');
|
|
411
|
+
const scopeStep = store.scopeOf(cwdStep);
|
|
412
|
+
save(scopeStep, { title: '记忆插件先看状态再改', tags: ['记忆插件', '排查'], summary: '先看状态文件再动代码' });
|
|
413
|
+
save(scopeStep, { title: '改完 client.js 先重载宿主', tags: ['client.js', '重载'], summary: '普通刷新覆盖不到 client.js' });
|
|
414
|
+
const agent = makeAgent('agent-tool-step', cwdStep);
|
|
415
|
+
const runtime = boot();
|
|
416
|
+
const said = human('记忆插件怎么排查');
|
|
417
|
+
const first = signal();
|
|
418
|
+
await runtime.preStep({ agent, signal: first, turn: 's1' }, enter(said));
|
|
419
|
+
assert.match(runtime.provider.text({ agent, signal: first }), /记忆插件先看状态再改/);
|
|
420
|
+
const toolStep = {
|
|
421
|
+
role: 'assistant',
|
|
422
|
+
id: 'asst-tool-step',
|
|
423
|
+
content: [
|
|
424
|
+
{ type: 'text', text: '我把改动写进文件了。' },
|
|
425
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/step/client.js' } },
|
|
426
|
+
],
|
|
427
|
+
};
|
|
428
|
+
const second = signal();
|
|
429
|
+
await runtime.preStep({ agent, signal: second, turn: 's1' }, enter(said, toolStep));
|
|
430
|
+
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '工具动作后补查一次');
|
|
431
|
+
}
|
|
432
|
+
|
|
356
433
|
// A14:上一轮注入过的文字不会被当成这一轮的来源;只有合成消息的新 turn 不继承。
|
|
357
434
|
{
|
|
358
435
|
const agent = makeAgent('agent-stale-section', cwdA);
|
|
@@ -585,7 +662,11 @@ console.log('recall.test.mjs: ok');
|
|
|
585
662
|
};
|
|
586
663
|
const second = signal();
|
|
587
664
|
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
|
|
588
|
-
|
|
665
|
+
const actionText = runtime.provider.text({ agent, signal: second });
|
|
666
|
+
assert.match(actionText, /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
|
|
667
|
+
const actionWin = runtime.debug.windowOf(agent);
|
|
668
|
+
assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(actionText), '最终交付字节与预算账本一致');
|
|
669
|
+
assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '最终卡片数与预算账本一致');
|
|
589
670
|
}
|
|
590
671
|
|
|
591
672
|
// A25b:短续句「继续优化收敛」没有自己的检索词,要沿用上一句,不能断在上一轮。
|
|
@@ -734,3 +815,163 @@ console.log('recall.test.mjs: ok');
|
|
|
734
815
|
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
|
|
735
816
|
assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
|
|
736
817
|
}
|
|
818
|
+
|
|
819
|
+
// A27:npm 原生命令在动作发生前拦一次,把凭据用法直接交给模型;同一轮不重复。
|
|
820
|
+
{
|
|
821
|
+
const cwdNpm = mkdir('npm-guard');
|
|
822
|
+
const scopeNpm = store.scopeOf(cwdNpm);
|
|
823
|
+
store.setRecallMode(scopeNpm, 'adaptive', { operationId: 'npm-guard-mode' });
|
|
824
|
+
assert.ok(store.upsert(global, {
|
|
825
|
+
title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
|
|
826
|
+
summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
|
|
827
|
+
body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
|
|
828
|
+
tags: ['npm', 'dsh-npm', '凭据'],
|
|
829
|
+
applicability: 'all_in_scope',
|
|
830
|
+
}).record);
|
|
831
|
+
const guards = [];
|
|
832
|
+
const agent = makeAgent('agent-npm-guard', cwdNpm);
|
|
833
|
+
agent.ctx = {
|
|
834
|
+
tools: { guard: (fn) => { guards.push(fn); return () => {}; } },
|
|
835
|
+
effect: (fn) => fn(),
|
|
836
|
+
};
|
|
837
|
+
const runtime = boot();
|
|
838
|
+
runtime.handlers.get('agent/created').at(-1)({ agent });
|
|
839
|
+
assert.equal(guards.length, 1, '每个 agent 装一个执行前守卫');
|
|
840
|
+
const sig = signal();
|
|
841
|
+
await runtime.preStep({ agent, signal: sig, turn: 'npm-1' }, enter(human('帮我发布这个包')));
|
|
842
|
+
const blocked = guards[0]({ name: 'bash', callId: 'call-npm-1', arguments: { command: 'npm whoami' } });
|
|
843
|
+
assert.match(String(blocked), /npm 发布一律走 dsh-npm/, '动作发生前给已有记忆正文');
|
|
844
|
+
assert.match(String(blocked), /Keychain/, '给正文而不是只给标题');
|
|
845
|
+
assert.equal(guards[0]({ name: 'bash', callId: 'call-npm-2', arguments: { command: 'npm publish' } }), undefined, '同一轮只提示一次');
|
|
846
|
+
assert.ok(scopeNpm.key, '项目层不受影响');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
// A28:零命中的同一轮里,关键动作发生后补查一次;补到后不再重复注入。
|
|
850
|
+
{
|
|
851
|
+
const cwdActionKey = mkdir('action-key');
|
|
852
|
+
const scopeActionKey = store.scopeOf(cwdActionKey);
|
|
853
|
+
store.setRecallMode(scopeActionKey, 'adaptive', { operationId: 'action-key-mode' });
|
|
854
|
+
const agent = makeAgent('agent-action-key', cwdActionKey);
|
|
855
|
+
const runtime = boot();
|
|
856
|
+
const first = signal();
|
|
857
|
+
await runtime.preStep({ agent, signal: first, turn: 'ak1' }, enter(human('这个怎么弄')));
|
|
858
|
+
assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
|
|
859
|
+
const assistantMessage = {
|
|
860
|
+
role: 'assistant',
|
|
861
|
+
id: 'asst-action-key-1',
|
|
862
|
+
content: [
|
|
863
|
+
{ type: 'text', text: '我先看一下。' },
|
|
864
|
+
{ type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } },
|
|
865
|
+
],
|
|
866
|
+
};
|
|
867
|
+
const toolResult = { role: 'user', id: 'tool-action-key-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
|
|
868
|
+
const second = signal();
|
|
869
|
+
await runtime.preStep({ agent, signal: second, turn: 'ak1' }, enter(assistantMessage, toolResult));
|
|
870
|
+
const text = runtime.provider.text({ agent, signal: second });
|
|
871
|
+
assert.match(text, /npm 发布一律走 dsh-npm/, '关键动作让凭据记忆在动作后补进来');
|
|
872
|
+
assert.equal(runtime.debug.preparedOf(agent).actionRetried, true, '一次请求只补查一次');
|
|
873
|
+
const actionWin = runtime.debug.windowOf(agent);
|
|
874
|
+
assert.equal(actionWin.autoBudget.totalBytes, Buffer.byteLength(text), '补查后的最终字节与账本一致');
|
|
875
|
+
assert.equal(actionWin.autoBudget.cards, runtime.debug.preparedOf(agent).cards.length, '补查后的卡片数与账本一致');
|
|
876
|
+
const third = signal();
|
|
877
|
+
await runtime.preStep({ agent, signal: third, turn: 'ak1' }, enter({
|
|
878
|
+
role: 'assistant',
|
|
879
|
+
id: 'asst-action-key-2',
|
|
880
|
+
content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
|
|
881
|
+
}, { role: 'user', id: 'tool-action-key-2', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
|
|
882
|
+
assert.equal(runtime.provider.text({ agent, signal: third }), text, '同一请求不重复补查,也不花第二份额度');
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// A29:没有「换任务」这类明说,但新话题已经可判定为另一件事时,旧动作线索不能跟过来。
|
|
886
|
+
{
|
|
887
|
+
const cwdFree = mkdir('free-switch');
|
|
888
|
+
const scopeFree = store.scopeOf(cwdFree);
|
|
889
|
+
save(scopeFree, {
|
|
890
|
+
title: '改完 client.js 先重载宿主',
|
|
891
|
+
tags: ['client.js', '重载'],
|
|
892
|
+
summary: 'client.js 改动后必须重载宿主,普通刷新覆盖不到',
|
|
893
|
+
});
|
|
894
|
+
save(scopeFree, {
|
|
895
|
+
title: 'PostgreSQL 连接池先查 max_connections',
|
|
896
|
+
tags: ['PostgreSQL', '连接池'],
|
|
897
|
+
summary: '连接池调大前先看 max_connections 和实际占用',
|
|
898
|
+
});
|
|
899
|
+
const agent = makeAgent('agent-free-switch', cwdFree);
|
|
900
|
+
const runtime = boot();
|
|
901
|
+
const first = signal();
|
|
902
|
+
await runtime.preStep({ agent, signal: first, turn: 'f1' }, enter(human('前端按钮颜色怎么改')));
|
|
903
|
+
const assistantMessage = {
|
|
904
|
+
role: 'assistant',
|
|
905
|
+
id: 'asst-free-1',
|
|
906
|
+
content: [
|
|
907
|
+
{ type: 'text', text: '我把 client.js 的样式改好了。' },
|
|
908
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/free/client.js' } },
|
|
909
|
+
],
|
|
910
|
+
};
|
|
911
|
+
const second = signal();
|
|
912
|
+
await runtime.preStep({ agent, signal: second, turn: 'f2' }, enter(assistantMessage, human('PostgreSQL 连接池满了怎么排查')));
|
|
913
|
+
const text = runtime.provider.text({ agent, signal: second });
|
|
914
|
+
assert.match(text, /PostgreSQL 连接池/, '新话题命中自己的记忆');
|
|
915
|
+
assert.doesNotMatch(text, /改完 client\.js 先重载宿主/, '旧任务线索不能进新任务');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// A30:只读文件、memory 工具输出不算「关键动作」,不能提前花掉一次补查机会。
|
|
919
|
+
{
|
|
920
|
+
const cwdRead = mkdir('read-action');
|
|
921
|
+
const scopeRead = store.scopeOf(cwdRead);
|
|
922
|
+
store.setRecallMode(scopeRead, 'adaptive', { operationId: 'read-action-mode' });
|
|
923
|
+
save(scopeRead, {
|
|
924
|
+
title: 'npm 发布一律走 dsh-npm,ENEEDAUTH 不是缺凭据',
|
|
925
|
+
tags: ['npm', 'dsh-npm', '凭据'],
|
|
926
|
+
summary: '发布走 dsh-npm,不要用 npm whoami 判断凭据',
|
|
927
|
+
body: '本机 npm 凭据在 Keychain,npm whoami 常报 ENEEDAUTH;发布一律用 dsh-npm publish,不要重新登录。',
|
|
928
|
+
});
|
|
929
|
+
const agent = makeAgent('agent-read-action', cwdRead);
|
|
930
|
+
const runtime = boot();
|
|
931
|
+
const first = signal();
|
|
932
|
+
await runtime.preStep({ agent, signal: first, turn: 'ra1' }, enter(human('这个怎么弄')));
|
|
933
|
+
assert.equal(runtime.provider.text({ agent, signal: first }), '', '第一步没有线索时不凭空命中');
|
|
934
|
+
const passiveStep = {
|
|
935
|
+
role: 'assistant',
|
|
936
|
+
id: 'asst-read-action-1',
|
|
937
|
+
content: [
|
|
938
|
+
{ type: 'text', text: '先看文档和已有记录。' },
|
|
939
|
+
{ type: 'tool-call', name: 'read', input: { file_path: '/tmp/lite-memory/README.md' } },
|
|
940
|
+
{ type: 'tool-call', name: 'memory_search', input: { query: 'npm 凭据怎么用' } },
|
|
941
|
+
],
|
|
942
|
+
};
|
|
943
|
+
const readResult = { role: 'user', id: 'tool-read-action-1', source: { kind: 'tool' }, content: [{ type: 'text', text: '# README 一段普通说明' }] };
|
|
944
|
+
const second = signal();
|
|
945
|
+
await runtime.preStep({ agent, signal: second, turn: 'ra1' }, enter(passiveStep, readResult));
|
|
946
|
+
const heldAfterRead = runtime.debug.preparedOf(agent);
|
|
947
|
+
assert.equal(heldAfterRead?.actionRetried, false, '只读操作不触发补查');
|
|
948
|
+
assert.equal(runtime.provider.text({ agent, signal: second }), '', '只读操作后仍保持空快照');
|
|
949
|
+
const activeStep = {
|
|
950
|
+
role: 'assistant',
|
|
951
|
+
id: 'asst-read-action-2',
|
|
952
|
+
content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm whoami' } }],
|
|
953
|
+
};
|
|
954
|
+
const activeResult = { role: 'user', id: 'tool-read-action-2', source: { kind: 'tool' }, content: [{ type: 'text', text: '已完成' }] };
|
|
955
|
+
const third = signal();
|
|
956
|
+
await runtime.preStep({ agent, signal: third, turn: 'ra1' }, enter(activeStep, activeResult));
|
|
957
|
+
const text = runtime.provider.text({ agent, signal: third });
|
|
958
|
+
assert.match(text, /npm 发布一律走 dsh-npm/, '真正关键动作仍能补查一次');
|
|
959
|
+
assert.equal(runtime.debug.preparedOf(agent).actionRetried, true);
|
|
960
|
+
const fourth = signal();
|
|
961
|
+
await runtime.preStep({ agent, signal: fourth, turn: 'ra1' }, enter({
|
|
962
|
+
role: 'assistant', id: 'asst-read-action-3', content: [{ type: 'tool-call', name: 'bash', input: { command: 'npm publish' } }],
|
|
963
|
+
}, { role: 'user', id: 'tool-read-action-3', source: { kind: 'tool' }, content: [{ type: 'text', text: 'ERR' }] }));
|
|
964
|
+
assert.equal(runtime.provider.text({ agent, signal: fourth }), text, '同一请求不重复补查');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// A31:只有真人明确说「存到全局/所有项目」才算跨项目授权,普通「全局变量」不能放行。
|
|
968
|
+
{
|
|
969
|
+
const runtime = boot();
|
|
970
|
+
const asked = runtime.debug.humanAskedGlobal;
|
|
971
|
+
for (const text of ['以后这条存全局', '记到全局记忆里', '所有项目都按这个来', '以后所有项目都这样做']) {
|
|
972
|
+
assert.equal(asked(text), true, text);
|
|
973
|
+
}
|
|
974
|
+
for (const text of ['把全局变量保存一下', '全局搜索这个函数', '这个项目保存一下', '都改完了保存']) {
|
|
975
|
+
assert.equal(asked(text), false, text);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
@@ -166,6 +166,10 @@ const seedRule = (scope, { title, body, summary, conditions = [], learning = { s
|
|
|
166
166
|
assert.ok(auto.experimentBytes <= 1200, 'experiment bytes over cap');
|
|
167
167
|
assert.ok(auto.cardBytes <= 1200);
|
|
168
168
|
assert.ok(Buffer.byteLength(text) <= 1300, 'rendered text exceeds budget');
|
|
169
|
+
assert.equal(auto.totalBytes, Buffer.byteLength(text), '最终交付字节与预算账本一致');
|
|
170
|
+
const held = runtime.debug.preparedOf(agent);
|
|
171
|
+
assert.equal(auto.cards, (auto.normalCards || 0) + (auto.experimentCards || 0), '卡片账本内部一致');
|
|
172
|
+
assert.equal(auto.normalCards || 0, (held?.cards || []).length, '普通卡片数与预算账本一致');
|
|
169
173
|
const heads = new Set(text.split('\n').filter((line) => /口播(节奏规范|实验规则)/.test(line)).map((line) => line.trim()));
|
|
170
174
|
assert.ok(heads.size <= 3, `too many cards: ${heads.size}`);
|
|
171
175
|
}
|
package/test/store.test.mjs
CHANGED
|
@@ -1101,8 +1101,15 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
|
|
|
1101
1101
|
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1102
1102
|
memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
|
|
1103
1103
|
}, { project });
|
|
1104
|
-
assert.equal(withEvidence.chosenScope, '
|
|
1105
|
-
assert.equal(withEvidence.reasonCode, '
|
|
1104
|
+
assert.equal(withEvidence.chosenScope, 'project', '模型自动归类即便有依据也要先留在项目层');
|
|
1105
|
+
assert.equal(withEvidence.reasonCode, 'model_class_project');
|
|
1106
|
+
|
|
1107
|
+
const withEvidenceConfirmed = store.routeScope({
|
|
1108
|
+
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1109
|
+
memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
|
|
1110
|
+
}, { project, humanScope: true });
|
|
1111
|
+
assert.equal(withEvidenceConfirmed.chosenScope, 'global', '人确认后才按归类落全局');
|
|
1112
|
+
assert.equal(withEvidenceConfirmed.reasonCode, 'class_portable_procedure');
|
|
1106
1113
|
|
|
1107
1114
|
const evidencePath = store.routeScope({
|
|
1108
1115
|
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
@@ -1133,8 +1140,17 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
|
|
|
1133
1140
|
memoryClass: 'portable_procedure',
|
|
1134
1141
|
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1135
1142
|
}, { project });
|
|
1136
|
-
assert.equal(scopedProcedure.chosenScope, '
|
|
1137
|
-
assert.equal(scopedProcedure.reasonCode, '
|
|
1143
|
+
assert.equal(scopedProcedure.chosenScope, 'project', '模型自动归类不能自己搬去全局层');
|
|
1144
|
+
assert.equal(scopedProcedure.reasonCode, 'model_class_project');
|
|
1145
|
+
|
|
1146
|
+
const scopedProcedureConfirmed = store.routeScope({
|
|
1147
|
+
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1148
|
+
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1149
|
+
memoryClass: 'portable_procedure',
|
|
1150
|
+
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1151
|
+
}, { project, humanScope: true });
|
|
1152
|
+
assert.equal(scopedProcedureConfirmed.chosenScope, 'global', '人确认后带机器条件的流程可进全局层');
|
|
1153
|
+
assert.equal(scopedProcedureConfirmed.reasonCode, 'machine_scoped_procedure');
|
|
1138
1154
|
|
|
1139
1155
|
const scopedExplicit = store.routeScope({
|
|
1140
1156
|
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
package/view.js
CHANGED
|
@@ -741,7 +741,7 @@ export function hasConcreteAction(text) {
|
|
|
741
741
|
* 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
|
|
742
742
|
* 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
|
|
743
743
|
*/
|
|
744
|
-
const SWITCH_MARK = /(
|
|
744
|
+
const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|换任务|先结束|结束刚才|结束上一个|去做|开始做)/u;
|
|
745
745
|
export function isExplicitSwitch(text, previousText = '') {
|
|
746
746
|
const current = plain(text, 400);
|
|
747
747
|
if (!current) return false;
|
|
@@ -755,18 +755,30 @@ export function isExplicitSwitch(text, previousText = '') {
|
|
|
755
755
|
* 补查只有在「还是同一个任务」时才成立:任务类型或 episode 变了,上一轮的动作历史属于旧任务,
|
|
756
756
|
* 不能拿它给新任务找记忆。类型缺失时保留补查,由调用方的明确换任务判断兜底。
|
|
757
757
|
*/
|
|
758
|
-
export function sameRecallTask(previous, current) {
|
|
759
|
-
if (!previous || !current) return
|
|
758
|
+
export function sameRecallTask(previous, current, options = {}) {
|
|
759
|
+
if (!previous || !current) return false;
|
|
760
|
+
const continuation = options.continuation === true;
|
|
760
761
|
const kind = (value) => plain(value && value.kind, 40).trim().toLowerCase();
|
|
761
762
|
const episode = (value) => plain(value && value.episodeId, 120).trim();
|
|
763
|
+
const previousKind = kind(previous);
|
|
764
|
+
const currentKind = kind(current);
|
|
762
765
|
/** 两侧都认出了任务类型且不同:明确换任务,上一轮的动作历史不再属于当前请求。 */
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
766
|
+
if (previousKind && currentKind && previousKind !== currentKind) return false;
|
|
767
|
+
const previousEpisode = episode(previous);
|
|
768
|
+
const currentEpisode = episode(current);
|
|
769
|
+
if (previousEpisode && currentEpisode) {
|
|
770
|
+
if (previousEpisode === currentEpisode) return true;
|
|
771
|
+
/** 同一句追问又出现在下一轮:episode 只是平台切的新轮次,任务没有变。 */
|
|
772
|
+
if (options.sameQuery === true && !previousKind && !currentKind) return true;
|
|
773
|
+
/** 真正的续说可能新开 episode:只有明确续说才允许跨 episode 沿用上一任务。 */
|
|
774
|
+
return continuation && (!previousKind || !currentKind || previousKind === currentKind);
|
|
767
775
|
}
|
|
768
|
-
/**
|
|
769
|
-
|
|
776
|
+
/**
|
|
777
|
+
* 认不出新任务类型时不能默认沿用旧任务:上一轮的动作线索只属于旧任务。
|
|
778
|
+
* 只有明确续说、或真人原话与上一轮完全相同(同一条追问的下一步)才接着用。
|
|
779
|
+
*/
|
|
780
|
+
if (!previousKind && !currentKind && options.sameQuery === true) return true;
|
|
781
|
+
return continuation;
|
|
770
782
|
}
|
|
771
783
|
|
|
772
784
|
/**
|
|
@@ -782,6 +794,31 @@ function isMemoryNoise(name, text) {
|
|
|
782
794
|
if (name && MEMORY_TOOL.test(String(name))) return true;
|
|
783
795
|
return Boolean(text && MEMORY_TEXT.test(String(text).trimStart()));
|
|
784
796
|
}
|
|
797
|
+
/** 纯读取不会改变下一步动作:读 README / 搜索 / 记忆工具都不消耗唯一补查。 */
|
|
798
|
+
const PASSIVE_TOOL = /^(?:read|glob|grep|list|view|fetch|web_search|web_fetch|job_output|job_list|todo_write|question|ask|memory(?:[_-].*)?)$/i;
|
|
799
|
+
const EFFECTIVE_COMMAND = /(?:^|[\s;&|])(?:npm|npx|pnpm|yarn|git|gh|docker|kubectl|helm|systemctl|launchctl|make|cargo|go|python|node|curl|ssh|rsync|deploy|publish|install|test|build|migrate|reload)(?:[\s;&|]|$)/i;
|
|
800
|
+
/** 动作侧是否真的发生了一个会改变状态的动作;只有这种动作才允许补查一次。 */
|
|
801
|
+
export function hasEffectiveAction(messages) {
|
|
802
|
+
if (!Array.isArray(messages)) return false;
|
|
803
|
+
for (const message of messages) {
|
|
804
|
+
const content = message?.content;
|
|
805
|
+
if (!Array.isArray(content)) continue;
|
|
806
|
+
for (const part of content) {
|
|
807
|
+
if (!part || typeof part !== 'object') continue;
|
|
808
|
+
if (part.type !== 'tool-call' && part.type !== 'tool_call') continue;
|
|
809
|
+
const name = String(part.name || '');
|
|
810
|
+
if (isMemoryNoise(name, '')) continue;
|
|
811
|
+
if (!PASSIVE_TOOL.test(name)) return true;
|
|
812
|
+
const input = part.input || part.arguments || part.args;
|
|
813
|
+
if (!input || typeof input !== 'object') continue;
|
|
814
|
+
for (const value of Object.values(input)) {
|
|
815
|
+
if (typeof value === 'string' && EFFECTIVE_COMMAND.test(value)) return true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
|
|
785
822
|
export function actionTerms(messages, options = {}) {
|
|
786
823
|
if (!Array.isArray(messages)) return '';
|
|
787
824
|
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
|
|
@@ -804,8 +841,12 @@ export function actionTerms(messages, options = {}) {
|
|
|
804
841
|
if (typeof part.name === 'string') push(part.name);
|
|
805
842
|
const input = part.input || part.arguments || part.args;
|
|
806
843
|
if (input && typeof input === 'object') {
|
|
807
|
-
for (const value of Object.
|
|
808
|
-
if (typeof value
|
|
844
|
+
for (const [key, value] of Object.entries(input)) {
|
|
845
|
+
if (typeof value !== 'string' || value.length > 300) continue;
|
|
846
|
+
/** 命令参数要按空格切词,否则 `npm whoami` 只剩工具名 bash,动作线索就丢了。 */
|
|
847
|
+
const commandish = /^(?:command|cmd|script|args?|input|shell)$/i.test(key)
|
|
848
|
+
|| EFFECTIVE_COMMAND.test(value);
|
|
849
|
+
if (commandish || /[./_-]/.test(value)) {
|
|
809
850
|
for (const piece of value.split(/[^\p{L}\p{N}_.-]+/u)) push(piece);
|
|
810
851
|
}
|
|
811
852
|
}
|