dsh-lite-memory 0.18.33 → 0.18.34
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/RELEASE-0.18.34.md +11 -0
- package/index.js +60 -10
- package/learning.js +23 -4
- package/package.json +1 -1
- package/store.js +30 -21
- package/test/crossing.test.mjs +25 -0
- package/test/learning.test.mjs +48 -0
- package/test/recall.test.mjs +75 -1
- package/view.js +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# 0.18.34:学到的东西能落下来,续句和审批算同一件事
|
|
2
|
+
|
|
3
|
+
- 首次在空项目里学到的规则不再因为“任务范围不在本项目”整条作废:收集时的任务范围被信任并原样保留,模型自己编的范围仍要校验。
|
|
4
|
+
- 条件按 key 唯一合并:同一个条件键两个值明确判冲突,不再先写成 active、再发现不适用;收集时的阶段只作上下文,不混进适用范围。
|
|
5
|
+
- 待裁决另存不再丢 `selection`、`triggers`、`links`:草稿审阅看到的就是最终会写下的内容。
|
|
6
|
+
- 任务身份独立于召回快照:正常轮次结束后的“继续”仍接上一次任务;上一轮零召回时,“继续”也不会把更早的记忆带回来。
|
|
7
|
+
- 历史线索以任务起始消息为边界:只补本任务内发生的动作,不把上一个任务的工具调用当线索。
|
|
8
|
+
- 工具动作后最多补查一次,且和首次召回共用一个请求额度。
|
|
9
|
+
- 真正交付给模型的卡片才更新 `lastUsed`:准备阶段被丢弃、超出预算、已过期的卡片不再算“用过”。
|
|
10
|
+
- 生命周期先于主题裁决:过期/被取代的条目不再参与同主题分组,不能把现行条目标成冲突。
|
|
11
|
+
- 审批原子化:待裁决的版本检查、另存、出队在同一临界区;旧面板不能批准新草稿,也不能删掉刚更新的待裁决。
|
package/index.js
CHANGED
|
@@ -1157,6 +1157,8 @@ export function apply(ctx, config) {
|
|
|
1157
1157
|
const prepared = new WeakMap();
|
|
1158
1158
|
/** 最近一次请求的任务身份:本轮没注入任何内容时 prepared 会清掉,补查仍需要知道上一轮任务。 */
|
|
1159
1159
|
const lastTask = new Map();
|
|
1160
|
+
/** 任务本身也要独立于 prepared:轮次收尾只清投影,不把「刚才在做什么」一起清掉。 */
|
|
1161
|
+
const recallTasks = new Map();
|
|
1160
1162
|
/** turn-stopping 只回 signal,不带头 agent;这里记 signal → agent 供收尾清理。 */
|
|
1161
1163
|
const signalAgents = new Map();
|
|
1162
1164
|
const generations = new WeakMap();
|
|
@@ -1299,6 +1301,8 @@ export function apply(ctx, config) {
|
|
|
1299
1301
|
try { index = loadIndex(source); } catch { continue; }
|
|
1300
1302
|
for (const item of index.entries) {
|
|
1301
1303
|
if (!item || !item.title || item.status === 'archived') continue;
|
|
1304
|
+
/** 生命周期先于主题裁决:过期/被取代的条目不再参与分组,不能把现行条目标成冲突。 */
|
|
1305
|
+
if (view.lifecycleStatusOf(item)) continue;
|
|
1302
1306
|
/** 实验来源只走任务绑定后的专用读取,不混进普通自动召回。 */
|
|
1303
1307
|
if (item.origin === 'project_evolution') continue;
|
|
1304
1308
|
if (seen.has(item.id)) continue;
|
|
@@ -1359,7 +1363,7 @@ export function apply(ctx, config) {
|
|
|
1359
1363
|
lines.push(line);
|
|
1360
1364
|
cards.push({ id: card.id, revision, scope: card.scope, title: card.title });
|
|
1361
1365
|
/** 卡片行与到期时间一起留到最后输出:中间隔了系统提示组装,不能拿投影时的判断当现在。 */
|
|
1362
|
-
linesMeta.push({ line, validUntil: row.record.validUntil || '' });
|
|
1366
|
+
linesMeta.push({ id: card.id, line, validUntil: row.record.validUntil || '' });
|
|
1363
1367
|
}
|
|
1364
1368
|
const finalText = `${RECALL_HEADER}\n${lines.join('\n')}`;
|
|
1365
1369
|
if (auto) {
|
|
@@ -1916,6 +1920,8 @@ export function apply(ctx, config) {
|
|
|
1916
1920
|
const now = Date.now();
|
|
1917
1921
|
const live = state.lines.filter((row) => !row.validUntil || now <= Date.parse(String(row.validUntil)));
|
|
1918
1922
|
normalText = live.length ? `${RECALL_HEADER}\n${live.map((row) => row.line).join('\n')}` : '';
|
|
1923
|
+
/** 真正交付的卡片才记使用:到期被撤下的、准备后被丢弃的都不算。 */
|
|
1924
|
+
if (live.length && state.scope) markUsed(state.scope, live.map((row) => row.id).filter(Boolean));
|
|
1919
1925
|
/** 投影时还在、发出时已过期:回执跟着真实输出改,别在面板上说「提醒了 2 条」而正文是空的。 */
|
|
1920
1926
|
if (state.scope && live.length !== state.cards.length) {
|
|
1921
1927
|
noteRecall(state.scope, state.sessionId, {
|
|
@@ -2659,6 +2665,7 @@ export function apply(ctx, config) {
|
|
|
2659
2665
|
const fresh = freshHumans(sessionId, humans);
|
|
2660
2666
|
const previous = prepared.get(agent);
|
|
2661
2667
|
const sameSession = Boolean(previous && previous.sessionId === sessionId);
|
|
2668
|
+
const sessionTask = recallTasks.get(sessionId) || null;
|
|
2662
2669
|
|
|
2663
2670
|
if (fresh.length === 0) {
|
|
2664
2671
|
/** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
|
|
@@ -2701,6 +2708,32 @@ export function apply(ctx, config) {
|
|
|
2701
2708
|
}
|
|
2702
2709
|
}
|
|
2703
2710
|
}
|
|
2711
|
+
/**
|
|
2712
|
+
* 工具续步里没有新的真人原话,但刚执行的动作本身就是线索。
|
|
2713
|
+
* 只补一次、共用一个请求额度,补到的卡片追加到已发布快照,超出仍由预算挡。
|
|
2714
|
+
*/
|
|
2715
|
+
const stepMessages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
2716
|
+
const seenAt = Number.isInteger(previous.seenMessages) ? previous.seenMessages : 0;
|
|
2717
|
+
const stepActions = seenAt < stepMessages.length ? stepMessages.slice(seenAt) : [];
|
|
2718
|
+
previous.seenMessages = stepMessages.length;
|
|
2719
|
+
const actionQuery = previous.recalledContext && !previous.actionRetried
|
|
2720
|
+
? view.actionTerms(stepActions, { limit: 10 }).slice(0, 240) : '';
|
|
2721
|
+
if (actionQuery) {
|
|
2722
|
+
previous.actionRetried = true;
|
|
2723
|
+
const win = windowOf(agent);
|
|
2724
|
+
if (win && generation === generations.get(agent) && !signal?.aborted) {
|
|
2725
|
+
const retry = projectRecall(scope, `${actionQuery} ${previous.query}`.trim().slice(0, RECALL_QUERY_CHARS), previous.recalledContext, win, previous.runtimeHeld || null);
|
|
2726
|
+
const known = new Set((previous.cards || []).map((card) => card.id));
|
|
2727
|
+
const added = (retry.cards || []).filter((card) => !known.has(card.id));
|
|
2728
|
+
if (added.length) {
|
|
2729
|
+
const addedIds = new Set(added.map((card) => card.id));
|
|
2730
|
+
const extraLines = (retry.lines || []).filter((row, index) => addedIds.has(retry.cards[index] && retry.cards[index].id));
|
|
2731
|
+
previous.cards = [...(previous.cards || []), ...added];
|
|
2732
|
+
previous.lines = [...(previous.lines || []), ...extraLines];
|
|
2733
|
+
previous.text = `${RECALL_HEADER}\n${previous.lines.map((row) => row.line).join('\n')}`;
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2704
2737
|
return decision;
|
|
2705
2738
|
}
|
|
2706
2739
|
|
|
@@ -2716,9 +2749,8 @@ export function apply(ctx, config) {
|
|
|
2716
2749
|
/** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
|
|
2717
2750
|
const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
|
|
2718
2751
|
/** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
|
|
2719
|
-
const carried =
|
|
2720
|
-
|
|
2721
|
-
? previous.query : '';
|
|
2752
|
+
const carried = (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
|
|
2753
|
+
? (previous?.query || sessionTask?.query || '') : '';
|
|
2722
2754
|
let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
|
|
2723
2755
|
if (win && query) win.query = query;
|
|
2724
2756
|
|
|
@@ -2766,12 +2798,22 @@ export function apply(ctx, config) {
|
|
|
2766
2798
|
episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
|
|
2767
2799
|
};
|
|
2768
2800
|
const rememberedTask = lastTask.get(sessionId) || null;
|
|
2769
|
-
const previousTask = previous && previous.task
|
|
2770
|
-
const
|
|
2771
|
-
|
|
2801
|
+
const previousTask = (previous && previous.task) || (sessionTask && sessionTask.task) || rememberedTask;
|
|
2802
|
+
const previousText = (previous && previous.query) || (sessionTask && sessionTask.query) || '';
|
|
2803
|
+
const sameTask = Boolean(sameSession || sessionTask || rememberedTask)
|
|
2804
|
+
&& !view.isExplicitSwitch(said, previousText)
|
|
2772
2805
|
&& view.sameRecallTask(previousTask, currentTask);
|
|
2806
|
+
const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
|
|
2807
|
+
? sessionTask.startMessageId : ids[0];
|
|
2773
2808
|
lastTask.set(sessionId, currentTask);
|
|
2809
|
+
recallTasks.set(sessionId, {
|
|
2810
|
+
query,
|
|
2811
|
+
task: currentTask,
|
|
2812
|
+
startMessageId: taskStartMessageId,
|
|
2813
|
+
updatedAt: Date.now(),
|
|
2814
|
+
});
|
|
2774
2815
|
while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
|
|
2816
|
+
while (recallTasks.size > 64) recallTasks.delete(recallTasks.keys().next().value);
|
|
2775
2817
|
/**
|
|
2776
2818
|
* 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
|
|
2777
2819
|
* 续句或没有具体动作的短问句,直接把最近的动作线索并进同一份查询:只查一次,不额外占预算。
|
|
@@ -2784,7 +2826,11 @@ export function apply(ctx, config) {
|
|
|
2784
2826
|
}
|
|
2785
2827
|
return messages;
|
|
2786
2828
|
})();
|
|
2787
|
-
|
|
2829
|
+
/** 只拿当前任务开始之后的消息,旧任务的工具痕迹不能混进新任务。 */
|
|
2830
|
+
const taskStart = taskStartMessageId
|
|
2831
|
+
? decision.messages.findIndex((message) => String(message?.id ?? '') === taskStartMessageId) : -1;
|
|
2832
|
+
const tailMessages = sameTask && taskStart >= 0 ? decision.messages.slice(taskStart + 1) : humanTail;
|
|
2833
|
+
const trail = sameTask ? view.actionTerms(tailMessages, { limit: 12 }).slice(0, 240) : '';
|
|
2788
2834
|
const trailInQuery = Boolean(trail) && (Boolean(carried) || !view.hasConcreteAction(said));
|
|
2789
2835
|
if (trailInQuery) {
|
|
2790
2836
|
query = `${query} ${trail}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
@@ -2860,10 +2906,14 @@ export function apply(ctx, config) {
|
|
|
2860
2906
|
fingerprint,
|
|
2861
2907
|
controlEpoch,
|
|
2862
2908
|
evolutionEpoch,
|
|
2909
|
+
recalledContext,
|
|
2910
|
+
runtimeHeld: runtime && runtime.held ? runtime.held : null,
|
|
2911
|
+
seenMessages: Array.isArray(decision.messages) ? decision.messages.length : 0,
|
|
2912
|
+
actionRetried: false,
|
|
2863
2913
|
at: Date.now(),
|
|
2864
2914
|
};
|
|
2865
2915
|
prepared.set(agent, state);
|
|
2866
|
-
|
|
2916
|
+
/** 真正输出给模型时才算使用;准备阶段被丢弃/中止的快照不能改 lastUsed。 */
|
|
2867
2917
|
payloadBytes.set(sessionId, win.bytes);
|
|
2868
2918
|
while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
|
|
2869
2919
|
if (signal) signalAgents.set(signal, agent);
|
|
@@ -3125,7 +3175,7 @@ export function apply(ctx, config) {
|
|
|
3125
3175
|
clearPrepared(agent);
|
|
3126
3176
|
windows.delete(agentKey(agent));
|
|
3127
3177
|
const sessionId = String(agent?.session?.id ?? '');
|
|
3128
|
-
if (sessionId) seenHumans.delete(sessionId);
|
|
3178
|
+
if (sessionId) { seenHumans.delete(sessionId); recallTasks.delete(sessionId); }
|
|
3129
3179
|
});
|
|
3130
3180
|
|
|
3131
3181
|
/**
|
package/learning.js
CHANGED
|
@@ -370,9 +370,11 @@ export function pendingChangeFrom(feedback = {}) {
|
|
|
370
370
|
*/
|
|
371
371
|
const expected = feedback.expectedTarget && typeof feedback.expectedTarget === 'object' ? feedback.expectedTarget : null;
|
|
372
372
|
const taskKind = String(feedback.cue?.taskKind || expected?.taskKind || '').trim();
|
|
373
|
-
|
|
373
|
+
/** 采集阶段是「在哪儿学到」的证据,不是长期适用范围:只把任务身份与系列带进条件。 */
|
|
374
|
+
const collectedConditions = ['taskKind', 'seriesId']
|
|
374
375
|
.filter((key) => String(expected?.[key] || '').trim())
|
|
375
376
|
.map((key) => ({ key, value: String(expected[key]).trim() }));
|
|
377
|
+
const expectedConditions = collectedConditions;
|
|
376
378
|
/**
|
|
377
379
|
* 兜底不重新推导作用域:候选收集时已经固定 expectedTarget 就照抄(只能沿用或收窄),
|
|
378
380
|
* 没有 expectedTarget 的旧调用才退回原句判断。
|
|
@@ -623,19 +625,30 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
|
|
|
623
625
|
}
|
|
624
626
|
/** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
|
|
625
627
|
const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
|
|
626
|
-
|
|
628
|
+
/** 采集阶段是「在哪儿学到」的证据,不是长期适用范围:只把任务身份与系列带进条件。 */
|
|
629
|
+
const collectedConditions = ['taskKind', 'seriesId']
|
|
627
630
|
.filter((key) => String(expected?.[key] || '').trim())
|
|
628
631
|
.map((key) => ({ key, value: String(expected[key]).trim() }));
|
|
632
|
+
const expectedConditions = collectedConditions;
|
|
629
633
|
/** 作品里学到的规范必须带上当时的任务范围:模型想写成全项目时收窄成条件规则,而不是丢掉这条反馈。 */
|
|
630
634
|
const narrowToEpisode = target === 'new' && expected?.scope === 'episode' && expectedConditions.length > 0;
|
|
631
635
|
if (narrowToEpisode) {
|
|
632
636
|
if (applicability === undefined || applicability === 'all_in_scope') applicability = 'conditional';
|
|
633
637
|
const merged = conditions !== undefined ? [...conditions] : [];
|
|
634
638
|
for (const row of expectedConditions) {
|
|
635
|
-
|
|
636
|
-
if (
|
|
639
|
+
const sameKey = merged.find((item) => item.key === row.key);
|
|
640
|
+
if (sameKey) {
|
|
641
|
+
if (sameKey.value !== row.value) return { ok: false, reason: '条件与收集时的任务范围冲突' };
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
/** 采集条件来自已绑定的真实任务,不要求它先出现在历史规则里;模型新提的条件仍走范围校验。 */
|
|
637
645
|
merged.push(row);
|
|
638
646
|
}
|
|
647
|
+
const keys = new Set();
|
|
648
|
+
for (const row of merged) {
|
|
649
|
+
if (keys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
|
|
650
|
+
keys.add(row.key);
|
|
651
|
+
}
|
|
639
652
|
conditions = merged;
|
|
640
653
|
}
|
|
641
654
|
const expectedApplicability = expected
|
|
@@ -650,6 +663,12 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
|
|
|
650
663
|
return { ok: false, reason: '作品规则不能抬成全项目' };
|
|
651
664
|
}
|
|
652
665
|
const effectiveConditions = conditions !== undefined ? conditions : normalizeConditions(existing?.conditions);
|
|
666
|
+
/** 条件键必须唯一:phase=script 与 phase=brief 同时存在,规则会显示生效却永远不适用。 */
|
|
667
|
+
const conditionKeys = new Set();
|
|
668
|
+
for (const row of effectiveConditions) {
|
|
669
|
+
if (conditionKeys.has(row.key)) return { ok: false, reason: '同一条件键给了两个值' };
|
|
670
|
+
conditionKeys.add(row.key);
|
|
671
|
+
}
|
|
653
672
|
if (applicability === 'all_in_scope' && effectiveConditions.length) {
|
|
654
673
|
return { ok: false, reason: '全项目规则不能带条件' };
|
|
655
674
|
}
|
package/package.json
CHANGED
package/store.js
CHANGED
|
@@ -2738,7 +2738,7 @@ function bindSameTopic(projectScope, ref) {
|
|
|
2738
2738
|
const CONFLICT_LIMIT = 20;
|
|
2739
2739
|
|
|
2740
2740
|
/** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
|
|
2741
|
-
const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'topicKey', 'source', 'origin'];
|
|
2741
|
+
const CONFLICT_DRAFT_KEYS = ['title', 'summary', 'body', 'when', 'kind', 'tags', 'conditions', 'applicability', 'selection', 'triggers', 'links', 'topicKey', 'source', 'origin'];
|
|
2742
2742
|
function conflictDraftOf(draft) {
|
|
2743
2743
|
const out = {};
|
|
2744
2744
|
if (!draft || typeof draft !== 'object') return out;
|
|
@@ -2754,6 +2754,10 @@ function conflictDraftOf(draft) {
|
|
|
2754
2754
|
else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
|
|
2755
2755
|
else if (key === 'applicability') out.applicability = value;
|
|
2756
2756
|
else if (key === 'topicKey') out.topicKey = normalizeText(value, 80);
|
|
2757
|
+
/** 适用范围、召回触发词与关联整份重放:经过待裁决不能改变语义。 */
|
|
2758
|
+
else if (key === 'selection') out.selection = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : null;
|
|
2759
|
+
else if (key === 'triggers') out.triggers = Array.isArray(value) ? value.slice(0, 8).map((item) => normalizeText(item, 40)).filter(Boolean) : [];
|
|
2760
|
+
else if (key === 'links') out.links = Array.isArray(value) ? value.slice(0, 3).map((item) => (item && typeof item === 'object' ? { ...item } : null)).filter(Boolean) : [];
|
|
2757
2761
|
else if (key === 'source') out.source = value;
|
|
2758
2762
|
else if (key === 'origin') out.origin = value;
|
|
2759
2763
|
}
|
|
@@ -2821,30 +2825,35 @@ function staleDraft(row, ref) {
|
|
|
2821
2825
|
|
|
2822
2826
|
/** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
|
|
2823
2827
|
export function resolveConflict(scope, ref, options = {}) {
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2828
|
+
/** 版本检查、另存和出队必须在同一临界区:中间被新反馈改写时,旧面板不能批准新草稿。 */
|
|
2829
|
+
return withLocks([scope, globalScope()], () => {
|
|
2830
|
+
const row = conflictRowLocked(scope, ref);
|
|
2831
|
+
if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
|
|
2832
|
+
if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
|
|
2833
|
+
const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
|
|
2834
|
+
if (scope.key === 'global') draft.scope = 'global';
|
|
2835
|
+
else draft.scope = 'project';
|
|
2836
|
+
if (!draft.title) draft.title = row.title;
|
|
2837
|
+
const result = saveEntry(scope, draft);
|
|
2838
|
+
if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
|
|
2839
|
+
if (result && result.needsScope) {
|
|
2840
|
+
const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
|
|
2841
|
+
return { ok: false, reason, result };
|
|
2842
|
+
}
|
|
2843
|
+
clearConflictLocked(scope, { id: row.id });
|
|
2844
|
+
return { ok: true, result, record: result && result.record };
|
|
2845
|
+
});
|
|
2839
2846
|
}
|
|
2840
2847
|
|
|
2841
2848
|
/** 删掉待裁决:用户决定不再另存。 */
|
|
2842
2849
|
export function dropConflict(scope, ref) {
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2850
|
+
return withLocks([scope, globalScope()], () => {
|
|
2851
|
+
const row = conflictRowLocked(scope, ref);
|
|
2852
|
+
if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
|
|
2853
|
+
if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
|
|
2854
|
+
clearConflictLocked(scope, { id: row.id });
|
|
2855
|
+
return { ok: true };
|
|
2856
|
+
});
|
|
2848
2857
|
}
|
|
2849
2858
|
|
|
2850
2859
|
/** 当前层还有几条没处理的冲突。 */
|
package/test/crossing.test.mjs
CHANGED
|
@@ -434,7 +434,32 @@ await test('S12 待裁决留可重放草稿,面板确认另存后才落库', (
|
|
|
434
434
|
assert.equal(pendingConflictsOf(scope), 0);
|
|
435
435
|
assert.equal(loadIndex(scope).entries.length, 1);
|
|
436
436
|
assert.equal(loadIndex(scope).entries[0].body, '项目补充');
|
|
437
|
+
assert.equal(loadIndex(scope).entries[0].when, '本仓库', 'when 经过待裁决不能丢');
|
|
437
438
|
assert.equal(dropConflict(scope, '不存在').ok, false);
|
|
438
439
|
});
|
|
439
440
|
|
|
441
|
+
await test('S12 经过待裁决另存不改变适用范围、触发词和关联', () => {
|
|
442
|
+
const scope = fresh('conflict-semantics');
|
|
443
|
+
const globalOne = saveEntry(globalScope(), { title: '发票抬头规则', summary: '开票信息固定', body: '全局版本', resolution: 'distinct' });
|
|
444
|
+
const linked = saveEntry(globalScope(), { title: '生效核验顺序', summary: '先验规则再验页面', body: '先验规则', resolution: 'distinct' });
|
|
445
|
+
const near = saveEntry(scope, {
|
|
446
|
+
title: '发票抬头规则', summary: '本项目发票只认这套抬头', body: '项目版本',
|
|
447
|
+
selection: { mode: 'complete', artifactKey: 'artifact-invoice-v1' },
|
|
448
|
+
triggers: ['artifact-hookword'],
|
|
449
|
+
links: [{ type: 'related', id: linked.record.id }],
|
|
450
|
+
topicKey: 'invoice',
|
|
451
|
+
});
|
|
452
|
+
assert.equal(near.needsResolution, true);
|
|
453
|
+
const [row] = conflictsOf(scope);
|
|
454
|
+
assert.deepEqual(row.draft.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
|
|
455
|
+
assert.deepEqual(row.draft.triggers, ['artifact-hookword']);
|
|
456
|
+
assert.deepEqual(row.draft.links, [{ type: 'related', id: linked.record.id }]);
|
|
457
|
+
const kept = resolveConflict(scope, { id: row.id, expectedRevision: row.revision });
|
|
458
|
+
assert.equal(kept.ok, true, JSON.stringify(kept));
|
|
459
|
+
const [record] = loadIndex(scope).entries;
|
|
460
|
+
assert.deepEqual(record.selection, { mode: 'complete', artifactKey: 'artifact-invoice-v1', items: [] });
|
|
461
|
+
assert.deepEqual(record.triggers, ['artifact-hookword']);
|
|
462
|
+
assert.deepEqual(record.links, [{ type: 'related', id: linked.record.id }]);
|
|
463
|
+
});
|
|
464
|
+
|
|
440
465
|
console.log(`crossing.test.mjs: ${passed} passed`);
|
package/test/learning.test.mjs
CHANGED
|
@@ -1036,6 +1036,54 @@ test('L03d2 代码任务学到的规则不会在数据库任务里生效', () =>
|
|
|
1036
1036
|
assert.equal(db.reason, 'condition_mismatch');
|
|
1037
1037
|
});
|
|
1038
1038
|
|
|
1039
|
+
test('L03d3 空项目的第一条反馈能带上真实任务范围保存', () => {
|
|
1040
|
+
const parsed = learning.parseOutput(JSON.stringify({
|
|
1041
|
+
changes: [{
|
|
1042
|
+
target: 'new', feedbackType: 'spec_change', topicKey: '前端边框',
|
|
1043
|
+
currentText: '前端页面默认采用细边框', applicability: 'all_in_scope', evidence: ['u0'],
|
|
1044
|
+
}],
|
|
1045
|
+
}), {
|
|
1046
|
+
finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
|
|
1047
|
+
ranges: { taskKinds: [], series: [], projects: ['p'] },
|
|
1048
|
+
expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
|
|
1049
|
+
});
|
|
1050
|
+
assert.equal(parsed.ok, true, parsed.reason);
|
|
1051
|
+
assert.equal(parsed.changes[0].applicability, 'conditional');
|
|
1052
|
+
assert.deepEqual(parsed.changes[0].conditions, [{ key: 'taskKind', value: 'code' }], '采集阶段不该锁死长期适用范围');
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
test('L03d4 模型条件与采集任务范围冲突时拒绝,不生成永不生效的规则', () => {
|
|
1056
|
+
const parsed = learning.parseOutput(JSON.stringify({
|
|
1057
|
+
changes: [{
|
|
1058
|
+
target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
|
|
1059
|
+
currentText: '先跑迁移再改代码', applicability: 'conditional',
|
|
1060
|
+
conditions: [{ key: 'taskKind', value: 'database' }], evidence: ['u0'],
|
|
1061
|
+
}],
|
|
1062
|
+
}), {
|
|
1063
|
+
finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
|
|
1064
|
+
ranges: { ...RANGES, taskKinds: ['code', 'database'] },
|
|
1065
|
+
expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code' },
|
|
1066
|
+
});
|
|
1067
|
+
assert.equal(parsed.ok, false);
|
|
1068
|
+
assert.match(parsed.reason, /冲突/);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
test('L03d5 同一条件键的两个值不能都留下', () => {
|
|
1072
|
+
const parsed = learning.parseOutput(JSON.stringify({
|
|
1073
|
+
changes: [{
|
|
1074
|
+
target: 'new', feedbackType: 'spec_change', topicKey: '阶段冲突',
|
|
1075
|
+
currentText: '只在脚本阶段生效', applicability: 'conditional',
|
|
1076
|
+
conditions: [{ key: 'phase', value: 'script' }, { key: 'phase', value: 'brief' }], evidence: ['u0'],
|
|
1077
|
+
}],
|
|
1078
|
+
}), {
|
|
1079
|
+
finish: { kind: 'stop' }, refs: { feedback: { u0: 'm1' }, rules: {} }, rules: [],
|
|
1080
|
+
ranges: { ...RANGES },
|
|
1081
|
+
expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep1', taskKind: 'code', phase: 'brief' },
|
|
1082
|
+
});
|
|
1083
|
+
assert.equal(parsed.ok, false);
|
|
1084
|
+
assert.match(parsed.reason, /条件/);
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1039
1087
|
test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
|
|
1040
1088
|
const rules = [];
|
|
1041
1089
|
for (let index = 0; index < 32; index += 1) {
|
package/test/recall.test.mjs
CHANGED
|
@@ -94,7 +94,10 @@ const patchState = (scope, id, patch) => {
|
|
|
94
94
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
95
95
|
const entry = raw.payload.entries.find((row) => row.id === id);
|
|
96
96
|
assert.ok(entry, `state 里没有 ${id}`);
|
|
97
|
-
Object.
|
|
97
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
98
|
+
entry[key] = value && typeof value === 'object' && !Array.isArray(value)
|
|
99
|
+
? { ...(entry[key] || {}), ...value } : value;
|
|
100
|
+
}
|
|
98
101
|
raw.checksum = `sha256:${crypto.createHash('sha256').update(JSON.stringify(raw.payload)).digest('hex')}`;
|
|
99
102
|
fs.writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
|
|
100
103
|
};
|
|
@@ -337,6 +340,25 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
337
340
|
assert.equal(bootA.debug.windowOf(agentReads).reads, 8);
|
|
338
341
|
}
|
|
339
342
|
|
|
343
|
+
// A11b:准备阶段不算使用;只有真正交付给模型的卡片才更新 lastUsed。
|
|
344
|
+
{
|
|
345
|
+
const cwdUse = mkdir('delivered');
|
|
346
|
+
const scopeUse = store.scopeOf(cwdUse);
|
|
347
|
+
const only = save(scopeUse, { title: '交付才算用过的记忆', tags: ['交付测试'], summary: '只看不交付不该改变使用时间' });
|
|
348
|
+
const agent = makeAgent('agent-delivered', cwdUse);
|
|
349
|
+
const runtime = boot();
|
|
350
|
+
const sig = signal();
|
|
351
|
+
patchState(scopeUse, only.record.id, { maintenance: { lastUsed: '2020-01-01T00:00:00.000Z' } });
|
|
352
|
+
const saved = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
353
|
+
assert.equal(saved.lastUsed, '2020-01-01T00:00:00.000Z', 'patch 生效');
|
|
354
|
+
await runtime.preStep({ agent, signal: sig, turn: 'u1' }, enter(human('交付测试怎么做')));
|
|
355
|
+
const preparedOnly = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
356
|
+
assert.equal(preparedOnly.lastUsed, saved.lastUsed, '准备但没输出时不能改 lastUsed');
|
|
357
|
+
assert.match(runtime.provider.text({ agent, signal: sig }), /交付才算用过的记忆/);
|
|
358
|
+
const delivered = store.readEntry(scopeUse, only.record.id, { usage: () => false });
|
|
359
|
+
assert.notEqual(delivered.lastUsed, saved.lastUsed, '真正交付后才记使用');
|
|
360
|
+
}
|
|
361
|
+
|
|
340
362
|
// A12:额度状态与冻结快照分开;清掉快照后重准备不重新分配。
|
|
341
363
|
{
|
|
342
364
|
const agent = makeAgent('agent-preserve', cwdA);
|
|
@@ -353,6 +375,58 @@ assert.notEqual(bootA.provider.text({ agent: agentB, signal: signalB }), prepare
|
|
|
353
375
|
assert.equal(win.bytes, used, 'same window does not re-allocate for already provided cards');
|
|
354
376
|
}
|
|
355
377
|
|
|
378
|
+
// A13b:轮次收尾清的是投影,不是任务;下一句「继续」仍要接上上一轮的原话。
|
|
379
|
+
{
|
|
380
|
+
const agent = makeAgent('agent-after-turn', cwdA);
|
|
381
|
+
const first = signal();
|
|
382
|
+
await bootA.preStep({ agent, signal: first, turn: 'n1' }, enter(human('插件改完怎么让它生效?')));
|
|
383
|
+
assert.ok(bootA.provider.text({ agent, signal: first }));
|
|
384
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'n1', signal: first });
|
|
385
|
+
assert.equal(bootA.provider.text({ agent, signal: first }), '');
|
|
386
|
+
const second = signal();
|
|
387
|
+
await bootA.preStep({ agent, signal: second, turn: 'n2' }, enter(human('继续')));
|
|
388
|
+
assert.match(bootA.provider.text({ agent, signal: second }), /插件改完先试热刷新/, '收尾后「继续」仍属原任务');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// A13c:新任务零召回也照样记下新查询;再说「继续」不能把旧任务的记忆带回来。
|
|
392
|
+
{
|
|
393
|
+
const agent = makeAgent('agent-zero-task', cwdA);
|
|
394
|
+
const first = signal();
|
|
395
|
+
await bootA.preStep({ agent, signal: first, turn: 'z1' }, enter(human('插件改完怎么让它生效?')));
|
|
396
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z1', signal: first });
|
|
397
|
+
const second = signal();
|
|
398
|
+
await bootA.preStep({ agent, signal: second, turn: 'z2' }, enter(human('帮我算一下 17 乘以 23 等于多少')));
|
|
399
|
+
bootA.handlers.get('agent/turn-stopping').at(-1)({ turn: 'z2', signal: second });
|
|
400
|
+
const third = signal();
|
|
401
|
+
await bootA.preStep({ agent, signal: third, turn: 'z3' }, enter(human('继续')));
|
|
402
|
+
assert.doesNotMatch(bootA.provider.text({ agent, signal: third }), /插件改完先试热刷新/, '零召回的新任务不能被旧任务续上');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// A13d:工具续步没有新的真人原话,刚执行的动作可以换来一次有界补查。
|
|
406
|
+
{
|
|
407
|
+
const cwdStep = mkdir('tool-step');
|
|
408
|
+
const scopeStep = store.scopeOf(cwdStep);
|
|
409
|
+
save(scopeStep, { title: '记忆插件先看状态再改', tags: ['记忆插件', '排查'], summary: '先看状态文件再动代码' });
|
|
410
|
+
save(scopeStep, { title: '改完 client.js 先重载宿主', tags: ['client.js', '重载'], summary: '普通刷新覆盖不到 client.js' });
|
|
411
|
+
const agent = makeAgent('agent-tool-step', cwdStep);
|
|
412
|
+
const runtime = boot();
|
|
413
|
+
const said = human('记忆插件怎么排查');
|
|
414
|
+
const first = signal();
|
|
415
|
+
await runtime.preStep({ agent, signal: first, turn: 's1' }, enter(said));
|
|
416
|
+
assert.match(runtime.provider.text({ agent, signal: first }), /记忆插件先看状态再改/);
|
|
417
|
+
const toolStep = {
|
|
418
|
+
role: 'assistant',
|
|
419
|
+
id: 'asst-tool-step',
|
|
420
|
+
content: [
|
|
421
|
+
{ type: 'text', text: '我把改动写进文件了。' },
|
|
422
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/step/client.js' } },
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
const second = signal();
|
|
426
|
+
await runtime.preStep({ agent, signal: second, turn: 's1' }, enter(said, toolStep));
|
|
427
|
+
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '工具动作后补查一次');
|
|
428
|
+
}
|
|
429
|
+
|
|
356
430
|
// A14:上一轮注入过的文字不会被当成这一轮的来源;只有合成消息的新 turn 不继承。
|
|
357
431
|
{
|
|
358
432
|
const agent = makeAgent('agent-stale-section', cwdA);
|
package/view.js
CHANGED
|
@@ -741,7 +741,7 @@ export function hasConcreteAction(text) {
|
|
|
741
741
|
* 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
|
|
742
742
|
* 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
|
|
743
743
|
*/
|
|
744
|
-
const SWITCH_MARK = /(
|
|
744
|
+
const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|换任务|先结束|结束刚才|结束上一个|去做|开始做)/u;
|
|
745
745
|
export function isExplicitSwitch(text, previousText = '') {
|
|
746
746
|
const current = plain(text, 400);
|
|
747
747
|
if (!current) return false;
|