dsh-lite-memory 0.18.32 → 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.33.md +7 -0
- package/docs/RELEASE-0.18.34.md +11 -0
- package/index.js +69 -19
- package/learning.js +33 -2
- package/package.json +1 -1
- package/store.js +37 -24
- package/test/crossing.test.mjs +25 -0
- package/test/learning.test.mjs +78 -2
- package/test/recall.test.mjs +75 -1
- package/test/store.test.mjs +22 -0
- package/view.js +13 -7
|
@@ -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, {
|
|
@@ -2449,6 +2455,8 @@ export function apply(ctx, config) {
|
|
|
2449
2455
|
}
|
|
2450
2456
|
const revision = record.revision || 1;
|
|
2451
2457
|
const body = String(record.body ?? '');
|
|
2458
|
+
/** 空正文只回标题与状态:没有正文可交付,就不占正文额度、不算引用。 */
|
|
2459
|
+
const hasBody = body.trim() !== '';
|
|
2452
2460
|
const value = {
|
|
2453
2461
|
title: record.title,
|
|
2454
2462
|
when: record.when || '',
|
|
@@ -2474,14 +2482,14 @@ export function apply(ctx, config) {
|
|
|
2474
2482
|
return receipt;
|
|
2475
2483
|
}
|
|
2476
2484
|
const bytes = jsonBytes(value);
|
|
2477
|
-
if (!affordableTool(win, bytes, 1)) return { scope: '', status: 'budget_exhausted' };
|
|
2485
|
+
if (!affordableTool(win, bytes, hasBody ? 1 : 0)) return { scope: '', status: 'budget_exhausted' };
|
|
2478
2486
|
win.bytes += bytes;
|
|
2479
2487
|
win.toolBytes += bytes;
|
|
2480
|
-
win.bodies += 1;
|
|
2488
|
+
if (hasBody) win.bodies += 1;
|
|
2481
2489
|
win.reads += 1;
|
|
2482
|
-
win.readBodies.set(record.id, revision);
|
|
2490
|
+
if (hasBody) win.readBodies.set(record.id, revision);
|
|
2483
2491
|
}
|
|
2484
|
-
recordCitations(scope, sessionIdOf(execution), [record]);
|
|
2492
|
+
if (hasBody) recordCitations(scope, sessionIdOf(execution), [record]);
|
|
2485
2493
|
return value;
|
|
2486
2494
|
},
|
|
2487
2495
|
}), 'lite-memory: memory_read');
|
|
@@ -2657,6 +2665,7 @@ export function apply(ctx, config) {
|
|
|
2657
2665
|
const fresh = freshHumans(sessionId, humans);
|
|
2658
2666
|
const previous = prepared.get(agent);
|
|
2659
2667
|
const sameSession = Boolean(previous && previous.sessionId === sessionId);
|
|
2668
|
+
const sessionTask = recallTasks.get(sessionId) || null;
|
|
2660
2669
|
|
|
2661
2670
|
if (fresh.length === 0) {
|
|
2662
2671
|
/** 工具续步:内容冻结,只把快照绑到本步的 signal;盘上版本变了才重投影。 */
|
|
@@ -2699,6 +2708,32 @@ export function apply(ctx, config) {
|
|
|
2699
2708
|
}
|
|
2700
2709
|
}
|
|
2701
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
|
+
}
|
|
2702
2737
|
return decision;
|
|
2703
2738
|
}
|
|
2704
2739
|
|
|
@@ -2714,9 +2749,8 @@ export function apply(ctx, config) {
|
|
|
2714
2749
|
/** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
|
|
2715
2750
|
const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
|
|
2716
2751
|
/** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
|
|
2717
|
-
const carried =
|
|
2718
|
-
|
|
2719
|
-
? previous.query : '';
|
|
2752
|
+
const carried = (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
|
|
2753
|
+
? (previous?.query || sessionTask?.query || '') : '';
|
|
2720
2754
|
let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
|
|
2721
2755
|
if (win && query) win.query = query;
|
|
2722
2756
|
|
|
@@ -2764,12 +2798,22 @@ export function apply(ctx, config) {
|
|
|
2764
2798
|
episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
|
|
2765
2799
|
};
|
|
2766
2800
|
const rememberedTask = lastTask.get(sessionId) || null;
|
|
2767
|
-
const previousTask = previous && previous.task
|
|
2768
|
-
const
|
|
2769
|
-
|
|
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)
|
|
2770
2805
|
&& view.sameRecallTask(previousTask, currentTask);
|
|
2806
|
+
const taskStartMessageId = sameTask && sessionTask && sessionTask.startMessageId
|
|
2807
|
+
? sessionTask.startMessageId : ids[0];
|
|
2771
2808
|
lastTask.set(sessionId, currentTask);
|
|
2809
|
+
recallTasks.set(sessionId, {
|
|
2810
|
+
query,
|
|
2811
|
+
task: currentTask,
|
|
2812
|
+
startMessageId: taskStartMessageId,
|
|
2813
|
+
updatedAt: Date.now(),
|
|
2814
|
+
});
|
|
2772
2815
|
while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
|
|
2816
|
+
while (recallTasks.size > 64) recallTasks.delete(recallTasks.keys().next().value);
|
|
2773
2817
|
/**
|
|
2774
2818
|
* 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
|
|
2775
2819
|
* 续句或没有具体动作的短问句,直接把最近的动作线索并进同一份查询:只查一次,不额外占预算。
|
|
@@ -2782,7 +2826,11 @@ export function apply(ctx, config) {
|
|
|
2782
2826
|
}
|
|
2783
2827
|
return messages;
|
|
2784
2828
|
})();
|
|
2785
|
-
|
|
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) : '';
|
|
2786
2834
|
const trailInQuery = Boolean(trail) && (Boolean(carried) || !view.hasConcreteAction(said));
|
|
2787
2835
|
if (trailInQuery) {
|
|
2788
2836
|
query = `${query} ${trail}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
@@ -2858,10 +2906,14 @@ export function apply(ctx, config) {
|
|
|
2858
2906
|
fingerprint,
|
|
2859
2907
|
controlEpoch,
|
|
2860
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,
|
|
2861
2913
|
at: Date.now(),
|
|
2862
2914
|
};
|
|
2863
2915
|
prepared.set(agent, state);
|
|
2864
|
-
|
|
2916
|
+
/** 真正输出给模型时才算使用;准备阶段被丢弃/中止的快照不能改 lastUsed。 */
|
|
2865
2917
|
payloadBytes.set(sessionId, win.bytes);
|
|
2866
2918
|
while (payloadBytes.size > 64) payloadBytes.delete(payloadBytes.keys().next().value);
|
|
2867
2919
|
if (signal) signalAgents.set(signal, agent);
|
|
@@ -2959,6 +3011,9 @@ export function apply(ctx, config) {
|
|
|
2959
3011
|
const operationId = `learn_${crypto.createHash('sha256')
|
|
2960
3012
|
.update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
|
|
2961
3013
|
.digest('hex').slice(0, 16)}`;
|
|
3014
|
+
/** 没有可用模型就别先扣滚动额度:扣了却发不出调用,额度会白烧一整窗口。 */
|
|
3015
|
+
const llm = getService('llm');
|
|
3016
|
+
if (!llm || typeof llm.prepareCall !== 'function') return;
|
|
2962
3017
|
const begin = beginLearningAttempt(scope, { episodeId: candidate.episodeId, operationId: `${operationId}_start`, now: Date.now() });
|
|
2963
3018
|
if (!begin || begin.ok !== true || begin.replayed) return;
|
|
2964
3019
|
const ranges = rangesOf(scope);
|
|
@@ -2977,11 +3032,6 @@ export function apply(ctx, config) {
|
|
|
2977
3032
|
revision: record.revision || 1,
|
|
2978
3033
|
specDigest: learning.specDigestOf(record),
|
|
2979
3034
|
}));
|
|
2980
|
-
const llm = getService('llm');
|
|
2981
|
-
if (!llm || typeof llm.prepareCall !== 'function') {
|
|
2982
|
-
recordLearningFailure(scope, { operationId: `${operationId}_fail`, episodeId: candidate.episodeId, reason: '宿主没有可用模型', epoch: begin.epoch });
|
|
2983
|
-
return;
|
|
2984
|
-
}
|
|
2985
3035
|
const controller = new AbortController();
|
|
2986
3036
|
learningAborts.set(scope.key, controller);
|
|
2987
3037
|
const deadline = setTimeout(() => { try { controller.abort(); } catch { /* already aborted */ } }, learning.LIMITS.timeoutMs);
|
|
@@ -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,9 +625,32 @@ 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;
|
|
633
|
+
/** 作品里学到的规范必须带上当时的任务范围:模型想写成全项目时收窄成条件规则,而不是丢掉这条反馈。 */
|
|
634
|
+
const narrowToEpisode = target === 'new' && expected?.scope === 'episode' && expectedConditions.length > 0;
|
|
635
|
+
if (narrowToEpisode) {
|
|
636
|
+
if (applicability === undefined || applicability === 'all_in_scope') applicability = 'conditional';
|
|
637
|
+
const merged = conditions !== undefined ? [...conditions] : [];
|
|
638
|
+
for (const row of expectedConditions) {
|
|
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
|
+
/** 采集条件来自已绑定的真实任务,不要求它先出现在历史规则里;模型新提的条件仍走范围校验。 */
|
|
645
|
+
merged.push(row);
|
|
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
|
+
}
|
|
652
|
+
conditions = merged;
|
|
653
|
+
}
|
|
629
654
|
const expectedApplicability = expected
|
|
630
655
|
? (expected.applicability === 'all_in_scope'
|
|
631
656
|
? 'all_in_scope'
|
|
@@ -638,6 +663,12 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
|
|
|
638
663
|
return { ok: false, reason: '作品规则不能抬成全项目' };
|
|
639
664
|
}
|
|
640
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
|
+
}
|
|
641
672
|
if (applicability === 'all_in_scope' && effectiveConditions.length) {
|
|
642
673
|
return { ok: false, reason: '全项目规则不能带条件' };
|
|
643
674
|
}
|
package/package.json
CHANGED
package/store.js
CHANGED
|
@@ -1653,23 +1653,27 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1653
1653
|
if (!record && !archived) continue;
|
|
1654
1654
|
if (!readableRecord(record || archived, options, index)) continue;
|
|
1655
1655
|
const body = readBody(source, id);
|
|
1656
|
+
/** 生命周期先判定:过期/被取代的条目不能再报成现行规则。 */
|
|
1657
|
+
const lifecycle = view.lifecycleStatusOf(record || archived, options.now || Date.now());
|
|
1656
1658
|
return {
|
|
1657
1659
|
source,
|
|
1658
1660
|
archived: Boolean(archived),
|
|
1661
|
+
lifecycle,
|
|
1659
1662
|
record: {
|
|
1660
1663
|
...(record || archived),
|
|
1661
1664
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1662
1665
|
body,
|
|
1663
|
-
status:
|
|
1666
|
+
status: lifecycle || view.statusOf(record, options.context || {}),
|
|
1664
1667
|
},
|
|
1665
1668
|
};
|
|
1666
1669
|
}
|
|
1667
1670
|
return null;
|
|
1668
1671
|
});
|
|
1669
1672
|
if (!found) return null;
|
|
1670
|
-
/**
|
|
1673
|
+
/** 读过才算用过;归档、被取代、过期、空正文或条件不匹配的读取都不算「在用」。 */
|
|
1671
1674
|
const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
|
|
1672
|
-
|
|
1675
|
+
const hasBody = typeof found.record.body === 'string' && found.record.body.trim() !== '';
|
|
1676
|
+
if (!found.archived && !found.lifecycle && hasBody && countsAsUsage) touchUsed(found.source, [id]);
|
|
1673
1677
|
return found.record;
|
|
1674
1678
|
}
|
|
1675
1679
|
|
|
@@ -2734,7 +2738,7 @@ function bindSameTopic(projectScope, ref) {
|
|
|
2734
2738
|
const CONFLICT_LIMIT = 20;
|
|
2735
2739
|
|
|
2736
2740
|
/** 待办里留一份可重放的草稿:面板能直接另存,不用用户回忆刚才想记什么。 */
|
|
2737
|
-
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'];
|
|
2738
2742
|
function conflictDraftOf(draft) {
|
|
2739
2743
|
const out = {};
|
|
2740
2744
|
if (!draft || typeof draft !== 'object') return out;
|
|
@@ -2750,6 +2754,10 @@ function conflictDraftOf(draft) {
|
|
|
2750
2754
|
else if (key === 'conditions') out.conditions = Array.isArray(value) ? value.slice(0, 8) : undefined;
|
|
2751
2755
|
else if (key === 'applicability') out.applicability = value;
|
|
2752
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) : [];
|
|
2753
2761
|
else if (key === 'source') out.source = value;
|
|
2754
2762
|
else if (key === 'origin') out.origin = value;
|
|
2755
2763
|
}
|
|
@@ -2817,30 +2825,35 @@ function staleDraft(row, ref) {
|
|
|
2817
2825
|
|
|
2818
2826
|
/** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
|
|
2819
2827
|
export function resolveConflict(scope, ref, options = {}) {
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
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
|
+
});
|
|
2835
2846
|
}
|
|
2836
2847
|
|
|
2837
2848
|
/** 删掉待裁决:用户决定不再另存。 */
|
|
2838
2849
|
export function dropConflict(scope, ref) {
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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
|
+
});
|
|
2844
2857
|
}
|
|
2845
2858
|
|
|
2846
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
|
@@ -8,6 +8,7 @@ const store = await import('../store.js');
|
|
|
8
8
|
const learning = await import('../learning.js');
|
|
9
9
|
const anchor = await import('../anchor.js');
|
|
10
10
|
const state = await import('../state.js');
|
|
11
|
+
const view = await import('../view.js');
|
|
11
12
|
|
|
12
13
|
let passed = 0;
|
|
13
14
|
const test = (name, fn) => {
|
|
@@ -996,18 +997,93 @@ test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
|
|
|
996
997
|
assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
|
|
997
998
|
|
|
998
999
|
const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
|
|
1000
|
+
/** 作品里学到的规范:想说成全项目时收窄成任务条件,而不是整条作废。 */
|
|
999
1001
|
const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1000
|
-
assert.equal(episodeWide.ok,
|
|
1002
|
+
assert.equal(episodeWide.ok, true, episodeWide.reason);
|
|
1003
|
+
assert.equal(episodeWide.change.applicability, 'conditional');
|
|
1004
|
+
assert.deepEqual(episodeWide.change.conditions, [{ key: 'taskKind', value: 'video' }]);
|
|
1001
1005
|
const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1002
|
-
assert.equal(droppedKind.ok,
|
|
1006
|
+
assert.equal(droppedKind.ok, true, droppedKind.reason);
|
|
1007
|
+
assert.deepEqual(droppedKind.change.conditions, [{ key: 'phase', value: 'script' }, { key: 'taskKind', value: 'video' }], '缺的任务范围自动补回,不能丢');
|
|
1003
1008
|
const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
|
|
1004
1009
|
assert.equal(keptKind.ok, true, keptKind.reason);
|
|
1010
|
+
assert.deepEqual(keptKind.change.conditions, [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }]);
|
|
1005
1011
|
|
|
1006
1012
|
const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
|
|
1007
1013
|
assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
|
|
1008
1014
|
assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
|
|
1009
1015
|
});
|
|
1010
1016
|
|
|
1017
|
+
test('L03d2 代码任务学到的规则不会在数据库任务里生效', () => {
|
|
1018
|
+
const refs = { feedback: { u0: 'm1' }, rules: {} };
|
|
1019
|
+
const parsed = learning.parseOutput(JSON.stringify({
|
|
1020
|
+
changes: [{
|
|
1021
|
+
target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
|
|
1022
|
+
currentText: '先跑迁移再改代码', applicability: 'all_in_scope', evidence: ['u0'],
|
|
1023
|
+
}],
|
|
1024
|
+
}), {
|
|
1025
|
+
finish: { kind: 'stop' }, refs, rules: [], ranges: { ...RANGES, taskKinds: ['code', 'database'] },
|
|
1026
|
+
expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep_code', taskKind: 'code' },
|
|
1027
|
+
});
|
|
1028
|
+
assert.equal(parsed.ok, true, parsed.reason);
|
|
1029
|
+
const change = parsed.changes[0];
|
|
1030
|
+
assert.equal(change.applicability, 'conditional');
|
|
1031
|
+
assert.deepEqual(change.conditions, [{ key: 'taskKind', value: 'code' }]);
|
|
1032
|
+
const code = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'code' } }, 'auto');
|
|
1033
|
+
const db = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'database' } }, 'auto');
|
|
1034
|
+
assert.equal(code.ok, true);
|
|
1035
|
+
assert.equal(db.ok, false);
|
|
1036
|
+
assert.equal(db.reason, 'condition_mismatch');
|
|
1037
|
+
});
|
|
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
|
+
|
|
1011
1087
|
test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
|
|
1012
1088
|
const rules = [];
|
|
1013
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/test/store.test.mjs
CHANGED
|
@@ -162,6 +162,28 @@ test('expired or superseded records do not block a new same-title save', () => {
|
|
|
162
162
|
assert.notEqual(replacement.record.id, second.record.id);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
+
test('expired reads report their lifecycle and empty reads do not count as usage', () => {
|
|
166
|
+
const scope = fresh('lifecycle-read');
|
|
167
|
+
const first = upsert(scope, { title: '过期条目', summary: '已经过了有效期', body: '旧正文' });
|
|
168
|
+
const index = loadIndex(scope);
|
|
169
|
+
index.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
|
|
170
|
+
index.entries.find((row) => row.id === first.record.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
171
|
+
saveIndex(scope, index);
|
|
172
|
+
const expired = readEntry(scope, first.record.id);
|
|
173
|
+
assert.equal(expired.status, 'expired');
|
|
174
|
+
assert.equal(expired.body, '旧正文');
|
|
175
|
+
assert.equal(loadIndex(scope).entries.find((row) => row.id === first.record.id).lastUsed, '2026-01-01T00:00:00.000Z');
|
|
176
|
+
|
|
177
|
+
const empty = upsert(scope, { title: '空正文条目', summary: '只有摘要', body: '' }).record;
|
|
178
|
+
loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
179
|
+
const saved = loadIndex(scope);
|
|
180
|
+
saved.entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
181
|
+
saveIndex(scope, saved);
|
|
182
|
+
const record = readEntry(scope, empty.id);
|
|
183
|
+
assert.equal(record.body, '');
|
|
184
|
+
assert.equal(loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed, '2026-01-01T00:00:00.000Z', '空读不算用过');
|
|
185
|
+
});
|
|
186
|
+
|
|
165
187
|
test('oversized body is rejected, old data untouched', () => {
|
|
166
188
|
const scope = fresh('oversize');
|
|
167
189
|
const big = 'x'.repeat(LIMITS.entryBytes + 1);
|
package/view.js
CHANGED
|
@@ -241,13 +241,19 @@ export function contextWithTask(context = {}, task = {}) {
|
|
|
241
241
|
return out;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (record
|
|
247
|
-
|
|
248
|
-
if (record.supersededBy) return
|
|
244
|
+
/** 生命周期先于适用范围:归档、被取代、过期这三类都不该再算现行规则。 */
|
|
245
|
+
export function lifecycleStatusOf(record, now = Date.now()) {
|
|
246
|
+
if (!record) return 'gone';
|
|
247
|
+
if (record.status === 'archived') return 'archived';
|
|
248
|
+
if (record.supersededBy) return 'superseded';
|
|
249
249
|
const validUntil = Date.parse(record.validUntil || '');
|
|
250
|
-
if (Number.isFinite(validUntil) && validUntil <= now) return
|
|
250
|
+
if (Number.isFinite(validUntil) && validUntil <= now) return 'expired';
|
|
251
|
+
return '';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
|
|
255
|
+
const lifecycle = lifecycleStatusOf(record, now);
|
|
256
|
+
if (lifecycle) return { ok: false, status: 'gone', reason: lifecycle };
|
|
251
257
|
const pid = runtime && runtime.projectId;
|
|
252
258
|
if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
|
|
253
259
|
return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };
|
|
@@ -735,7 +741,7 @@ export function hasConcreteAction(text) {
|
|
|
735
741
|
* 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
|
|
736
742
|
* 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
|
|
737
743
|
*/
|
|
738
|
-
const SWITCH_MARK = /(
|
|
744
|
+
const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|换任务|先结束|结束刚才|结束上一个|去做|开始做)/u;
|
|
739
745
|
export function isExplicitSwitch(text, previousText = '') {
|
|
740
746
|
const current = plain(text, 400);
|
|
741
747
|
if (!current) return false;
|