dsh-lite-memory 0.18.26 → 0.18.28
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/docs/CONVENTIONS.md +3 -2
- package/docs/RELEASE-0.18.27.md +5 -0
- package/index.js +112 -51
- package/package.json +1 -1
- package/state.js +7 -4
- package/store.js +40 -6
- package/test/contract.test.mjs +58 -0
- package/test/control.test.mjs +5 -0
- package/test/crossing.test.mjs +22 -0
- package/test/evolution-read.test.mjs +36 -0
- package/test/recall.test.mjs +32 -4
- package/test/runtime-context.test.mjs +2 -0
- package/test/state.test.mjs +3 -3
- package/test/view.test.mjs +5 -1
- package/view.js +38 -2
package/docs/CONVENTIONS.md
CHANGED
|
@@ -83,9 +83,10 @@
|
|
|
83
83
|
**范围路由**:模型给的 `scope` 是提案,不是命令。
|
|
84
84
|
|
|
85
85
|
- 显式 `scope="global"` 表示「用户在这一次对话里要求存全局」,只有用户确实这么说才传;模型自己觉得通用不是理由。显式全局不再因为缺少 `evidenceRef` 被降级。
|
|
86
|
-
- 仍然会拦住的两类:设备事实没有条件(`machine` / `targetOs` / `runtime` / `shell`
|
|
86
|
+
- 仍然会拦住的两类:设备事实没有条件(`machine` / `targetOs` / `runtime` / `shell` 至少一个),以及正文或依据里写死本机路径、回环地址、端口却没声明适用环境的「通用流程」——后者换台机器/换个项目就不成立,留在项目层。
|
|
87
|
+
- 声明了上述环境条件的本机流程(例如本机特有的命令包装)可以进全局层:条件不成立的机器上不会拿出来用;命令名/工具名本身不算本机痕迹。
|
|
87
88
|
- `memoryClass="project_fact"` 不因写了 `global` 就变全局:项目事实换个项目本来就不成立。
|
|
88
|
-
- 按类别推导(没有显式 `scope`)保持更严:`portable_procedure` 没有 `evidenceRef
|
|
89
|
+
- 按类别推导(没有显式 `scope`)保持更严:`portable_procedure` 没有 `evidenceRef`、也没有环境条件时落项目层。
|
|
89
90
|
- 每次降级都要能解释:只在降级时返回 `scopeNote` 一行,说明为什么没进全局、怎么改才进得去;正常落全局或落项目不带这行。
|
|
90
91
|
|
|
91
92
|
**限额与腾位**:每层活跃条目软上限 200、硬上限 1000;单条正文 4096 字节;单层总字节 1 MiB。到硬上限时把最久没被读的活跃条目移进归档,并在结果里写明「已归档「标题」腾位」,不拒绝新记忆;单条正文超限只报错、不截断。
|
package/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
globalScope,
|
|
4
4
|
upsert,
|
|
5
5
|
search,
|
|
6
|
+
markUsed,
|
|
6
7
|
readEntry,
|
|
7
8
|
readLinks,
|
|
8
9
|
forget,
|
|
@@ -919,7 +920,39 @@ const FORGET_PARAMS = {
|
|
|
919
920
|
additionalProperties: false,
|
|
920
921
|
};
|
|
921
922
|
|
|
922
|
-
/**
|
|
923
|
+
/**
|
|
924
|
+
* 按标题解析内部身份。对外仍只显示标题,但解析必须能说明「唯一 / 同层歧义 / 不存在」:
|
|
925
|
+
* 同层同名是正常情况(不同前置条件各一条),静默取第一条会读错、写错。
|
|
926
|
+
*/
|
|
927
|
+
function titleHitsIn(target, title, { includeArchived = false } = {}) {
|
|
928
|
+
let index;
|
|
929
|
+
try { index = loadIndex(target); } catch { return []; }
|
|
930
|
+
const rows = includeArchived ? (index.archived || []) : (index.entries || []);
|
|
931
|
+
return rows.filter((entry) => entry && (includeArchived || entry.status !== 'archived')
|
|
932
|
+
&& String(entry.title || '').trim() === title);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function candidateNote(entry) {
|
|
936
|
+
const conditions = Array.isArray(entry.conditions) ? entry.conditions.filter((row) => row && row.key) : [];
|
|
937
|
+
const parts = [];
|
|
938
|
+
if (entry.when) parts.push(String(entry.when));
|
|
939
|
+
if (conditions.length) parts.push(conditions.map((row) => `${row.key}=${row.value}`).join('·'));
|
|
940
|
+
return parts.join(',');
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** 同层同名时把候选条件一起报出来,模型/用户才能决定改哪条或先归档哪条。 */
|
|
944
|
+
function ambiguousTitleError(title, hits, label) {
|
|
945
|
+
const detail = hits.slice(0, 4).map((entry) => {
|
|
946
|
+
const note = candidateNote(entry);
|
|
947
|
+
return note ? `「${title}」(${note})` : `「${title}」`;
|
|
948
|
+
}).join('、');
|
|
949
|
+
return `${label}「${title}」有 ${hits.length} 条同名,无法确定是哪一条:${detail}。先归档其中一条,或换一个能区分的标题再试`;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* 返回 { id } 或抛出:显式层只查那一层;默认先项目后全局(别的项目不参与解析)。
|
|
954
|
+
* 同一层出现多条同名一律报歧义,绝不取第一条。
|
|
955
|
+
*/
|
|
923
956
|
function refIdOf(scope, args, label = '记忆') {
|
|
924
957
|
const id = typeof args.id === 'string' ? args.id.trim() : '';
|
|
925
958
|
if (id) return id;
|
|
@@ -928,34 +961,30 @@ function refIdOf(scope, args, label = '记忆') {
|
|
|
928
961
|
/** 显式指定层时只解析那一层:同名条目不会被顺手改掉,项目层也不会命中全局。 */
|
|
929
962
|
const explicit = args && args.scope === 'global' ? globalScope() : args && args.scope === 'project' ? scope : null;
|
|
930
963
|
const sources = explicit ? [explicit] : runtimeLayers(scope);
|
|
931
|
-
|
|
964
|
+
let crossLayerHit = null;
|
|
932
965
|
for (const target of sources) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
&& String(entry.title || '').trim() === title);
|
|
937
|
-
if (hit) return hit.id;
|
|
966
|
+
const hits = titleHitsIn(target, title);
|
|
967
|
+
if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
|
|
968
|
+
if (hits.length === 1 && !crossLayerHit) crossLayerHit = hits[0];
|
|
938
969
|
}
|
|
970
|
+
if (crossLayerHit) return crossLayerHit.id;
|
|
939
971
|
/** 忘掉已归档的条目也要能按标题解析到;归档不会因此重新参与召回。 */
|
|
940
972
|
for (const target of sources) {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
&& String(entry.title || '').trim() === title);
|
|
945
|
-
if (hit) return hit.id;
|
|
973
|
+
const hits = titleHitsIn(target, title, { includeArchived: true });
|
|
974
|
+
if (hits.length > 1) throw new Error(ambiguousTitleError(title, hits, label));
|
|
975
|
+
if (hits.length === 1) return hits[0].id;
|
|
946
976
|
}
|
|
947
|
-
throw new Error(`没有「${title}
|
|
977
|
+
throw new Error(`没有「${title}」这条${label};先用 memory_search 对一下标题`);
|
|
948
978
|
}
|
|
949
979
|
|
|
950
980
|
/** 同名条目可能分布在项目层和全局层:更新前先报候选,不能先匹配。 */
|
|
951
981
|
function titleCandidates(scope, title) {
|
|
952
982
|
const out = [];
|
|
953
983
|
for (const target of runtimeLayers(scope)) {
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
if (hit) out.push({ id: hit.id, scope: target.key === 'global' ? 'global' : 'project' });
|
|
984
|
+
const hits = titleHitsIn(target, title);
|
|
985
|
+
if (!hits.length) continue;
|
|
986
|
+
if (hits.length > 1) out.push({ ambiguous: true, title, hits, scope: target.key === 'global' ? 'global' : 'project' });
|
|
987
|
+
else out.push({ id: hits[0].id, scope: target.key === 'global' ? 'global' : 'project', note: candidateNote(hits[0]) });
|
|
959
988
|
}
|
|
960
989
|
return out;
|
|
961
990
|
}
|
|
@@ -1073,6 +1102,8 @@ export function apply(ctx, config) {
|
|
|
1073
1102
|
};
|
|
1074
1103
|
/** 已准备好的快照只挂在 Agent 上,弱引用随手回收,不跨会话串。 */
|
|
1075
1104
|
const prepared = new WeakMap();
|
|
1105
|
+
/** 最近一次请求的任务身份:本轮没注入任何内容时 prepared 会清掉,补查仍需要知道上一轮任务。 */
|
|
1106
|
+
const lastTask = new Map();
|
|
1076
1107
|
/** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
|
|
1077
1108
|
const signalAgents = new Map();
|
|
1078
1109
|
const generations = new WeakMap();
|
|
@@ -1273,7 +1304,7 @@ export function apply(ctx, config) {
|
|
|
1273
1304
|
win.recallHeader = true;
|
|
1274
1305
|
}
|
|
1275
1306
|
lines.push(line);
|
|
1276
|
-
cards.push({ revision, scope: card.scope, title: card.title });
|
|
1307
|
+
cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
|
|
1277
1308
|
/** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
|
|
1278
1309
|
linesMeta.push({ line, validUntil: row.record.validUntil || '' });
|
|
1279
1310
|
}
|
|
@@ -1318,8 +1349,9 @@ export function apply(ctx, config) {
|
|
|
1318
1349
|
: waiting ? 'needs_context'
|
|
1319
1350
|
: (gateRuntime && gateRuntime.enabled ? 'no_overlap' : 'needs_context');
|
|
1320
1351
|
const matched = ranked.length || nearMatched;
|
|
1321
|
-
|
|
1322
|
-
return { text:
|
|
1352
|
+
const ids = cards.map((card) => card.id).filter(Boolean);
|
|
1353
|
+
if (!cards.length) return { text: '', cards: [], lines: [], ids, bytes: 0, query, reason, matched };
|
|
1354
|
+
return { text: finalText, cards, lines: linesMeta, ids, bytes: Buffer.byteLength(finalText), query, reason, matched };
|
|
1323
1355
|
}
|
|
1324
1356
|
|
|
1325
1357
|
/* ---------------- 项目自进化:任务锚点、读取决策、上下文解析 ---------------- */
|
|
@@ -1491,21 +1523,31 @@ export function apply(ctx, config) {
|
|
|
1491
1523
|
const unknown = [];
|
|
1492
1524
|
const byTopic = new Map();
|
|
1493
1525
|
let conflict = false;
|
|
1526
|
+
const now = Date.now();
|
|
1527
|
+
const query = held && held.query ? String(held.query) : '';
|
|
1528
|
+
const runtime = { projectId: scope.key, experimental: true, context: context || {}, task: (held && held.task) || {} };
|
|
1494
1529
|
for (const record of index.entries) {
|
|
1495
1530
|
if (record.origin !== 'project_evolution') continue;
|
|
1496
1531
|
if (record.ownerProjectId && record.ownerProjectId !== scope.key) continue;
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1532
|
+
const score = query ? view.matchScore(record, query).score : 0;
|
|
1533
|
+
/** 生命周期、归属、学习状态、适用条件统一走 eligibility:被取代、过期、暂停都不该从实验通道溜进来。 */
|
|
1534
|
+
const usable = view.eligibility(record, runtime, 'auto', now);
|
|
1535
|
+
if (!usable.ok) {
|
|
1536
|
+
if (usable.status === 'needs_context' && usable.reason !== 'condition_mismatch'
|
|
1537
|
+
&& score >= view.MATCH_FLOOR) unknown.push(record);
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* 实验规则的任务条件本身就是相关度:栏目/阶段都对上,说明这条规则就属于当前这一集。
|
|
1542
|
+
* 没有声明条件的规则仍要过词面门,避免一条宽泛规则在所有请求里都出现。
|
|
1543
|
+
*/
|
|
1544
|
+
const conditions = Array.isArray(record.conditions) ? record.conditions.filter((row) => row && row.key) : [];
|
|
1545
|
+
const bound = conditions.length > 0;
|
|
1546
|
+
if (!bound && score < view.MATCH_FLOOR) continue;
|
|
1505
1547
|
const topic = record.topicKey || record.id;
|
|
1506
1548
|
if (byTopic.has(topic) && byTopic.get(topic).body !== record.body) conflict = true;
|
|
1507
1549
|
byTopic.set(topic, record);
|
|
1508
|
-
matched.push(record);
|
|
1550
|
+
matched.push({ record, score: bound ? Math.max(score, view.MATCH_FLOOR) : score });
|
|
1509
1551
|
}
|
|
1510
1552
|
if (conflict) {
|
|
1511
1553
|
return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
|
|
@@ -1520,15 +1562,10 @@ export function apply(ctx, config) {
|
|
|
1520
1562
|
? Math.max(0, view.CARD.bytes - Buffer.byteLength(auto.normalText || '') - 2)
|
|
1521
1563
|
: EXPERIMENT_BYTES;
|
|
1522
1564
|
let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
/** 条件明确且已满足的规则按绑定注入;无条件的全项目规则必须和本轮问题有交集。 */
|
|
1528
|
-
.filter((row) => row.score > 0 || (Array.isArray(row.record.conditions) && row.record.conditions.length > 0))
|
|
1529
|
-
.sort((a, b) => b.score - a.score)
|
|
1530
|
-
.map((row) => row.record)
|
|
1531
|
-
: matched;
|
|
1565
|
+
const relevant = matched
|
|
1566
|
+
.slice()
|
|
1567
|
+
.sort((a, b) => b.score - a.score)
|
|
1568
|
+
.map((row) => row.record);
|
|
1532
1569
|
for (const record of relevant.slice(0, cardCap)) {
|
|
1533
1570
|
const body = String(record.body || '');
|
|
1534
1571
|
const short = body && body.length <= EXPERIMENT_SHORT_CHARS && body.split('\n').length <= 4;
|
|
@@ -1649,6 +1686,7 @@ export function apply(ctx, config) {
|
|
|
1649
1686
|
projectKey: scope.key, sessionId, batchKey, messageIds, query,
|
|
1650
1687
|
context, task, anchor: routed.anchor, ranges, decision, epoch,
|
|
1651
1688
|
experimental: enabled,
|
|
1689
|
+
episodeId: (episode && episode.episodeId) || (routed.anchor && routed.anchor.episodeId) || '',
|
|
1652
1690
|
episodeUpdatedAt: episode ? episode.updatedAt : '', injectedIds: [],
|
|
1653
1691
|
at: Date.now(),
|
|
1654
1692
|
};
|
|
@@ -1889,6 +1927,7 @@ export function apply(ctx, config) {
|
|
|
1889
1927
|
archived: { type: 'string' },
|
|
1890
1928
|
pendingConflicts: { type: 'integer' },
|
|
1891
1929
|
error: { type: 'string' },
|
|
1930
|
+
offCopy: { type: 'string' },
|
|
1892
1931
|
},
|
|
1893
1932
|
required: ['title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
|
|
1894
1933
|
additionalProperties: false,
|
|
@@ -1899,7 +1938,7 @@ export function apply(ctx, config) {
|
|
|
1899
1938
|
? `memory · ${value.error}`
|
|
1900
1939
|
: value.needsScope
|
|
1901
1940
|
? value.reason === 'memory_disabled'
|
|
1902
|
-
? `memory · ${
|
|
1941
|
+
? `memory · ${value.offCopy || '本项目记忆已关闭,先在面板打开'}`
|
|
1903
1942
|
: value.reason === 'unconfirmed_project'
|
|
1904
1943
|
? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
|
|
1905
1944
|
: `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
|
|
@@ -1919,7 +1958,7 @@ export function apply(ctx, config) {
|
|
|
1919
1958
|
return {
|
|
1920
1959
|
title: String(args.title || ''), summary: '', when: '', revision: 0,
|
|
1921
1960
|
merged: false, changed: false, scope: '', needsScope: true, reason: 'memory_disabled',
|
|
1922
|
-
needsResolution: false, similarTitles: '', similar: '', archived: '',
|
|
1961
|
+
needsResolution: false, similarTitles: '', similar: '', archived: '', offCopy: memoryOffCopy(projectScope),
|
|
1923
1962
|
};
|
|
1924
1963
|
}
|
|
1925
1964
|
const supersedes = String(args.supersedes || '').trim();
|
|
@@ -1932,13 +1971,17 @@ export function apply(ctx, config) {
|
|
|
1932
1971
|
/** 更新句柄也用标题:id 只是底层实现,模型抄标题就行。 */
|
|
1933
1972
|
const updateTitle = String(args.updateTitle || '').trim();
|
|
1934
1973
|
if (updateTitle) {
|
|
1935
|
-
const candidates =
|
|
1936
|
-
|
|
1974
|
+
const candidates = titleCandidates(projectScope, updateTitle);
|
|
1975
|
+
const ambiguous = candidates.find((row) => row.ambiguous);
|
|
1976
|
+
/** 同层同名是允许的(不同前置条件各一条),但不能替用户挑第一条改。 */
|
|
1977
|
+
if (ambiguous) return saveFailure(ambiguousTitleError(updateTitle, ambiguous.hits, '记忆'), args);
|
|
1978
|
+
if (!args.scope && candidates.length > 1) {
|
|
1937
1979
|
return saveFailure(`「${updateTitle}」在项目层和全局层各有一条:改全局加 scope="global",改项目加 scope="project"`, args);
|
|
1938
1980
|
}
|
|
1939
|
-
const
|
|
1940
|
-
|
|
1941
|
-
|
|
1981
|
+
const layer = args.scope === 'global' ? 'global' : args.scope === 'project' ? 'project' : '';
|
|
1982
|
+
const picked = layer ? candidates.find((row) => row.scope === layer) : candidates[0];
|
|
1983
|
+
if (!picked) return saveFailure(`没有「${updateTitle}」这条记忆;要新增就不用 updateTitle`, args);
|
|
1984
|
+
draft.id = picked.id;
|
|
1942
1985
|
}
|
|
1943
1986
|
/** 关联目标同样按标题解析成内部 id 再落盘。 */
|
|
1944
1987
|
if (Array.isArray(args.links) && args.links.length) {
|
|
@@ -1946,8 +1989,10 @@ export function apply(ctx, config) {
|
|
|
1946
1989
|
for (const link of args.links) {
|
|
1947
1990
|
const title = typeof link?.title === 'string' ? link.title.trim() : '';
|
|
1948
1991
|
if (!title) return saveFailure('关联要写成 {type, title}:给被关联那条记忆的标题', args);
|
|
1949
|
-
|
|
1950
|
-
|
|
1992
|
+
let targetId = '';
|
|
1993
|
+
try { targetId = refIdOf(projectScope, { title }, '记忆'); } catch (error) {
|
|
1994
|
+
return saveFailure(String(error && error.message ? error.message : `没有「${title}」这条记忆`), args);
|
|
1995
|
+
}
|
|
1951
1996
|
resolved.push({ type: link.type || 'related', id: targetId });
|
|
1952
1997
|
}
|
|
1953
1998
|
draft.links = resolved;
|
|
@@ -2162,6 +2207,8 @@ export function apply(ctx, config) {
|
|
|
2162
2207
|
context,
|
|
2163
2208
|
history: args.history === true,
|
|
2164
2209
|
allowExperimental: resolved.experimental === true,
|
|
2210
|
+
/** 「用过」要等真正给了模型看再记:检索到的前 50 条不是交付。 */
|
|
2211
|
+
markUsed: false,
|
|
2165
2212
|
}).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key)
|
|
2166
2213
|
.filter((item) => item.archived || view.eligibility(item, gateRuntime, 'search').ok);
|
|
2167
2214
|
const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
|
|
@@ -2219,10 +2266,12 @@ export function apply(ctx, config) {
|
|
|
2219
2266
|
win.toolBytes += jsonBytes(value);
|
|
2220
2267
|
win.reads += 1;
|
|
2221
2268
|
recordCitations(scope, sessionIdOf(execution), hits.slice(0, kept));
|
|
2269
|
+
markUsed(scope, hits.slice(0, kept).map((item) => item.id));
|
|
2222
2270
|
return value;
|
|
2223
2271
|
}
|
|
2224
2272
|
const value = valueOf(shown.length);
|
|
2225
2273
|
recordCitations(scope, sessionIdOf(execution), hits);
|
|
2274
|
+
markUsed(scope, hits.map((item) => item.id));
|
|
2226
2275
|
return value;
|
|
2227
2276
|
},
|
|
2228
2277
|
}), 'lite-memory: memory_search');
|
|
@@ -2554,7 +2603,7 @@ export function apply(ctx, config) {
|
|
|
2554
2603
|
.filter((message) => message?.source?.kind === 'user');
|
|
2555
2604
|
const fresh = freshHumans(sessionId, humans);
|
|
2556
2605
|
const previous = prepared.get(agent);
|
|
2557
|
-
const sameSession = previous && previous.sessionId === sessionId;
|
|
2606
|
+
const sameSession = Boolean(previous && previous.sessionId === sessionId);
|
|
2558
2607
|
|
|
2559
2608
|
if (fresh.length === 0) {
|
|
2560
2609
|
/** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
|
|
@@ -2651,7 +2700,6 @@ export function apply(ctx, config) {
|
|
|
2651
2700
|
/** 任务上下文独立于自进化开关:普通记忆按当前任务判适用,实验规则再叠一层可用性。 */
|
|
2652
2701
|
const auto = emptyAutoBudget();
|
|
2653
2702
|
if (win) win.autoBudget = auto;
|
|
2654
|
-
const experiment = runtime ? experimentFor(scope, runtime, win) : null;
|
|
2655
2703
|
const recalledContext = runtime ? runtime.held.context : context;
|
|
2656
2704
|
const remembered = runtime && runtime.enabled
|
|
2657
2705
|
? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
|
|
@@ -2661,7 +2709,16 @@ export function apply(ctx, config) {
|
|
|
2661
2709
|
* 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
|
|
2662
2710
|
* 第一次检索一张卡都没有时,用最近的实词/工具名补一次有界查询,预算仍是同一份。
|
|
2663
2711
|
*/
|
|
2664
|
-
|
|
2712
|
+
const currentTask = {
|
|
2713
|
+
kind: (runtime && runtime.held && runtime.held.task && runtime.held.task.kind) || '',
|
|
2714
|
+
episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
|
|
2715
|
+
};
|
|
2716
|
+
const rememberedTask = lastTask.get(sessionId) || null;
|
|
2717
|
+
const previousTask = previous && previous.task ? previous.task : rememberedTask;
|
|
2718
|
+
const sameTask = Boolean(sameSession || rememberedTask) && view.sameRecallTask(previousTask, currentTask);
|
|
2719
|
+
lastTask.set(sessionId, currentTask);
|
|
2720
|
+
while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
|
|
2721
|
+
if (!snapshot.cards.length && !carried && sameTask) {
|
|
2665
2722
|
const humanTail = (() => {
|
|
2666
2723
|
const messages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
2667
2724
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -2680,6 +2737,8 @@ export function apply(ctx, config) {
|
|
|
2680
2737
|
}
|
|
2681
2738
|
}
|
|
2682
2739
|
}
|
|
2740
|
+
/** 实验规则最后才取预算:普通卡片先占,弱相关实验不能把强相关项目规则挤出去。 */
|
|
2741
|
+
const experiment = runtime ? experimentFor(scope, runtime, win) : null;
|
|
2683
2742
|
noteRecall(scope, sessionId, {
|
|
2684
2743
|
reason: snapshot.reason, matched: snapshot.matched, cards: snapshot.cards.length, chars: snapshot.bytes,
|
|
2685
2744
|
});
|
|
@@ -2729,6 +2788,7 @@ export function apply(ctx, config) {
|
|
|
2729
2788
|
cards: snapshot ? snapshot.cards : [],
|
|
2730
2789
|
lines: snapshot ? snapshot.lines : [],
|
|
2731
2790
|
matched: snapshot ? snapshot.matched : 0,
|
|
2791
|
+
task: currentTask,
|
|
2732
2792
|
scope,
|
|
2733
2793
|
query,
|
|
2734
2794
|
sessionId,
|
|
@@ -2740,6 +2800,7 @@ export function apply(ctx, config) {
|
|
|
2740
2800
|
at: Date.now(),
|
|
2741
2801
|
};
|
|
2742
2802
|
prepared.set(agent, state);
|
|
2803
|
+
if (snapshot && snapshot.ids && snapshot.ids.length) markUsed(scope, snapshot.ids);
|
|
2743
2804
|
payloadBytes.set(sessionId, win.bytes);
|
|
2744
2805
|
while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
|
|
2745
2806
|
if (signal) signalAgents.set(signal, agent);
|
package/package.json
CHANGED
package/state.js
CHANGED
|
@@ -143,8 +143,8 @@ export function applyControl(draft, enabled, mode) {
|
|
|
143
143
|
evolution.changedAt = now;
|
|
144
144
|
/** 默认绑定属于已确认结果;临时锚点和在途观察不属于。 */
|
|
145
145
|
evolution.tasks = { ...emptyTasks(), ...(evolution.tasks && evolution.tasks.defaults ? { defaults: evolution.tasks.defaults } : {}) };
|
|
146
|
-
/**
|
|
147
|
-
evolution.learning =
|
|
146
|
+
/** 额度只增不减:次数、回执和滚动时间戳都留着,关开不能把项目额度洗掉。 */
|
|
147
|
+
evolution.learning = ledger;
|
|
148
148
|
}
|
|
149
149
|
return { changed: true, enabled: next, mode: nextMode, epoch: config.epoch };
|
|
150
150
|
}
|
|
@@ -190,8 +190,11 @@ export function applyEvolution(draft, enabled) {
|
|
|
190
190
|
changedAt: now,
|
|
191
191
|
/** 开与关都让上一轮锚点失效;已经确认的项目默认保留,它不属于临时作品状态。 */
|
|
192
192
|
tasks: { ...emptyTasks(), ...(current.tasks && current.tasks.defaults ? { defaults: current.tasks.defaults } : {}) },
|
|
193
|
-
/**
|
|
194
|
-
learning: {
|
|
193
|
+
/** 只清在途候选与回执;额度账本 recent 是滚动时间戳,开关/重启都得留着。 */
|
|
194
|
+
learning: {
|
|
195
|
+
...normalizeLearning(current.learning),
|
|
196
|
+
recent: (Array.isArray(current.learning && current.learning.recent) ? current.learning.recent : []).slice(-32),
|
|
197
|
+
},
|
|
195
198
|
};
|
|
196
199
|
draft.experimental = { ...(draft.experimental || {}), projectEvolution: config };
|
|
197
200
|
/** 显式关闭自进化:试用转暂停待复核,不删除候选与证据;重新打开后可逐条恢复。 */
|
package/store.js
CHANGED
|
@@ -1587,13 +1587,24 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1587
1587
|
archived: Boolean(row.archived),
|
|
1588
1588
|
}));
|
|
1589
1589
|
});
|
|
1590
|
-
/**
|
|
1591
|
-
|
|
1592
|
-
|
|
1590
|
+
/** 锁外回写:在层锁里再抢同一层的锁会死锁;只给真正会交付给调用方的检索默认记账。 */
|
|
1591
|
+
if (options.markUsed !== false) {
|
|
1592
|
+
for (const row of found) {
|
|
1593
|
+
if (!row.archived) touchUsed(row.source, [row.record.id]);
|
|
1594
|
+
}
|
|
1593
1595
|
}
|
|
1594
1596
|
return found.map((row) => row.record);
|
|
1595
1597
|
}
|
|
1596
1598
|
|
|
1599
|
+
/** 交付后再记账:调用方只把最终真给模型看过的条目交回来。 */
|
|
1600
|
+
export function markUsed(scope, ids) {
|
|
1601
|
+
const wanted = (Array.isArray(ids) ? ids : []).filter((id) => typeof id === 'string' && id);
|
|
1602
|
+
if (!wanted.length) return;
|
|
1603
|
+
for (const source of [scope, globalScope()]) {
|
|
1604
|
+
try { touchUsed(source, wanted); } catch { /* lastUsed 只是提示 */ }
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1597
1608
|
/**
|
|
1598
1609
|
* 只读命中:给自动提示用。不改 hits / lastUsed,不落盘。
|
|
1599
1610
|
* 标题(含旧标题)必须真跟查询有交集,否则长句里零星重合也会冒出提示。
|
|
@@ -2511,11 +2522,27 @@ const MACHINE_BOUND_PATTERNS = [
|
|
|
2511
2522
|
/\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
|
|
2512
2523
|
];
|
|
2513
2524
|
|
|
2514
|
-
function
|
|
2515
|
-
|
|
2525
|
+
function contentText(source) {
|
|
2526
|
+
return [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
|
|
2516
2527
|
.filter((value) => typeof value === 'string' && value)
|
|
2517
2528
|
.join('\n');
|
|
2518
|
-
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function machineBoundText(source) {
|
|
2532
|
+
return MACHINE_BOUND_PATTERNS.some((re) => re.test(contentText(source)));
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
/** 内容自己说了「本项目 / 这个仓库」:模型给的类别标签不能把它抬到全局层。 */
|
|
2536
|
+
const PROJECT_BOUND_PATTERNS = [
|
|
2537
|
+
/本项目|这个项目|当前项目|该项目/,
|
|
2538
|
+
/本仓库|这个仓库|当前仓库/,
|
|
2539
|
+
/本工程|这个工程|当前工程/,
|
|
2540
|
+
/本目录|当前目录/,
|
|
2541
|
+
/本项目里|我们项目/,
|
|
2542
|
+
];
|
|
2543
|
+
|
|
2544
|
+
function projectBoundText(source) {
|
|
2545
|
+
return PROJECT_BOUND_PATTERNS.some((re) => re.test(contentText(source)));
|
|
2519
2546
|
}
|
|
2520
2547
|
|
|
2521
2548
|
/**
|
|
@@ -2556,6 +2583,8 @@ export function routeScope(draft, options = {}) {
|
|
|
2556
2583
|
};
|
|
2557
2584
|
/** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
|
|
2558
2585
|
const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
|
|
2586
|
+
/** 「本项目里…」是项目事实的措辞,类别写成偏好/流程也不能这样进通用层。 */
|
|
2587
|
+
const projectBound = projectBoundText(source);
|
|
2559
2588
|
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
2560
2589
|
if (explicit) {
|
|
2561
2590
|
/**
|
|
@@ -2566,6 +2595,7 @@ export function routeScope(draft, options = {}) {
|
|
|
2566
2595
|
if (explicit === 'global' && !humanConfirmed) {
|
|
2567
2596
|
if (machineConflict) return fallbackToProject('machine_bound_scope');
|
|
2568
2597
|
if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
|
|
2598
|
+
if (projectBound) return fallbackToProject('project_bound_scope');
|
|
2569
2599
|
}
|
|
2570
2600
|
if (explicit === 'project' && !projectReady) {
|
|
2571
2601
|
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
@@ -2590,16 +2620,20 @@ export function routeScope(draft, options = {}) {
|
|
|
2590
2620
|
if (cls === 'personal_preference') {
|
|
2591
2621
|
/** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
|
|
2592
2622
|
if (machineConflict) return fallbackToProject('machine_bound_preference');
|
|
2623
|
+
/** 说了「本项目」的偏好其实是项目约定,类别写错也不能跨项目。 */
|
|
2624
|
+
if (projectBound) return fallbackToProject('project_bound_preference');
|
|
2593
2625
|
return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
|
|
2594
2626
|
}
|
|
2595
2627
|
if (cls === 'portable_procedure') {
|
|
2596
2628
|
if (machineConflict) return fallbackToProject('machine_bound_procedure');
|
|
2629
|
+
if (projectBound) return fallbackToProject('project_bound_procedure');
|
|
2597
2630
|
/** 带适用环境条件的本机流程:条件成立时才用,换台机器自然不生效。 */
|
|
2598
2631
|
if (envScoped) return { ...base, chosenScope: 'global', reasonCode: 'machine_scoped_procedure' };
|
|
2599
2632
|
/** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
|
|
2600
2633
|
if (!evidence) return fallbackToProject('unbound_evidence');
|
|
2601
2634
|
return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
|
|
2602
2635
|
}
|
|
2636
|
+
if (projectBound) return fallbackToProject('project_bound_environment');
|
|
2603
2637
|
const conditions = view.normalizeConditions(source.conditions) || [];
|
|
2604
2638
|
const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
|
|
2605
2639
|
if (!scoped) {
|
package/test/contract.test.mjs
CHANGED
|
@@ -190,6 +190,23 @@ const hitText = render('memory_search', {}, hits);
|
|
|
190
190
|
assert.match(hitText, new RegExp(`^memory · 命中 ${hits.count} 条 · 引用 ${hits.chars} 字\\n- \\[[^\\]]+\\] 「`));
|
|
191
191
|
assert.doesNotMatch(hitText, /mem_[a-z0-9]{6,}/, '检索结果只给标题与适用范围');
|
|
192
192
|
assert.doesNotMatch(hitText, /第 .* 版/);
|
|
193
|
+
/** 用量只在真交付后才记:检索候选(尤其被预算/上限丢掉的那些)不算用过。 */
|
|
194
|
+
const contractScope = store.scopeOf(cwd);
|
|
195
|
+
await exec('memory_save', { title: '用量记账探针', summary: '只在交付后记 lastUsed', kind: 'note' });
|
|
196
|
+
const usedRow = () => store.loadIndex(contractScope).entries.find((item) => item.title === '用量记账探针');
|
|
197
|
+
const oldUse = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
198
|
+
const probeId = usedRow().id;
|
|
199
|
+
state.transact(contractScope, {}, (draft) => {
|
|
200
|
+
const row = draft.entries.find((item) => item.id === probeId);
|
|
201
|
+
row.maintenance.lastUsed = oldUse;
|
|
202
|
+
return { changed: true };
|
|
203
|
+
});
|
|
204
|
+
assert.equal(usedRow().lastUsed, oldUse);
|
|
205
|
+
const silent = store.search(contractScope, '用量记账探针', 50, { markUsed: false });
|
|
206
|
+
assert.ok(silent.length >= 1);
|
|
207
|
+
assert.equal(usedRow().lastUsed, oldUse, '候选检索不记 usage');
|
|
208
|
+
await exec('memory_search', { query: '用量记账探针' });
|
|
209
|
+
assert.notEqual(usedRow().lastUsed, oldUse, '真交付给模型后才记 usage');
|
|
193
210
|
/** 「发版当天」这类自由文本条件没有可信上下文,不能当成适用条目塞给模型。 */
|
|
194
211
|
const miss = await exec('memory_search', { query: '完全没有出现过的主题' });
|
|
195
212
|
assert.equal(miss.count, 0);
|
|
@@ -254,6 +271,41 @@ assert.ok(ambiguousSave.error, '跨层同名要报改哪一层');
|
|
|
254
271
|
assertSchema('memory_save', ambiguousSave);
|
|
255
272
|
assert.match(render('memory_save', {}, ambiguousSave), /^memory · /);
|
|
256
273
|
assert.doesNotMatch(render('memory_save', {}, ambiguousSave), /undefined|saved|merged/);
|
|
274
|
+
/** 同层同名不是「取第一条」:读、忘、更新都必须报歧义,按 id 仍能精确到条。 */
|
|
275
|
+
const twinScope = store.scopeOf(cwd);
|
|
276
|
+
const twinA = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第一条', body: '正文甲', when: '机器甲', writeMode: 'patch' });
|
|
277
|
+
const twinB = store.saveEntry(twinScope, { title: '同层重名探针', summary: '第二条', body: '正文乙', when: '机器乙', writeMode: 'patch' });
|
|
278
|
+
const twinAId = twinA.record?.id ?? twinA.id;
|
|
279
|
+
const twinBId = twinB.record?.id ?? twinB.id;
|
|
280
|
+
assert.notEqual(twinAId, twinBId);
|
|
281
|
+
await assert.rejects(() => exec('memory_read', { title: '同层重名探针' }), /有 2 条同名/);
|
|
282
|
+
await assert.rejects(() => exec('memory_forget', { title: '同层重名探针', mode: 'archive' }), /有 2 条同名/);
|
|
283
|
+
const twinUpdate = await exec('memory_save', { title: '换个标题', summary: '尝试更新同名条目', updateTitle: '同层重名探针' });
|
|
284
|
+
assert.ok(twinUpdate.error, '同层同名更新要报歧义');
|
|
285
|
+
assert.match(twinUpdate.error, /有 2 条同名/);
|
|
286
|
+
assertSchema('memory_save', twinUpdate);
|
|
287
|
+
assert.equal(store.readEntry(twinScope, twinAId).body, '正文甲', '按 id 读不能被同名条目串走');
|
|
288
|
+
assert.equal(store.readEntry(twinScope, twinBId).body, '正文乙');
|
|
289
|
+
|
|
290
|
+
/** 重命名后仍按原 id 定位到同一条;归档后重建同名条目也不能冒领旧 id。 */
|
|
291
|
+
const renameSaved = store.saveEntry(twinScope, { title: '改名前的探针', summary: '旧身份', body: '旧正文', writeMode: 'patch' });
|
|
292
|
+
const renameSeedId = renameSaved.record?.id ?? renameSaved.id;
|
|
293
|
+
const renamed = await exec('memory_save', { title: '改名后的探针', summary: '旧身份', updateTitle: '改名前的探针' });
|
|
294
|
+
assert.equal(renamed.changed, true);
|
|
295
|
+
assert.equal(store.locateByTitle(twinScope, '改名后的探针').record.id, renameSeedId, '改名必须落在同一条记录上');
|
|
296
|
+
const oldIdRead = store.readEntry(twinScope, renameSeedId);
|
|
297
|
+
assert.equal(oldIdRead.title, '改名后的探针');
|
|
298
|
+
assert.equal(oldIdRead.body, '旧正文');
|
|
299
|
+
await exec('memory_forget', { id: renameSeedId, mode: 'archive' });
|
|
300
|
+
const recreated = await exec('memory_save', { title: '改名后的探针', summary: '新身份', body: '新正文' });
|
|
301
|
+
const recreatedId = store.locateByTitle(twinScope, '改名后的探针').record.id;
|
|
302
|
+
assert.notEqual(recreatedId, renameSeedId, '删掉再建是新的身份');
|
|
303
|
+
const staleRead = await exec('memory_read', { id: renameSeedId });
|
|
304
|
+
assert.equal(staleRead.status, 'archived', '旧 id 只能读到旧条目的归档状态');
|
|
305
|
+
assert.equal(staleRead.body, '');
|
|
306
|
+
const freshRead = await exec('memory_read', { id: recreatedId });
|
|
307
|
+
assert.equal(freshRead.body, '新正文');
|
|
308
|
+
|
|
257
309
|
const missingSave = await exec('memory_save', { title: '别的标题', summary: '没有这条', updateTitle: '根本不存在的条目' });
|
|
258
310
|
assert.ok(missingSave.error);
|
|
259
311
|
assertSchema('memory_save', missingSave);
|
|
@@ -460,6 +512,8 @@ const pass = (text, id) => async () => ({
|
|
|
460
512
|
messages: [{ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text }] }],
|
|
461
513
|
});
|
|
462
514
|
const recall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
515
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
516
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
463
517
|
assert.ok(recall, 'recall context registered');
|
|
464
518
|
assert.equal(recall.order, 60);
|
|
465
519
|
assert.equal(recall.interpolate, false);
|
|
@@ -545,6 +599,8 @@ const evoEnter = async () => ({
|
|
|
545
599
|
});
|
|
546
600
|
await preStep({ agent: evoAgent, signal: evoSig, turn: '21' }, evoEnter);
|
|
547
601
|
const evoRecall = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
602
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
603
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
548
604
|
assert.match(evoRecall.text({ agent: evoAgent, signal: evoSig }), /开场三句内给结论/);
|
|
549
605
|
assert.equal(evoRecall.text({ agent: evoAgent, signal: evoSig }).includes('README'), false);
|
|
550
606
|
|
|
@@ -668,6 +724,8 @@ assert.equal(closedTool.changed, false);
|
|
|
668
724
|
assert.equal(closedTool.reason, 'memory_disabled');
|
|
669
725
|
assertSchema('memory_save', closedTool);
|
|
670
726
|
assert.equal(closedTool.id, undefined);
|
|
727
|
+
/** 关闭态的保存回执也要能渲染:不能等用户真去写一条才报 ReferenceError。 */
|
|
728
|
+
assert.match(render('memory_save', {}, closedTool), /^memory · 本项目记忆已关闭/);
|
|
671
729
|
/** 关闭状态下的读取也只报状态,不返回正文。 */
|
|
672
730
|
const closedRead = await defs.get('memory_read').execute({ title: closedSeed.title }, { agent: closedAgent });
|
|
673
731
|
assert.equal(closedRead.status, 'memory_disabled');
|
package/test/control.test.mjs
CHANGED
|
@@ -33,6 +33,8 @@ function boot() {
|
|
|
33
33
|
};
|
|
34
34
|
apply(ctx, { mode: 'adaptive' });
|
|
35
35
|
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
36
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
37
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
36
38
|
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
37
39
|
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
|
38
40
|
return { exec, preStep, text: (agent, signal) => provider.text({ agent, signal }), debug: globalThis.__liteMemoryRecall };
|
|
@@ -286,8 +288,11 @@ const toggle = (scope, enabled) => store.setMemoryEnabled(scope, enabled, { oper
|
|
|
286
288
|
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a1' }).ok, true);
|
|
287
289
|
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a2' }).ok, true);
|
|
288
290
|
assert.equal(store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a3' }).ok, false);
|
|
291
|
+
const ledgerBefore = store.loadIndex(scope).__evolution.learning.recent.length;
|
|
292
|
+
assert.ok(ledgerBefore >= 2, '先有滚动额度记录');
|
|
289
293
|
toggle(scope, false);
|
|
290
294
|
toggle(scope, true);
|
|
295
|
+
assert.ok(store.loadIndex(scope).__evolution.learning.recent.length >= ledgerBefore, '关开不重置滚动额度账本');
|
|
291
296
|
assert.deepEqual(store.taskState(scope).defaults, { ...defaults, confirmedBy: 'user', confirmedAt: '' });
|
|
292
297
|
assert.equal(store.setEvolution(scope, true, { operationId: 'evo-quota-again' }).ok, true);
|
|
293
298
|
const limited = store.beginLearningAttempt(scope, { episodeId: 'ep-preserve', operationId: 'a4' });
|
package/test/crossing.test.mjs
CHANGED
|
@@ -59,6 +59,28 @@ await test('S03 项目事实留在项目层', () => {
|
|
|
59
59
|
assert.equal(idsOf(scope).length, 1);
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
+
await test('S03 模型给的类别标签不能把「本项目」抬进全局层', () => {
|
|
63
|
+
const scope = fresh('route-project-bound');
|
|
64
|
+
const pref = saveEntry(scope, {
|
|
65
|
+
title: '本项目回复用中文',
|
|
66
|
+
summary: '这个项目里回复用中文',
|
|
67
|
+
body: '本项目统一中文、简短',
|
|
68
|
+
memoryClass: 'personal_preference',
|
|
69
|
+
});
|
|
70
|
+
assert.equal(pref.chosenScope, 'project');
|
|
71
|
+
assert.equal(pref.reasonCode, 'project_bound_preference');
|
|
72
|
+
assert.equal(stateOf(globalScope()).entries.filter((row) => row.current.title === '本项目回复用中文').length, 0);
|
|
73
|
+
const proc = saveEntry(scope, {
|
|
74
|
+
title: '本项目构建',
|
|
75
|
+
summary: '这个仓库构建用 pnpm',
|
|
76
|
+
body: '本仓库统一 pnpm build',
|
|
77
|
+
memoryClass: 'portable_procedure',
|
|
78
|
+
evidenceRef: 'README',
|
|
79
|
+
});
|
|
80
|
+
assert.equal(proc.chosenScope, 'project');
|
|
81
|
+
assert.equal(proc.reasonCode, 'project_bound_procedure');
|
|
82
|
+
});
|
|
83
|
+
|
|
62
84
|
await test('S03 设备事实没给设备条件就返回 needsScope,不落盘', () => {
|
|
63
85
|
const scope = fresh('route-env');
|
|
64
86
|
const saved = saveEntry(scope, { title: '本机 Node', summary: '本机 Node 版本', body: 'v24', memoryClass: 'environment_fact' });
|
|
@@ -601,3 +601,39 @@ const scopeOf = (dir) => store.scopeOf(dir);
|
|
|
601
601
|
}
|
|
602
602
|
|
|
603
603
|
console.log('evolution-read.test.mjs: all passed');
|
|
604
|
+
|
|
605
|
+
/* L23:被取代 / 已过期的实验规则不再从实验通道注入正文。 */
|
|
606
|
+
{
|
|
607
|
+
const dir = mkdir('lifecycle');
|
|
608
|
+
const scope = scopeOf(dir);
|
|
609
|
+
const superseded = seedRule(scope, { title: '旧开场规则', body: '旧规则正文:开场先自我介绍。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
610
|
+
const expired = seedRule(scope, { title: '过期规则', body: '过期规则正文:开场念固定slogan。', conditions: [{ key: 'seriesId', value: '口播' }] });
|
|
611
|
+
enable(scope, { seriesId: '口播' });
|
|
612
|
+
state.transact(scope, {}, (draft) => {
|
|
613
|
+
const first = draft.entries.find((entry) => entry.id === superseded);
|
|
614
|
+
first.supersededBy = 'mem_deadbeef';
|
|
615
|
+
const second = draft.entries.find((entry) => entry.id === expired);
|
|
616
|
+
second.validUntil = new Date(Date.now() - 60_000).toISOString();
|
|
617
|
+
return { changed: true };
|
|
618
|
+
});
|
|
619
|
+
const runtime = boot();
|
|
620
|
+
const agent = makeAgent('a-lifecycle', dir);
|
|
621
|
+
const sig = signal();
|
|
622
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期口播开场怎么改')));
|
|
623
|
+
const text = runtime.text(agent, sig);
|
|
624
|
+
assert.equal(text.includes('旧规则正文'), false, '被取代的规则不能注入');
|
|
625
|
+
assert.equal(text.includes('过期规则正文'), false, '过期规则不能注入');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* L24:没有声明条件的实验规则,问题不相关时也不靠「有规则」硬注入。 */
|
|
629
|
+
{
|
|
630
|
+
const dir = mkdir('unbound');
|
|
631
|
+
const scope = scopeOf(dir);
|
|
632
|
+
seedRule(scope, { title: '导出参数', body: '导出统一用 H.264。', conditions: [] });
|
|
633
|
+
enable(scope, { seriesId: '口播' });
|
|
634
|
+
const runtime = boot();
|
|
635
|
+
const agent = makeAgent('a-unbound', dir);
|
|
636
|
+
const sig = signal();
|
|
637
|
+
await runtime.preStep({ agent, signal: sig, turn: 't1' }, enter(human('这期封面标题怎么写')));
|
|
638
|
+
assert.equal(runtime.text(agent, sig).includes('导出统一用 H.264'), false, '无关问题不注入宽泛规则');
|
|
639
|
+
}
|
package/test/recall.test.mjs
CHANGED
|
@@ -39,6 +39,8 @@ function boot(mode = 'adaptive') {
|
|
|
39
39
|
apply(ctx, { mode });
|
|
40
40
|
const debug = globalThis.__liteMemoryRecall;
|
|
41
41
|
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
42
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
43
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
42
44
|
assert.ok(provider, 'recall provider registered');
|
|
43
45
|
assert.equal(provider.order, 60);
|
|
44
46
|
assert.equal(provider.interpolate, false);
|
|
@@ -111,8 +113,8 @@ const linuxOnly = save(scopeA, {
|
|
|
111
113
|
tags: ['插件', '构建'],
|
|
112
114
|
when: '',
|
|
113
115
|
applicability: 'conditional',
|
|
114
|
-
conditions: [{ key: '
|
|
115
|
-
summary: '
|
|
116
|
+
conditions: [{ key: 'machine', value: '别的机器' }],
|
|
117
|
+
summary: '只在另一台机器上跑构建检查',
|
|
116
118
|
});
|
|
117
119
|
const unresolved = save(scopeA, { title: '发布前要检查什么', tags: ['发布', '检查'], when: '发布前', applicability: 'unresolved' });
|
|
118
120
|
const globalHot = save(global, { title: '插件改动后先热刷新', tags: ['插件', '热刷新'], summary: '全局规矩:先热刷新再谈重启' });
|
|
@@ -266,10 +268,10 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
266
268
|
const cwdC = mkdir('c');
|
|
267
269
|
const scopeC = store.scopeOf(cwdC);
|
|
268
270
|
save(scopeC, {
|
|
269
|
-
title: '
|
|
271
|
+
title: '只在另一台机器上跑构建检查',
|
|
270
272
|
tags: ['构建'],
|
|
271
273
|
applicability: 'conditional',
|
|
272
|
-
conditions: [{ key: '
|
|
274
|
+
conditions: [{ key: 'machine', value: '别的机器' }],
|
|
273
275
|
});
|
|
274
276
|
save(scopeC, { title: '发布前检查清单', tags: ['发布'], when: '发布前', applicability: 'unresolved' });
|
|
275
277
|
const agent = makeAgent('agent-cond', cwdC);
|
|
@@ -585,3 +587,29 @@ console.log('recall.test.mjs: ok');
|
|
|
585
587
|
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('这个怎么弄')));
|
|
586
588
|
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
|
|
587
589
|
}
|
|
590
|
+
|
|
591
|
+
// A26:明确换任务后,不能拿上一轮助手的话去补查、把旧任务记忆塞进新任务。
|
|
592
|
+
{
|
|
593
|
+
const cwdSwitch = mkdir('switch');
|
|
594
|
+
const scopeSwitch = store.scopeOf(cwdSwitch);
|
|
595
|
+
save(scopeSwitch, {
|
|
596
|
+
title: '口播开场先给结论',
|
|
597
|
+
tags: ['口播', '开场'],
|
|
598
|
+
summary: '口播开场三句内先给结论,别先铺垫',
|
|
599
|
+
});
|
|
600
|
+
const agent = makeAgent('agent-switch', cwdSwitch);
|
|
601
|
+
const runtime = boot();
|
|
602
|
+
const first = signal();
|
|
603
|
+
await runtime.preStep({ agent, signal: first, turn: 't1' }, enter(human('这期口播开场怎么改')));
|
|
604
|
+
const assistantMessage = {
|
|
605
|
+
role: 'assistant',
|
|
606
|
+
id: 'asst-switch-1',
|
|
607
|
+
content: [
|
|
608
|
+
{ type: 'text', text: '口播开场先给结论,栏目风格保持短句。' },
|
|
609
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/switch/talk.md' } },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
612
|
+
const second = signal();
|
|
613
|
+
await runtime.preStep({ agent, signal: second, turn: 't2' }, enter(assistantMessage, human('开始写 PostgreSQL 迁移脚本,先建表')));
|
|
614
|
+
assert.equal(runtime.provider.text({ agent, signal: second }), '', '换任务后不补查旧任务的动作线索');
|
|
615
|
+
}
|
|
@@ -33,6 +33,8 @@ function boot(mode = 'adaptive') {
|
|
|
33
33
|
};
|
|
34
34
|
apply(ctx, { mode });
|
|
35
35
|
const provider = promptContexts.find((entry) => entry.name === 'lite-memory:recall');
|
|
36
|
+
/* CI 冷启动偶尔超过召回耗时上限,逻辑断言不该被「这轮先不注入」判失败。 */
|
|
37
|
+
globalThis.__liteMemoryRecall?.setDeadlineMs?.(5000);
|
|
36
38
|
assert.ok(provider, 'recall provider registered');
|
|
37
39
|
const exec = (name, args, agent) => defs.get(name).execute(args, { agent });
|
|
38
40
|
const preStep = (payload, decision) => handlers.get('agent/pre-step').at(-1)(payload, async () => decision);
|
package/test/state.test.mjs
CHANGED
|
@@ -633,7 +633,7 @@ test('P0-B 坏 state 且没有 prev:报错给出损坏副本与可执行的恢
|
|
|
633
633
|
assert.equal(fs.readFileSync(stateFile(scope), 'utf8'), broken);
|
|
634
634
|
});
|
|
635
635
|
|
|
636
|
-
test('D05
|
|
636
|
+
test('D05 学习开关关掉再开,不重置已用次数、滚动额度与整理记录', () => {
|
|
637
637
|
const scope = fresh('d05-learning-budget');
|
|
638
638
|
const at = new Date().toISOString();
|
|
639
639
|
transact(scope, {}, (draft) => {
|
|
@@ -647,7 +647,7 @@ test('D05 学习开关关掉再开,不重置已用次数和整理记录', () =
|
|
|
647
647
|
});
|
|
648
648
|
const opened = load(scope).state.experimental.projectEvolution;
|
|
649
649
|
assert.equal(opened.enabled, true);
|
|
650
|
-
assert.deepEqual(opened.learning.recent, [], '
|
|
650
|
+
assert.deepEqual(opened.learning.recent, [at], '滚动额度时间戳要留着,关开不洗额度');
|
|
651
651
|
assert.equal(opened.learning.receipts.length, 1, '打开不能清整理回执');
|
|
652
652
|
assert.equal(opened.learning.attempts.ep_seed.count, 2, '打开不能清已用次数');
|
|
653
653
|
transact(scope, {}, (draft) => state.applyEvolution(draft, false));
|
|
@@ -656,7 +656,7 @@ test('D05 学习开关关掉再开,不重置已用次数和整理记录', () =
|
|
|
656
656
|
assert.equal(again.enabled, true);
|
|
657
657
|
assert.equal(again.learning.receipts.length, 1, '关掉再开不重置整理回执');
|
|
658
658
|
assert.equal(again.learning.attempts.ep_seed.count, 2, '关掉再开不重置已用次数');
|
|
659
|
-
assert.deepEqual(again.learning.recent, []);
|
|
659
|
+
assert.deepEqual(again.learning.recent, [at], '关掉再开也不能洗掉滚动额度');
|
|
660
660
|
});
|
|
661
661
|
|
|
662
662
|
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
package/test/view.test.mjs
CHANGED
|
@@ -70,7 +70,11 @@ test('条件裁决:不把不知道当不存在,也不把未解决当普遍
|
|
|
70
70
|
const record = { id: 'mem_000000000001', applicability: 'conditional', conditions: [{ key: 'targetOs', value: 'macOS' }], when: '' };
|
|
71
71
|
assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'macos' })).state, 'match');
|
|
72
72
|
assert.equal(view.evaluate(record, ctxOf(null, { targetOs: 'linux' })).state, 'mismatch');
|
|
73
|
-
assert.equal(
|
|
73
|
+
assert.equal(ctxOf(null, {}).targetOs, view.hostTargetOs());
|
|
74
|
+
const hostRecord = { ...record, conditions: [{ key: 'targetOs', value: view.hostTargetOs() || 'macos' }] };
|
|
75
|
+
assert.equal(view.evaluate(hostRecord, ctxOf(null, {})).state, 'match');
|
|
76
|
+
const machineOnly = { ...record, conditions: [{ key: 'machine', value: '这台机器' }] };
|
|
77
|
+
assert.equal(view.evaluate(machineOnly, ctxOf(null, {})).state, 'unknown');
|
|
74
78
|
const unresolved = { ...record, applicability: 'unresolved', conditions: [] };
|
|
75
79
|
assert.equal(view.statusOf(unresolved, ctxOf(null, {})), 'needs_context');
|
|
76
80
|
assert.equal(view.canAutoRecall(unresolved, ctxOf(null, {})), false);
|
package/view.js
CHANGED
|
@@ -152,7 +152,8 @@ export function validateApplicability(applicability, conditions, selection, when
|
|
|
152
152
|
export function trustedContext(scope, options = {}) {
|
|
153
153
|
const cwd = typeof options.cwd === 'string' && options.cwd ? options.cwd : (scope && scope.cwd) || '';
|
|
154
154
|
return {
|
|
155
|
-
targetOs
|
|
155
|
+
/** 宿主的真实平台是可信上下文:条件里写 targetOs 的规则本职就是按平台生效。 */
|
|
156
|
+
targetOs: options.targetOs === undefined ? hostTargetOs() : options.targetOs,
|
|
156
157
|
shell: options.shell,
|
|
157
158
|
runtime: options.runtime,
|
|
158
159
|
machine: options.machine,
|
|
@@ -166,6 +167,15 @@ export function trustedContext(scope, options = {}) {
|
|
|
166
167
|
};
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/** 只报归一化后的平台名;未知平台不硬猜,留给条件判 unknown。 */
|
|
171
|
+
export function hostTargetOs() {
|
|
172
|
+
const platform = typeof process !== 'undefined' && process ? process.platform : '';
|
|
173
|
+
if (platform === 'darwin') return 'macos';
|
|
174
|
+
if (platform === 'win32') return 'windows';
|
|
175
|
+
if (platform === 'linux') return 'linux';
|
|
176
|
+
return '';
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
function sameText(left, right) {
|
|
170
180
|
return plain(left, PATH_CHARS).toLowerCase() === plain(right, PATH_CHARS).toLowerCase();
|
|
171
181
|
}
|
|
@@ -700,12 +710,37 @@ export function isContinuationQuery(text) {
|
|
|
700
710
|
return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
|
|
701
711
|
}
|
|
702
712
|
|
|
713
|
+
/**
|
|
714
|
+
* 补查只有在「还是同一个任务」时才成立:任务类型或 episode 变了,上一轮的动作历史属于旧任务,
|
|
715
|
+
* 不能拿它给新任务找记忆。缺任一侧身份都按不同任务处理,宁可少补一次也不串旧话题。
|
|
716
|
+
*/
|
|
717
|
+
export function sameRecallTask(previous, current) {
|
|
718
|
+
if (!previous || !current) return true;
|
|
719
|
+
const kind = (value) => plain(value && value.kind, 40).trim().toLowerCase();
|
|
720
|
+
const episode = (value) => plain(value && value.episodeId, 120).trim();
|
|
721
|
+
/** 两侧都认出了任务类型且不同:明确换任务,上一轮的动作历史不再属于当前请求。 */
|
|
722
|
+
if (kind(previous) && kind(current) && kind(previous) !== kind(current)) return false;
|
|
723
|
+
/** 同类型但是另一个 episode:同类任务的新一轮,也不沿用旧任务的动作线索。 */
|
|
724
|
+
if (kind(previous) && kind(current) && episode(previous) && episode(current)) {
|
|
725
|
+
return episode(previous) === episode(current);
|
|
726
|
+
}
|
|
727
|
+
/** 认不出类型(或只有一侧有)时保留补查:这正是该机制要救的场景。 */
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
|
|
703
731
|
/**
|
|
704
732
|
* 动作侧检索词:用户这句话没提,但「刚才在动什么」往往就写在上一轮助手的话和工具名里。
|
|
705
733
|
* 预防型记忆(先重载再重启、改插件后别用 Cmd+R)约束的是下一步动作,
|
|
706
734
|
* 只拿用户原话查,词面永远在错的一侧。这里只取少量最近的实词和工具名,避免长文稀释命中。
|
|
707
735
|
*/
|
|
708
736
|
const ACTION_NOISE = new Set(['const', 'function', 'return', 'import', 'export', 'from', 'async', 'await', 'true', 'false', 'null', 'undefined', 'string', 'number', 'object']);
|
|
737
|
+
const MEMORY_TOOL = /^memory(?:[_-]|$)/i;
|
|
738
|
+
const MEMORY_TEXT = /^(?:memory ·|已有记忆可能相关|这条记忆|有些实验规则)/u;
|
|
739
|
+
/** 记忆工具的调用与记忆自身的呈现文本不能充当新召回线索:否则记忆会自己触发自己。 */
|
|
740
|
+
function isMemoryNoise(name, text) {
|
|
741
|
+
if (name && MEMORY_TOOL.test(String(name))) return true;
|
|
742
|
+
return Boolean(text && MEMORY_TEXT.test(String(text).trimStart()));
|
|
743
|
+
}
|
|
709
744
|
export function actionTerms(messages, options = {}) {
|
|
710
745
|
if (!Array.isArray(messages)) return '';
|
|
711
746
|
const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 16;
|
|
@@ -724,6 +759,7 @@ export function actionTerms(messages, options = {}) {
|
|
|
724
759
|
for (const part of content) {
|
|
725
760
|
if (!part || typeof part !== 'object') continue;
|
|
726
761
|
if (part.type === 'tool-call' || part.type === 'tool_call') {
|
|
762
|
+
if (isMemoryNoise(part.name, '')) continue;
|
|
727
763
|
if (typeof part.name === 'string') push(part.name);
|
|
728
764
|
const input = part.input || part.arguments || part.args;
|
|
729
765
|
if (input && typeof input === 'object') {
|
|
@@ -735,7 +771,7 @@ export function actionTerms(messages, options = {}) {
|
|
|
735
771
|
}
|
|
736
772
|
continue;
|
|
737
773
|
}
|
|
738
|
-
if (part.type === 'text' && typeof part.text === 'string') text += `${part.text}\n`;
|
|
774
|
+
if (part.type === 'text' && typeof part.text === 'string' && !isMemoryNoise('', part.text)) text += `${part.text}\n`;
|
|
739
775
|
}
|
|
740
776
|
return text;
|
|
741
777
|
};
|