dsh-lite-memory 0.18.34 → 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.35.md +11 -0
- package/index.js +128 -29
- package/learning.js +11 -4
- package/package.json +1 -1
- package/store.js +16 -9
- package/test/contract.test.mjs +15 -11
- package/test/crossing.test.mjs +10 -4
- package/test/layer-read.test.mjs +2 -2
- package/test/learning.test.mjs +39 -0
- package/test/recall.test.mjs +169 -2
- package/test/runtime-context.test.mjs +4 -0
- package/test/store.test.mjs +20 -4
- package/view.js +51 -10
package/README.md
CHANGED
|
@@ -29,12 +29,16 @@ Lightweight memory for the DeepSeek Harness. Nothing resident: no standing promp
|
|
|
29
29
|
|
|
30
30
|
真命中看这句话和记忆的标题(含旧标题)、标签、触发词、摘要有没有对得上的词:有就多一行「已有记忆可能相关」,最多三条标题,每条前面标归属(`[global]` 或项目目录名如 `[DSH]`);没对上就不出现,不改命中计数,正文要 `memory_read` 才读。一条记忆写了触发词,用户说到那个词也算命中。
|
|
31
31
|
|
|
32
|
+
普通记忆卡片和实验规则共用一份额度:一次最多 3 条、1200 字节,按整条准入,先到先占;自动提示只带标题和摘要,不带正文。实验短规则(正文不超过 240 字且不超过 4 行)直接带正文,更长的只给标题和用途,正文仍要 `memory_read`。
|
|
33
|
+
|
|
32
34
|
| Case | Behaviour |
|
|
33
35
|
| --- | --- |
|
|
34
36
|
| **L0** you ask it to remember | written immediately, one confirmation line |
|
|
35
37
|
| **L1** it spots something worth keeping | one muted line `memory · suggested "X" · sug_x` with a single 「保留」 button — readable before you click, nothing written unless you click, at most once per two days |
|
|
36
38
|
| **L2** common sense, one-off parameters, credential values | never written |
|
|
37
39
|
|
|
40
|
+
The automatic hint carries titles and summaries, never bodies. Normal cards and experimental rules share one budget: at most 3 cards / 1200 bytes per request, admitted as whole cards, first come first served. An experimental rule with a body of at most 240 characters and 4 lines is inlined; a longer one gives only its title and purpose, and the body still requires `memory_read`.
|
|
41
|
+
|
|
38
42
|
Those lines belong to the turn that made them: each turn is rebuilt from its own tool results, so reopening the session shows them again and no other turn leaks in. Saving again updates an entry only when the title (or one of its earlier titles) matches exactly under the same precondition. A merely similar title writes nothing first: the result names the closest entry, so the caller either re-saves with that title or adds `resolution="distinct"`. An update keeps every field it was not given and replaces the body wholesale; the previous body is kept only for internal undo and never shown or searched. The same subject under different preconditions stays two entries.
|
|
39
43
|
|
|
40
44
|
Writes land in one of two layers: the current project by default, or the global layer (warns past 200, hard ceiling 1000) when the user says the rule holds for every project. Both layers are searched together, and a `when` field separates the same subject under different preconditions.
|
package/bench/bench.mjs
CHANGED
|
@@ -38,7 +38,7 @@ apply(ctx);
|
|
|
38
38
|
|
|
39
39
|
const cwd = path.join(os.tmpdir(), 'lite-memory-bench-project');
|
|
40
40
|
fs.mkdirSync(cwd, { recursive: true });
|
|
41
|
-
const agent = { cwd, session: { id: 'bench-session', header: { cwd } } };
|
|
41
|
+
const agent = { id: 'bench-agent', cwd, session: { id: 'bench-session', header: { cwd } } };
|
|
42
42
|
const exec = (name, args) => defs.get(name).execute(args, { agent });
|
|
43
43
|
const save = (args) => exec('memory_save', args);
|
|
44
44
|
|
|
@@ -63,16 +63,22 @@ for (let i = 0; i < 200; i += 1) {
|
|
|
63
63
|
resolution: 'distinct',
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* 全局层用受信任写入直接种:经 memory_save 的模型提案会被降到项目层,
|
|
68
|
+
* 这里要测的是两层都在时的召回路径,不是降级路径。
|
|
69
|
+
*/
|
|
70
|
+
const globalScope = store.globalScope();
|
|
66
71
|
for (let i = 0; i < 200; i += 1) {
|
|
67
|
-
|
|
72
|
+
const saved = store.saveEntry(globalScope, {
|
|
68
73
|
title: `全局条目 ${i} 交付规范`,
|
|
69
74
|
summary: `全局条目 ${i} 的通用说明`,
|
|
70
75
|
when: `全局场景 ${i}`,
|
|
71
76
|
body: `全局正文 ${i}:交付前清零告警。`,
|
|
72
|
-
scope: 'global',
|
|
73
77
|
applicability: 'all_in_scope',
|
|
78
|
+
scopeConfirmed: true,
|
|
74
79
|
resolution: 'distinct',
|
|
75
80
|
});
|
|
81
|
+
if (!saved || !saved.record) throw new Error(`global seed ${i} failed: ${JSON.stringify(saved).slice(0, 500)}`);
|
|
76
82
|
}
|
|
77
83
|
report.seedMs = Date.now() - started;
|
|
78
84
|
report.entries = {
|
|
@@ -108,6 +114,19 @@ report.prepare = {
|
|
|
108
114
|
avgContextBytes: Math.round(bytes / timings.length),
|
|
109
115
|
};
|
|
110
116
|
|
|
117
|
+
// Positive scenario: a normal question must actually receive budgeted memory text.
|
|
118
|
+
const positiveSignal = new AbortController().signal;
|
|
119
|
+
await preStep({ agent, signal: positiveSignal, turn: '9000' }, async () => ({
|
|
120
|
+
kind: 'enter',
|
|
121
|
+
messages: [{ role: 'user', id: 'bench-positive', source: { kind: 'user' }, content: [{ type: 'text', text: '全局条目 7 的交付规范是什么' }] }],
|
|
122
|
+
}));
|
|
123
|
+
const positiveText = recall.text({ agent, signal: positiveSignal });
|
|
124
|
+
report.positive = {
|
|
125
|
+
bytes: Buffer.byteLength(positiveText, 'utf8'),
|
|
126
|
+
hasGlobalCard: /全局条目 7 交付规范/.test(positiveText),
|
|
127
|
+
underCap: Buffer.byteLength(positiveText, 'utf8') <= 1300,
|
|
128
|
+
};
|
|
129
|
+
|
|
111
130
|
// File reads per prepare, measured on the same path.
|
|
112
131
|
let reads = 0;
|
|
113
132
|
const original = fs.readFileSync;
|
|
@@ -140,6 +159,9 @@ report.afterSequence = {
|
|
|
140
159
|
files: fs.readdirSync(store.scopeOf(cwd).dir).sort(),
|
|
141
160
|
};
|
|
142
161
|
report.rssEndMB = rss();
|
|
143
|
-
report.ok = project.entries.length <= store.LIMITS.entries + store.LIMITS.hardEntries
|
|
162
|
+
report.ok = project.entries.length <= store.LIMITS.entries + store.LIMITS.hardEntries
|
|
163
|
+
&& report.entries.project >= 200 && report.entries.global >= 200
|
|
164
|
+
&& report.prepare.avgContextBytes > 0
|
|
165
|
+
&& report.positive.bytes > 0 && report.positive.hasGlobalCard && report.positive.underCap;
|
|
144
166
|
|
|
145
167
|
console.log(JSON.stringify(report, null, 2));
|
package/docs/BENCH.md
CHANGED
|
@@ -25,21 +25,22 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
|
|
|
25
25
|
|
|
26
26
|
## 固定数据集性能
|
|
27
27
|
|
|
28
|
-
2026-10-
|
|
28
|
+
2026-10-29,macOS arm64,Node v24.16.0,本地 SSD。数据:项目 200 条 + 全局 200 条,全部带标题、摘要、`when`、正文;另加一条正面场景,确认全局卡片真的进得来且不超预算。
|
|
29
29
|
|
|
30
30
|
0.18.0 起每次保存都走「写临时文件 → fsync → rename → fsync 目录」并保留上一版,代价是单次保存多两次落盘;下面这组数是带这套持久化保证后测的。
|
|
31
31
|
|
|
32
32
|
| 指标 | 数值 |
|
|
33
33
|
| --- | --- |
|
|
34
|
-
| 准备时延 P50 |
|
|
35
|
-
| 准备时延 P95 |
|
|
36
|
-
| 冷启动首次准备 |
|
|
37
|
-
| 最慢一次 |
|
|
38
|
-
| 每次准备上下文 |
|
|
39
|
-
|
|
|
40
|
-
|
|
|
34
|
+
| 准备时延 P50 | 62.3 ms |
|
|
35
|
+
| 准备时延 P95 | 112.4 ms |
|
|
36
|
+
| 冷启动首次准备 | 83.9 ms |
|
|
37
|
+
| 最慢一次 | 136.1 ms |
|
|
38
|
+
| 每次准备上下文 | 368 B |
|
|
39
|
+
| 正面场景上下文 | 335 B,含 1 张全局卡片,未超 3 条 / 1200 字节 |
|
|
40
|
+
| 每次准备文件读取 | 0.2 |
|
|
41
|
+
| 本次数据规模 | 项目 200 条 + 全局 200 条 |
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
数据翻倍到两层各 200 条后,准备时延比 0.18.0 的 200 条单层基线高;deadline 150 ms 不变,当前 P95 112.4 ms 仍在限内。模型 provider 在准备阶段无 I/O、无网络。
|
|
43
44
|
|
|
44
45
|
## 长期序列
|
|
45
46
|
|
|
@@ -49,10 +50,10 @@ npm run bench:evolution # 实验规则/样例/检查点/回执的存储与投
|
|
|
49
50
|
| --- | --- |
|
|
50
51
|
| 项目活跃条目 | 350 |
|
|
51
52
|
| 全局活跃条目 | 200 |
|
|
52
|
-
| `state.json` |
|
|
53
|
+
| `state.json` | 396 KB(上限 2 MiB) |
|
|
53
54
|
| 层内文件 | `state.json`、`state.prev.json` 两个,不随轮数增长 |
|
|
54
|
-
| RSS |
|
|
55
|
-
| 序列耗时 |
|
|
55
|
+
| RSS | 190 MB 量级(含进程内缓存与测试数据) |
|
|
56
|
+
| 序列耗时 | 7.3 s(150 轮全落盘) |
|
|
56
57
|
|
|
57
58
|
## 实验整理成本(0.11.0)
|
|
58
59
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# 0.18.35:召回账本能对上,跨项目写入要人点头
|
|
2
|
+
|
|
3
|
+
- npm 原生凭据命令在执行前拦一次:`npm whoami` / `publish` / `login` / `token` / `config get` 会直接收到已有记忆里那条「发布一律走 dsh-npm」的正文,同一请求只提示一次,`dsh-npm` 自己的命令不受影响。
|
|
4
|
+
- 自动注入只有一份预算:普通卡片与实验规则合计最多 3 条、1200 字节,按整条准入;最终交付文本的字节数和卡片数与账本一致,准备阶段被丢弃、到期撤下的条目不再记使用。
|
|
5
|
+
- 任务状态和展示快照分开:第一步零命中不再丢掉请求身份;之后只有真正的执行动作(写文件、跑命令等)触发一次补查,读文件、`memory_search`/`memory_read` 的输出不花这次机会;换任务后旧动作线索不跟过来。
|
|
6
|
+
- 使用记账推迟到回合收尾批量写,provider 输出路径不碰写锁;回合结束、请求替换和 agent 销毁都不会漏掉最后一轮的使用记录。
|
|
7
|
+
- 跨项目写入只认真人原话:模型写进 `memory_save` 参数里的 `scope` 只是提案,自动整理产物一律留在项目层;只有本轮明确说「存到全局/所有项目/跨项目」才允许落全局,普通说「全局变量」不会误判成授权。
|
|
8
|
+
- 学习条件统一校验:收集阶段带进来的 `taskKind` / `seriesId` 是可信范围,模型新提的条件仍要落在本项目已有栏目或阶段里;同一主题连续纠正收敛到一条现行版,换栏目不适用,关掉再开还是同一现行版。
|
|
9
|
+
- 基准改成真实的跨层召回:200 条项目记忆 + 200 条可信全局记忆,加一条正面场景,确认全局卡片确实进得来且不超预算。
|
|
10
|
+
|
|
11
|
+
验证:`npm test` 全绿;`npm run bench` 在本机固定数据集上通过。
|
package/index.js
CHANGED
|
@@ -1176,10 +1176,20 @@ export function apply(ctx, config) {
|
|
|
1176
1176
|
|
|
1177
1177
|
const agentKey = (agent) => String(agent?.id ?? '');
|
|
1178
1178
|
|
|
1179
|
+
/** 回合收尾/换请求时批量记账:一次锁,不在 provider 输出路径上。 */
|
|
1180
|
+
const flushUsage = (state) => {
|
|
1181
|
+
if (!state || !state.scope || !(state.deliveredIds instanceof Set) || state.deliveredIds.size === 0) return;
|
|
1182
|
+
const ids = [...state.deliveredIds].filter(Boolean);
|
|
1183
|
+
state.deliveredIds = new Set();
|
|
1184
|
+
if (!ids.length) return;
|
|
1185
|
+
try { markUsed(state.scope, ids); } catch { /* usage is best-effort */ }
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1179
1188
|
const clearPrepared = (agent) => {
|
|
1180
1189
|
if (!agent) return;
|
|
1181
1190
|
const state = prepared.get(agent);
|
|
1182
1191
|
if (state && state.signal) signalAgents.delete(state.signal);
|
|
1192
|
+
flushUsage(state);
|
|
1183
1193
|
prepared.delete(agent);
|
|
1184
1194
|
const key = agentKey(agent);
|
|
1185
1195
|
const cleanup = key ? abortCleanups.get(key) : null;
|
|
@@ -1920,8 +1930,11 @@ export function apply(ctx, config) {
|
|
|
1920
1930
|
const now = Date.now();
|
|
1921
1931
|
const live = state.lines.filter((row) => !row.validUntil || now <= Date.parse(String(row.validUntil)));
|
|
1922
1932
|
normalText = live.length ? `${RECALL_HEADER}\n${live.map((row) => row.line).join('\n')}` : '';
|
|
1923
|
-
/**
|
|
1924
|
-
if (live.length && state.scope)
|
|
1933
|
+
/** 真正交付的卡片才记使用;记账推迟到回合收尾,provider 不碰写锁。 */
|
|
1934
|
+
if (live.length && state.scope) {
|
|
1935
|
+
if (!(state.deliveredIds instanceof Set)) state.deliveredIds = new Set();
|
|
1936
|
+
for (const row of live) if (row.id) state.deliveredIds.add(row.id);
|
|
1937
|
+
}
|
|
1925
1938
|
/** 投影时还在、发出时已过期:回执跟着真实输出改,别在面板上说「提醒了 2 条」而正文是空的。 */
|
|
1926
1939
|
if (state.scope && live.length !== state.cards.length) {
|
|
1927
1940
|
noteRecall(state.scope, state.sessionId, {
|
|
@@ -2024,8 +2037,21 @@ export function apply(ctx, config) {
|
|
|
2024
2037
|
const supersedesId = supersedes && !/^mem_[a-z0-9]+$/.test(supersedes)
|
|
2025
2038
|
? (() => { try { return refIdOf(projectScope, { title: supersedes }, '记忆'); } catch { return ''; } })()
|
|
2026
2039
|
: supersedes;
|
|
2027
|
-
/**
|
|
2028
|
-
|
|
2040
|
+
/**
|
|
2041
|
+
* 模型写进参数里的 scope 只是提案:只有本轮真人原话明确说「所有项目/全局」,
|
|
2042
|
+
* 或面板直接确认过,才允许落到全局层。工具写入的正文由模型写,不能冒充用户原话。
|
|
2043
|
+
*/
|
|
2044
|
+
const preparedState = prepared.get(execution && execution.agent);
|
|
2045
|
+
const humanSaid = String(preparedState && preparedState.saidOnly || '');
|
|
2046
|
+
const humanGlobal = humanAskedGlobal(humanSaid);
|
|
2047
|
+
const draft = {
|
|
2048
|
+
...args,
|
|
2049
|
+
writeMode: args.mode || 'patch',
|
|
2050
|
+
origin: 'model_drafted',
|
|
2051
|
+
scopeConfirmed: humanGlobal,
|
|
2052
|
+
requestedScope: args.scope,
|
|
2053
|
+
};
|
|
2054
|
+
if (humanGlobal) draft.scope = 'global'; else delete draft.scope;
|
|
2029
2055
|
if (supersedes) { if (supersedesId) draft.supersedes = supersedesId; else delete draft.supersedes; }
|
|
2030
2056
|
/** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
|
|
2031
2057
|
const updateTitle = String(args.updateTitle || '').trim();
|
|
@@ -2097,8 +2123,9 @@ export function apply(ctx, config) {
|
|
|
2097
2123
|
/** 显式要了全局却被硬冲突挡回项目层时说清原因:否则模型只能看到 scope=project,无从纠。 */
|
|
2098
2124
|
const scopeNote = (() => {
|
|
2099
2125
|
if (args.scope !== 'global' || scope.key === 'global') return '';
|
|
2100
|
-
if (saved.reasonCode === 'machine_bound_scope') return '按本机痕迹留在项目层:正文或依据里写了本机路径/端口,改成别的项目仍成立的通用规则再存全局;只在本机适用的流程可补 machine 条件后存全局';
|
|
2101
|
-
if (saved.reasonCode === 'model_global_project_fact') return '按项目事实留在项目层:换成别的项目就不成立;确实是通用流程就改 memoryClass';
|
|
2126
|
+
if (saved.reasonCode === 'machine_bound_scope' || saved.reasonCode === 'machine_bound_procedure') return '按本机痕迹留在项目层:正文或依据里写了本机路径/端口,改成别的项目仍成立的通用规则再存全局;只在本机适用的流程可补 machine 条件后存全局';
|
|
2127
|
+
if (saved.reasonCode === 'model_global_project_fact' || saved.reasonCode === 'class_project_fact') return '按项目事实留在项目层:换成别的项目就不成立;确实是通用流程就改 memoryClass';
|
|
2128
|
+
if (saved.reasonCode === 'model_class_project' || saved.reasonCode === 'unclassified_project') return '按当前项目保存:模型归类只是提案,要存全局得由用户明确说「所有项目都这样」';
|
|
2102
2129
|
return '';
|
|
2103
2130
|
})();
|
|
2104
2131
|
if (saved.needsResolution) {
|
|
@@ -2617,14 +2644,66 @@ export function apply(ctx, config) {
|
|
|
2617
2644
|
}), 'lite-memory: recall context');
|
|
2618
2645
|
}
|
|
2619
2646
|
|
|
2647
|
+
/**
|
|
2648
|
+
* 只有人在这句话里明确要求「存到全局/所有项目」才算跨项目授权。
|
|
2649
|
+
* 不能因为句子里同时出现「全局」和「保存」就放行:那可能只是在说全局变量。
|
|
2650
|
+
*/
|
|
2651
|
+
const humanAskedGlobal = (said) => {
|
|
2652
|
+
const text = String(said || '');
|
|
2653
|
+
return /(?:存|记|写|放|加|保存|放到|记到|写进).{0,10}(?:全局|所有项目|跨项目)/u.test(text)
|
|
2654
|
+
|| /(?:全局|所有项目|跨项目).{0,10}(?:都|一律|通用|适用|记住)/u.test(text)
|
|
2655
|
+
|| /(?:以后|今后).{0,16}(?:所有项目|每个项目|全局|跨项目)/u.test(text);
|
|
2656
|
+
};
|
|
2657
|
+
|
|
2658
|
+
/**
|
|
2659
|
+
* npm 原生命令最容易把「没登录」误判成「没凭据」:whoami 报 ENEEDAUTH 之后,
|
|
2660
|
+
* 会话常会反复试 login/token,真正该走的 dsh-npm 反而没人提。
|
|
2661
|
+
* 这里只在动作发生前拦一次,把已有记忆正文直接交给模型;同一轮不重复,不产生循环。
|
|
2662
|
+
*/
|
|
2663
|
+
const npmProbeHint = (agent, execution) => {
|
|
2664
|
+
try {
|
|
2665
|
+
const name = String(execution?.name || '');
|
|
2666
|
+
if (!/^(bash|run_command|shell|terminal|exec)$/.test(name)) return undefined;
|
|
2667
|
+
const command = String(execution?.arguments?.command || execution?.arguments?.cmd || execution?.arguments?.input || '');
|
|
2668
|
+
if (!command || command.length > 4000 || !/\bnpm\b/.test(command)) return undefined;
|
|
2669
|
+
if (/dsh-npm/.test(command)) return undefined;
|
|
2670
|
+
if (!/(^|[;&|]\s*)(?:sudo\s+)?npm\s+(?:whoami|publish|login|adduser|add-user|token|config\s+get)\b/.test(command)) return undefined;
|
|
2671
|
+
const held = prepared.get(agent);
|
|
2672
|
+
if (!held || held.npmHinted) return undefined;
|
|
2673
|
+
if (!memoryOnForAgent(agent)) return undefined;
|
|
2674
|
+
const index = loadIndex(globalScope());
|
|
2675
|
+
const row = (index.entries || []).find((entry) => {
|
|
2676
|
+
if (!entry || entry.archived || entry.origin === 'project_evolution') return false;
|
|
2677
|
+
/** 过期/被取代的规则不能再在动作前拦人。 */
|
|
2678
|
+
if (view.lifecycleStatusOf(entry)) return false;
|
|
2679
|
+
if (held.deliveredIds instanceof Set && held.deliveredIds.has(entry.id)) return false;
|
|
2680
|
+
const head = `${entry.title || ''} ${entry.summary || ''} ${(entry.tags || []).join(' ')}`;
|
|
2681
|
+
const body = String(entry.body || '').slice(0, 800);
|
|
2682
|
+
return /npm|dsh-npm/i.test(head)
|
|
2683
|
+
&& /(凭据|登录|token|whoami|发布|publish|ENEEDAUTH|Keychain)/i.test(`${head} ${body}`);
|
|
2684
|
+
});
|
|
2685
|
+
if (!row) return undefined;
|
|
2686
|
+
held.npmHinted = true;
|
|
2687
|
+
/** 这条正文真的交给模型了,按交付记使用。 */
|
|
2688
|
+
if (!(held.deliveredIds instanceof Set)) held.deliveredIds = new Set();
|
|
2689
|
+
if (row.id) held.deliveredIds.add(row.id);
|
|
2690
|
+
const body = String(row.body || row.summary || '').trim().slice(0, 600);
|
|
2691
|
+
return `[lite-memory] 这条 npm 命令先别执行:它容易被登录态误导。已有记忆「${row.title}」:${body}\n按记忆改用 dsh-npm;这条提示同一轮只出现一次,直接继续,不要重复探测凭据。`;
|
|
2692
|
+
} catch {
|
|
2693
|
+
return undefined;
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2696
|
+
|
|
2620
2697
|
/** 执行入口再拦一次:工具若已出现在旧快照里,调用时也不放行。 */
|
|
2621
2698
|
if (typeof ctx.on === 'function') ctx.on('agent/created', (payload) => {
|
|
2622
2699
|
const agent = payload?.agent;
|
|
2623
2700
|
if (!agent?.ctx?.tools) return;
|
|
2624
2701
|
try {
|
|
2625
2702
|
agent.ctx.effect(() => agent.ctx.tools.guard((execution) => {
|
|
2626
|
-
if (
|
|
2627
|
-
|
|
2703
|
+
if (MEMORY_TOOLS.includes(execution?.name)) {
|
|
2704
|
+
return memoryOnForAgent(agent) ? undefined : '本项目记忆已关闭,先在面板打开';
|
|
2705
|
+
}
|
|
2706
|
+
return npmProbeHint(agent, execution);
|
|
2628
2707
|
}), 'lite-memory: tool guard');
|
|
2629
2708
|
} catch { /* older host without guards */ }
|
|
2630
2709
|
syncMemoryTools(agent);
|
|
@@ -2664,6 +2743,7 @@ export function apply(ctx, config) {
|
|
|
2664
2743
|
.filter((message) => message?.source?.kind === 'user');
|
|
2665
2744
|
const fresh = freshHumans(sessionId, humans);
|
|
2666
2745
|
const previous = prepared.get(agent);
|
|
2746
|
+
if (fresh.length > 0 && previous) flushUsage(previous);
|
|
2667
2747
|
const sameSession = Boolean(previous && previous.sessionId === sessionId);
|
|
2668
2748
|
const sessionTask = recallTasks.get(sessionId) || null;
|
|
2669
2749
|
|
|
@@ -2713,24 +2793,31 @@ export function apply(ctx, config) {
|
|
|
2713
2793
|
* 只补一次、共用一个请求额度,补到的卡片追加到已发布快照,超出仍由预算挡。
|
|
2714
2794
|
*/
|
|
2715
2795
|
const stepMessages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
2716
|
-
const
|
|
2717
|
-
const stepActions =
|
|
2796
|
+
const seenIds = previous.seenIds instanceof Set ? previous.seenIds : new Set(previous.seenIds || []);
|
|
2797
|
+
const stepActions = stepMessages.filter((message) => {
|
|
2798
|
+
const id = String(message?.id ?? '');
|
|
2799
|
+
return id ? !seenIds.has(id) : false;
|
|
2800
|
+
});
|
|
2801
|
+
previous.seenIds = new Set([...seenIds, ...stepMessages.map((message) => String(message?.id ?? '')).filter(Boolean)].slice(-64));
|
|
2718
2802
|
previous.seenMessages = stepMessages.length;
|
|
2803
|
+
/** 只读文件、搜索、记忆工具都不算动作;一次请求最多补查一次。 */
|
|
2719
2804
|
const actionQuery = previous.recalledContext && !previous.actionRetried
|
|
2805
|
+
&& view.hasEffectiveAction(stepActions)
|
|
2720
2806
|
? view.actionTerms(stepActions, { limit: 10 }).slice(0, 240) : '';
|
|
2721
2807
|
if (actionQuery) {
|
|
2722
2808
|
previous.actionRetried = true;
|
|
2723
2809
|
const win = windowOf(agent);
|
|
2724
2810
|
if (win && generation === generations.get(agent) && !signal?.aborted) {
|
|
2725
|
-
const
|
|
2726
|
-
const
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
previous.
|
|
2732
|
-
previous.
|
|
2733
|
-
previous.
|
|
2811
|
+
const nextQuery = `${actionQuery} ${previous.query}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
2812
|
+
const retry = projectRecall(scope, nextQuery, previous.recalledContext, win, previous.runtimeHeld || null);
|
|
2813
|
+
if (retry.cards.length) {
|
|
2814
|
+
/** 补查结果整体替换展示快照;账本与正文来自同一次准入。 */
|
|
2815
|
+
previous.text = retry.text;
|
|
2816
|
+
previous.cards = retry.cards;
|
|
2817
|
+
previous.lines = retry.lines;
|
|
2818
|
+
previous.query = nextQuery;
|
|
2819
|
+
previous.matched = retry.matched || retry.cards.length;
|
|
2820
|
+
if (win) win.query = nextQuery;
|
|
2734
2821
|
}
|
|
2735
2822
|
}
|
|
2736
2823
|
}
|
|
@@ -2748,9 +2835,10 @@ export function apply(ctx, config) {
|
|
|
2748
2835
|
} : null;
|
|
2749
2836
|
/** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
|
|
2750
2837
|
const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
|
|
2838
|
+
const continuationHint = view.isContinuationQuery(said) || view.isEllipticalContinuation(said);
|
|
2751
2839
|
/** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
|
|
2752
|
-
const carried =
|
|
2753
|
-
? (previous?.query || sessionTask?.query || '') : '';
|
|
2840
|
+
const carried = continuationHint
|
|
2841
|
+
? (sessionTask?.taskQuery || previous?.query || sessionTask?.query || '') : '';
|
|
2754
2842
|
let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
|
|
2755
2843
|
if (win && query) win.query = query;
|
|
2756
2844
|
|
|
@@ -2800,14 +2888,19 @@ export function apply(ctx, config) {
|
|
|
2800
2888
|
const rememberedTask = lastTask.get(sessionId) || null;
|
|
2801
2889
|
const previousTask = (previous && previous.task) || (sessionTask && sessionTask.task) || rememberedTask;
|
|
2802
2890
|
const previousText = (previous && previous.query) || (sessionTask && sessionTask.query) || '';
|
|
2891
|
+
const previousSaid = (previous && previous.saidOnly) || (sessionTask && sessionTask.saidOnly) || '';
|
|
2892
|
+
const sameQuery = Boolean(previousSaid && said && previousSaid === said);
|
|
2803
2893
|
const sameTask = Boolean(sameSession || sessionTask || rememberedTask)
|
|
2804
2894
|
&& !view.isExplicitSwitch(said, previousText)
|
|
2805
|
-
&& view.sameRecallTask(previousTask, currentTask);
|
|
2895
|
+
&& view.sameRecallTask(previousTask, currentTask, { continuation: continuationHint, sameQuery });
|
|
2806
2896
|
const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
|
|
2807
2897
|
? sessionTask.startMessageId : ids[0];
|
|
2808
2898
|
lastTask.set(sessionId, currentTask);
|
|
2809
2899
|
recallTasks.set(sessionId, {
|
|
2810
2900
|
query,
|
|
2901
|
+
saidOnly: said,
|
|
2902
|
+
/** 当前任务的稳定原话:续说只拼这一份,不把上一轮的动作线索越滚越长。 */
|
|
2903
|
+
taskQuery: sameTask ? (sessionTask?.taskQuery || carried || said) : said,
|
|
2811
2904
|
task: currentTask,
|
|
2812
2905
|
startMessageId: taskStartMessageId,
|
|
2813
2906
|
updatedAt: Date.now(),
|
|
@@ -2837,12 +2930,16 @@ export function apply(ctx, config) {
|
|
|
2837
2930
|
if (win) win.query = query;
|
|
2838
2931
|
}
|
|
2839
2932
|
let snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
|
|
2840
|
-
/**
|
|
2933
|
+
/**
|
|
2934
|
+
* 并了动作线索仍一张卡都没有,才补一次有界查询。
|
|
2935
|
+
* 补到就用补查结果整体替换:两批卡片各量一次会绕过 3 条 / 1200 字节的共用额度。
|
|
2936
|
+
*/
|
|
2841
2937
|
if (!snapshot.cards.length && !carried && sameTask && trail && !trailInQuery) {
|
|
2842
2938
|
const actionQuery = `${trail} ${query}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
2843
2939
|
const retry = projectRecall(scope, actionQuery, recalledContext, win, runtime ? runtime.held : null);
|
|
2844
2940
|
if (retry.cards.length) {
|
|
2845
2941
|
snapshot = retry;
|
|
2942
|
+
query = actionQuery;
|
|
2846
2943
|
if (win) win.query = actionQuery;
|
|
2847
2944
|
}
|
|
2848
2945
|
}
|
|
@@ -2881,12 +2978,10 @@ export function apply(ctx, config) {
|
|
|
2881
2978
|
clearPrepared(agent);
|
|
2882
2979
|
return decision;
|
|
2883
2980
|
}
|
|
2884
|
-
/**
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
return decision;
|
|
2889
|
-
}
|
|
2981
|
+
/**
|
|
2982
|
+
* 零命中也要保留请求状态:这一轮第一步没命中,下一步的动作仍可能触发一次补查。
|
|
2983
|
+
* 展示快照为空即可,不把任务身份和额度窗口一起丢掉。
|
|
2984
|
+
*/
|
|
2890
2985
|
let evolutionEpoch = '';
|
|
2891
2986
|
try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
|
|
2892
2987
|
const state = {
|
|
@@ -2900,6 +2995,7 @@ export function apply(ctx, config) {
|
|
|
2900
2995
|
task: currentTask,
|
|
2901
2996
|
scope,
|
|
2902
2997
|
query,
|
|
2998
|
+
saidOnly: said,
|
|
2903
2999
|
sessionId,
|
|
2904
3000
|
turn,
|
|
2905
3001
|
signal,
|
|
@@ -2909,7 +3005,9 @@ export function apply(ctx, config) {
|
|
|
2909
3005
|
recalledContext,
|
|
2910
3006
|
runtimeHeld: runtime && runtime.held ? runtime.held : null,
|
|
2911
3007
|
seenMessages: Array.isArray(decision.messages) ? decision.messages.length : 0,
|
|
3008
|
+
seenIds: Array.isArray(decision.messages) ? decision.messages.map((message) => String(message?.id ?? '')).filter(Boolean).slice(-64) : [],
|
|
2912
3009
|
actionRetried: false,
|
|
3010
|
+
deliveredIds: new Set(),
|
|
2913
3011
|
at: Date.now(),
|
|
2914
3012
|
};
|
|
2915
3013
|
prepared.set(agent, state);
|
|
@@ -3219,6 +3317,7 @@ export function apply(ctx, config) {
|
|
|
3219
3317
|
windowOf: (agent) => windowOf(agent),
|
|
3220
3318
|
/** 测试专用:CI 冷启动偶尔超过 150ms,逻辑测试不该被耗时上限判失败。 */
|
|
3221
3319
|
setDeadlineMs: (ms) => { if (Number.isFinite(ms) && ms > 0) recallDeadlineMs = ms; },
|
|
3320
|
+
humanAskedGlobal,
|
|
3222
3321
|
};
|
|
3223
3322
|
return () => {
|
|
3224
3323
|
delete globalThis.__liteMemoryRecall;
|
package/learning.js
CHANGED
|
@@ -618,10 +618,6 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
|
|
|
618
618
|
if (!Array.isArray(change.conditions)) return { ok: false, reason: '条件不是列表' };
|
|
619
619
|
conditions = normalizeConditions(change.conditions);
|
|
620
620
|
if (conditions.length !== change.conditions.length) return { ok: false, reason: '条件里有不认的键或空值' };
|
|
621
|
-
for (const condition of conditions) {
|
|
622
|
-
const allowed = conditionValueAllowed(condition, { ranges, existing });
|
|
623
|
-
if (!allowed) return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
|
|
624
|
-
}
|
|
625
621
|
}
|
|
626
622
|
/** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
|
|
627
623
|
const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
|
|
@@ -669,6 +665,17 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
|
|
|
669
665
|
if (conditionKeys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
|
|
670
666
|
conditionKeys.add(row.key);
|
|
671
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* 统一校验最终条件:收集阶段带进来的 taskKind/seriesId 是可信范围,历史范围里没有也允许;
|
|
670
|
+
* 模型新提的条件仍必须落在本项目已有栏目或阶段里。
|
|
671
|
+
*/
|
|
672
|
+
for (const condition of effectiveConditions) {
|
|
673
|
+
const fromCollected = expectedConditions.some((row) => row.key === condition.key && row.value === condition.value);
|
|
674
|
+
if (fromCollected) continue;
|
|
675
|
+
if (!conditionValueAllowed(condition, { ranges, existing })) {
|
|
676
|
+
return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
672
679
|
if (applicability === 'all_in_scope' && effectiveConditions.length) {
|
|
673
680
|
return { ok: false, reason: '全项目规则不能带条件' };
|
|
674
681
|
}
|
package/package.json
CHANGED
package/store.js
CHANGED
|
@@ -2595,16 +2595,16 @@ export function routeScope(draft, options = {}) {
|
|
|
2595
2595
|
requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
|
|
2596
2596
|
};
|
|
2597
2597
|
};
|
|
2598
|
-
/** 人明确点过「就存全局」才算数;模型自己提的
|
|
2598
|
+
/** 人明确点过「就存全局」才算数;模型自己提的 scope 只是提案。 */
|
|
2599
2599
|
const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
|
|
2600
|
+
/** 实验/自动整理的产物永远留在项目层:它们没有「用户要求跨项目」这层依据。 */
|
|
2601
|
+
const autoOrigin = source.origin === 'project_evolution' || source.origin === 'model_drafted';
|
|
2600
2602
|
/** 「本项目里…」是项目事实的措辞,类别写成偏好/流程也不能这样进通用层。 */
|
|
2601
2603
|
const projectBound = projectBoundText(source);
|
|
2602
2604
|
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
2603
|
-
if (explicit) {
|
|
2605
|
+
if (explicit && (humanConfirmed || !autoOrigin)) {
|
|
2604
2606
|
/**
|
|
2605
|
-
*
|
|
2606
|
-
* 所以不能再按模型自荐降级,只有本机痕迹和项目事实两种硬冲突能改落点。
|
|
2607
|
-
* 没显式 scope 时才用 memoryClass 提案,缺可搬依据的流程留在项目层。
|
|
2607
|
+
* 只有人确认过的 scope 才代表「用户这个消息里要的那一层」;模型写进参数的 scope 只是提案。
|
|
2608
2608
|
*/
|
|
2609
2609
|
if (explicit === 'global' && !humanConfirmed) {
|
|
2610
2610
|
if (machineConflict) return fallbackToProject('machine_bound_scope');
|
|
@@ -2631,21 +2631,28 @@ export function routeScope(draft, options = {}) {
|
|
|
2631
2631
|
}
|
|
2632
2632
|
return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
|
|
2633
2633
|
}
|
|
2634
|
+
/**
|
|
2635
|
+
* 自动归类只能决定「它像哪类」,不能决定跨项目;默认落点一律是当前项目。
|
|
2636
|
+
* 具体冲突(本机痕迹、「本项目」措辞)仍按原原因报告,其余全局结论统一降级。
|
|
2637
|
+
*/
|
|
2638
|
+
const globalOutcome = (reasonCode) => (humanConfirmed
|
|
2639
|
+
? { ...base, chosenScope: 'global', reasonCode }
|
|
2640
|
+
: fallbackToProject('model_class_project'));
|
|
2634
2641
|
if (cls === 'personal_preference') {
|
|
2635
2642
|
/** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
|
|
2636
2643
|
if (machineConflict) return fallbackToProject('machine_bound_preference');
|
|
2637
2644
|
/** 说了「本项目」的偏好其实是项目约定,类别写错也不能跨项目。 */
|
|
2638
2645
|
if (projectBound) return fallbackToProject('project_bound_preference');
|
|
2639
|
-
return
|
|
2646
|
+
return globalOutcome('class_personal_preference');
|
|
2640
2647
|
}
|
|
2641
2648
|
if (cls === 'portable_procedure') {
|
|
2642
2649
|
if (machineConflict) return fallbackToProject('machine_bound_procedure');
|
|
2643
2650
|
if (projectBound) return fallbackToProject('project_bound_procedure');
|
|
2644
2651
|
/** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
|
|
2645
|
-
if (envScoped) return
|
|
2652
|
+
if (envScoped) return globalOutcome('machine_scoped_procedure');
|
|
2646
2653
|
/** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
|
|
2647
2654
|
if (!evidence) return fallbackToProject('unbound_evidence');
|
|
2648
|
-
return
|
|
2655
|
+
return globalOutcome('class_portable_procedure');
|
|
2649
2656
|
}
|
|
2650
2657
|
if (projectBound) return fallbackToProject('project_bound_environment');
|
|
2651
2658
|
const conditions = view.normalizeConditions(source.conditions) || [];
|
|
@@ -2658,7 +2665,7 @@ export function routeScope(draft, options = {}) {
|
|
|
2658
2665
|
requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
|
|
2659
2666
|
};
|
|
2660
2667
|
}
|
|
2661
|
-
return
|
|
2668
|
+
return globalOutcome('class_environment_fact');
|
|
2662
2669
|
}
|
|
2663
2670
|
|
|
2664
2671
|
function locateInLayers(projectScope, id) {
|
package/test/contract.test.mjs
CHANGED
|
@@ -377,10 +377,15 @@ assert.equal((await call('/lite-memory/unknown', { cwd })).status, 404);
|
|
|
377
377
|
assert.equal((await call('/lite-memory/keep', {}, { method: 'GET' })).status, 405);
|
|
378
378
|
|
|
379
379
|
// ── global layer ─────────────────────────────────────────────────────────────
|
|
380
|
-
const
|
|
381
|
-
assert.equal(
|
|
382
|
-
assert.
|
|
383
|
-
|
|
380
|
+
const proposedGlobal = await exec('memory_save', { title: '回复语言模型提案', summary: '模型建议回复语言用中文', scope: 'global' });
|
|
381
|
+
assert.equal(proposedGlobal.scope, 'project', '模型自填 scope 只是提案,默认落项目层');
|
|
382
|
+
assert.match(proposedGlobal.scopeNote, /模型归类只是提案/, '降级要能解释');
|
|
383
|
+
const global = store.saveEntry(store.globalScope(), {
|
|
384
|
+
title: '回复用中文', summary: '用户要求回复用中文', body: '回复默认用中文', scopeConfirmed: true,
|
|
385
|
+
});
|
|
386
|
+
assert.ok(global.record, '人明确要全局时才落全局层');
|
|
387
|
+
assert.equal(global.chosenScope, 'global');
|
|
388
|
+
assert.equal(exec === null, false);
|
|
384
389
|
|
|
385
390
|
// 显式写 global 也不再被悄无声息地改层:本机痕迹和项目事实各有一条能解释的降级。
|
|
386
391
|
const machineGlobal = await exec('memory_save', {
|
|
@@ -402,7 +407,7 @@ const factGlobal = await exec('memory_save', {
|
|
|
402
407
|
assert.equal(factGlobal.scope, 'project');
|
|
403
408
|
assert.match(factGlobal.scopeNote, /按项目事实留在项目层/);
|
|
404
409
|
const globalSearch = await exec('memory_search', { query: '回复用中文' });
|
|
405
|
-
assert.
|
|
410
|
+
assert.ok(globalSearch.count >= 1, '全局层条目能被搜到');
|
|
406
411
|
assert.match(render('memory_search', {}, globalSearch), /\[global\] 「/);
|
|
407
412
|
assert.equal(typeof globalThis.__liteMemoryStats, 'function');
|
|
408
413
|
const stats = globalThis.__liteMemoryStats(cwd);
|
|
@@ -1050,10 +1055,9 @@ await defs.get('memory_save').execute(
|
|
|
1050
1055
|
{ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', when: '周四晚上' },
|
|
1051
1056
|
{ agent: ovAgent },
|
|
1052
1057
|
);
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
);
|
|
1058
|
+
store.saveEntry(store.globalScope(), {
|
|
1059
|
+
title: '观察全局', summary: '跨项目通用说明', body: '全局正文。', scopeConfirmed: true,
|
|
1060
|
+
});
|
|
1057
1061
|
const ovOther = await defs.get('memory_save').execute(
|
|
1058
1062
|
{ title: '别的项目', summary: '只在别的项目里', body: '另一层的正文。', tags: ['别的'] },
|
|
1059
1063
|
{ agent: { id: 'session-overview-other', cwd: ovOtherCwd, session: { id: 'session-overview-other', header: { cwd: ovOtherCwd } } } },
|
|
@@ -1215,7 +1219,7 @@ const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confli
|
|
|
1215
1219
|
sessionCwds.set('session-conflict-panel', cfCwd);
|
|
1216
1220
|
sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
|
|
1217
1221
|
const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
|
|
1218
|
-
|
|
1222
|
+
store.saveEntry(store.globalScope(), { title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scopeConfirmed: true });
|
|
1219
1223
|
const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
|
|
1220
1224
|
assert.equal(cfSave.needsResolution, true);
|
|
1221
1225
|
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认前不落库');
|
|
@@ -1276,7 +1280,7 @@ const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-confl
|
|
|
1276
1280
|
sessionCwds.set('session-conflict-global', cfgCwd);
|
|
1277
1281
|
const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
|
|
1278
1282
|
await defs.get('memory_save').execute({ title: '命名规则', summary: '项目里用短横线', body: '短横线。' }, { agent: cfgAgent });
|
|
1279
|
-
const cfgSave =
|
|
1283
|
+
const cfgSave = store.saveEntry(store.scopeOf(cfgCwd), { title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global', scopeConfirmed: true });
|
|
1280
1284
|
assert.equal(cfgSave.needsResolution, true);
|
|
1281
1285
|
const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
|
|
1282
1286
|
assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
|
package/test/crossing.test.mjs
CHANGED
|
@@ -44,12 +44,18 @@ const idsOfPrev = (scope) => {
|
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
// ── S03 范围路由 ────────────────────────────────────────────────────────────
|
|
47
|
-
await test('S03
|
|
47
|
+
await test('S03 个人偏好:模型自动归类留项目层,人明确要全局才落全局', () => {
|
|
48
48
|
const scope = fresh('route-pref');
|
|
49
|
-
const
|
|
49
|
+
const automatic = saveEntry(scope, { title: '回答用中文(自动)', summary: '回复语言偏好', body: '中文、简短(自动)', memoryClass: 'personal_preference' });
|
|
50
|
+
assert.equal(automatic.chosenScope, 'project', '模型归类只是提案,不能自己跨项目');
|
|
51
|
+
assert.equal(automatic.reasonCode, 'model_class_project');
|
|
52
|
+
assert.equal(idsOf(scope).length, 1);
|
|
53
|
+
const saved = saveEntry(scope, {
|
|
54
|
+
title: '回答用中文', summary: '回复语言偏好', body: '中文、简短',
|
|
55
|
+
memoryClass: 'personal_preference', scopeConfirmed: true, resolution: 'distinct',
|
|
56
|
+
});
|
|
50
57
|
assert.equal(saved.chosenScope, 'global');
|
|
51
58
|
assert.equal(saved.reasonCode, 'class_personal_preference');
|
|
52
|
-
assert.equal(idsOf(scope).length, 0);
|
|
53
59
|
assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '回答用中文').length, 1);
|
|
54
60
|
});
|
|
55
61
|
|
|
@@ -100,7 +106,7 @@ await test('S03 设备事实没给设备条件就返回 needsScope,不落盘',
|
|
|
100
106
|
await test('S03 设备事实带条件进全局层且是条件适用', () => {
|
|
101
107
|
const scope = fresh('route-env-ok');
|
|
102
108
|
const saved = saveEntry(scope, {
|
|
103
|
-
title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact',
|
|
109
|
+
title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact', scopeConfirmed: true,
|
|
104
110
|
conditions: [{ key: 'machine', value: 'mac-mini' }],
|
|
105
111
|
});
|
|
106
112
|
assert.equal(saved.chosenScope, 'global');
|
package/test/layer-read.test.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fileURLToPath } from 'node:url'
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
@@ -172,7 +172,7 @@ const globalDraft = store.saveEntry(store.scopeOf(projectB), {
|
|
|
172
172
|
title: 'npm 发布凭据取用与发布后验版',
|
|
173
173
|
summary: 'npm 发布用 account regrevia,发布后轮询 dist-tags 验版',
|
|
174
174
|
body: '发布用 regrevia 身份;发完轮询 dist-tags.latest 判上线。',
|
|
175
|
-
memoryClass: 'personal_preference',
|
|
175
|
+
memoryClass: 'personal_preference', scope: 'global', scopeConfirmed: true,
|
|
176
176
|
});
|
|
177
177
|
assert.equal(globalDraft.chosenScope, 'global');
|
|
178
178
|
const near = await exec('memory_save', {
|
package/test/learning.test.mjs
CHANGED
|
@@ -1408,4 +1408,43 @@ test('L10 整理器规则先稳定排序再取 8', () => {
|
|
|
1408
1408
|
assert.equal(learning.orderOrganizerRules([], { limit: 3 }).length, 0);
|
|
1409
1409
|
});
|
|
1410
1410
|
|
|
1411
|
+
/* 收敛:连续纠正同一主题只留一条现行规则;换栏目不适用;关掉再开还是同一现行版。 */
|
|
1412
|
+
test('B30 连续纠正同一规则收敛到一条现行版', () => {
|
|
1413
|
+
const scope = fresh('converge');
|
|
1414
|
+
enable(scope);
|
|
1415
|
+
const firstEpisode = seedEpisode(scope, { sessionId: 'conv1', messageIds: ['cm1'] });
|
|
1416
|
+
const firstStart = begin(scope, firstEpisode);
|
|
1417
|
+
assert.equal(commitNew(scope, firstEpisode, firstStart, NEW_CHANGE).ok, true);
|
|
1418
|
+
const id = ruleRows(scope)[0].id;
|
|
1419
|
+
const bodies = ['开头两句内给结论。', '开头一句内给结论。'];
|
|
1420
|
+
bodies.forEach((body, index) => {
|
|
1421
|
+
const episode = seedEpisode(scope, { sessionId: `conv${index + 2}`, messageIds: [`cm${index + 2}`] });
|
|
1422
|
+
const started = begin(scope, episode);
|
|
1423
|
+
assert.equal(started.ok, true, started.reason);
|
|
1424
|
+
const result = commitExisting(scope, episode, started, id, {
|
|
1425
|
+
target: 'r0', feedbackType: 'spec_change', currentText: body, evidence: ['u0'],
|
|
1426
|
+
});
|
|
1427
|
+
assert.equal(result.ok, true, result.reason);
|
|
1428
|
+
});
|
|
1429
|
+
const rows = ruleRows(scope);
|
|
1430
|
+
assert.equal(rows.length, 1, '同一主题不堆多份现行规则');
|
|
1431
|
+
const current = rows[0];
|
|
1432
|
+
assert.equal(current.id, id);
|
|
1433
|
+
assert.match(current.body, /一句内给结论/);
|
|
1434
|
+
assert.ok((current.versions || []).length <= 2, '旧版有上限');
|
|
1435
|
+
const wrongColumn = view.eligibility(current, {
|
|
1436
|
+
projectId: scope.key,
|
|
1437
|
+
context: { seriesId: '字幕' },
|
|
1438
|
+
task: { taskKind: 'video', seriesId: '字幕' },
|
|
1439
|
+
}, 'auto');
|
|
1440
|
+
assert.equal(wrongColumn.ok, false, '换栏目不适用');
|
|
1441
|
+
store.setMemoryEnabled(scope, false, { operationId: 'conv-close' });
|
|
1442
|
+
assert.equal(ruleRows(scope).length, 1, '关闭不清成果');
|
|
1443
|
+
store.setMemoryEnabled(scope, true, { operationId: 'conv-open' });
|
|
1444
|
+
const reopened = ruleOf(scope, id);
|
|
1445
|
+
assert.equal(reopened.body, current.body);
|
|
1446
|
+
assert.equal(reopened.revision, current.revision);
|
|
1447
|
+
assert.equal(reopened.learning.state, current.learning.state);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1411
1450
|
console.log(`learning.test.mjs: ${passed} passed`);
|
package/test/recall.test.mjs
CHANGED
|
@@ -355,8 +355,11 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
355
355
|
const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
356
356
|
assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
|
|
357
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 });
|
|
358
361
|
const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
359
|
-
assert.notEqual(delivered.lastUsed, saved.lastUsed, '
|
|
362
|
+
assert.notEqual(delivered.lastUsed, saved.lastUsed, '回合收尾只给真正交付的卡片记使用');
|
|
360
363
|
}
|
|
361
364
|
|
|
362
365
|
// A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
|
|
@@ -659,7 +662,11 @@ console.log('recall.test.mjs: ok');
|
|
|
659
662
|
};
|
|
660
663
|
const second = signal();
|
|
661
664
|
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
|
|
662
|
-
|
|
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, '最终卡片数与预算账本一致');
|
|
663
670
|
}
|
|
664
671
|
|
|
665
672
|
// A25b:短续句「继续优化收敛」没有自己的检索词,要沿用上一句,不能断在上一轮。
|
|
@@ -808,3 +815,163 @@ console.log('recall.test.mjs: ok');
|
|
|
808
815
|
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
|
|
809
816
|
assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
|
|
810
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
|
@@ -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
|
}
|