dsh-lite-memory 0.18.29 → 0.18.31
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/anchor.js +3 -0
- package/client.js +38 -10
- package/docs/RELEASE-0.18.30.md +7 -0
- package/docs/RELEASE-0.18.31.md +6 -0
- package/index.js +66 -30
- package/package.json +1 -1
- package/store.js +39 -17
- package/test/anchor.test.mjs +29 -0
- package/test/contract.test.mjs +68 -5
- package/test/crossing.test.mjs +1 -1
- package/test/lexical.test.mjs +13 -0
- package/test/recall.test.mjs +121 -0
- package/view.js +36 -1
package/anchor.js
CHANGED
|
@@ -10,6 +10,8 @@ export const TASK_LIMITS = { sessions: 8, episodes: 12, sourceMessages: 8, idleM
|
|
|
10
10
|
|
|
11
11
|
const SWITCH_WORDS = /(改做|改为|改到|改成|换个|换一个|换成|转到|现在做|接下来做|新做|另做|开始做|去做)/;
|
|
12
12
|
const CONTINUE_WORDS = /(继续|再做一期|下一期|再来一期|再开一期|续一集|再拍一期)/;
|
|
13
|
+
/** 短续句只由通用动作词组成、没点名新对象:仍属于当前任务,不能当换任务。 */
|
|
14
|
+
const ELLIPTICAL_CONTINUE = /^(?:继续|接着|再接着|往下)[,,、\s]*(?:把|将|再)?(?:优化|收敛|调整|完善|打磨|推进|处理|改|修|做|写|整理|检查|排查|测试|发布|跑|看看|看下|弄|试|收尾|统一|对齐|细化|补|过一遍|走一遍|一下|下)+[。.!!??…]*$/u;
|
|
13
15
|
const REVISE_WORDS = /(再改一下|改一版|再改改|调整一下|这版再|继续改)/;
|
|
14
16
|
const THANKS_WORDS = /^(谢谢|多谢|谢了|辛苦了|thanks|thank\s*you)[!!。.~~\s]*$/i;
|
|
15
17
|
const REFERENCE_WORDS = /(这个|这条|这次|上次|之前|刚才|上面|前面|它|这里|继续|接着|还是|同样|再改)/;
|
|
@@ -71,6 +73,7 @@ function taskTerms(value) {
|
|
|
71
73
|
export function looksLikeNewTask(query, previousText) {
|
|
72
74
|
const current = text(query, 200);
|
|
73
75
|
if (!current || isThanks(current)) return false;
|
|
76
|
+
if (ELLIPTICAL_CONTINUE.test(current)) return false;
|
|
74
77
|
const next = taskTerms(current);
|
|
75
78
|
const previous = taskTerms(previousText);
|
|
76
79
|
const cue = isContinuation(current) || REFERENCE_WORDS.test(current);
|
package/client.js
CHANGED
|
@@ -2157,6 +2157,7 @@ window.__ModuleLoader__.load({
|
|
|
2157
2157
|
const [display, setDisplay] = React.useState({ ...DISPLAY_DEFAULTS });
|
|
2158
2158
|
const [hubFilter, setHubFilter] = React.useState(null);
|
|
2159
2159
|
const [checkup, setCheckup] = React.useState(false);
|
|
2160
|
+
const [expandedConflict, setExpandedConflict] = React.useState('');
|
|
2160
2161
|
const alive = React.useRef(true);
|
|
2161
2162
|
const generation = React.useRef(0);
|
|
2162
2163
|
const scopeRef = React.useRef('');
|
|
@@ -2205,10 +2206,13 @@ window.__ModuleLoader__.load({
|
|
|
2205
2206
|
if (!row || busy) return;
|
|
2206
2207
|
setBusy(true);
|
|
2207
2208
|
setNote('');
|
|
2208
|
-
post('/lite-memory/conflict', {
|
|
2209
|
+
post('/lite-memory/conflict', {
|
|
2210
|
+
sessionId, scopeKey: selectedKey, conflictId: row.id, expectedRevision: row.revision, op,
|
|
2211
|
+
}).then((result) => {
|
|
2209
2212
|
if (!alive.current) return;
|
|
2210
2213
|
setBusy(false);
|
|
2211
2214
|
if (result.ok && result.body && result.body.ok) {
|
|
2215
|
+
setExpandedConflict('');
|
|
2212
2216
|
if (op === 'drop') setNote('已忽略这条待裁决');
|
|
2213
2217
|
load(selectedKey);
|
|
2214
2218
|
} else setNote(failure(result, '待裁决没处理成'));
|
|
@@ -2220,6 +2224,7 @@ window.__ModuleLoader__.load({
|
|
|
2220
2224
|
setDisplay(readDisplaySettings(selectedKey));
|
|
2221
2225
|
setShowSettings(false);
|
|
2222
2226
|
setHubFilter(null);
|
|
2227
|
+
setExpandedConflict('');
|
|
2223
2228
|
}, [selectedKey]);
|
|
2224
2229
|
React.useEffect(() => {
|
|
2225
2230
|
setMemoryView((value) => viewModeOf(display, value));
|
|
@@ -2726,18 +2731,41 @@ window.__ModuleLoader__.load({
|
|
|
2726
2731
|
const memoryViewActive = viewModeOf(display, memoryView);
|
|
2727
2732
|
const evolutionViewActive = graphViewOf(display.graph, evolutionView);
|
|
2728
2733
|
|
|
2734
|
+
const conflictReadOnly = !memoryOn || (!globalView && selectedKey !== currentKey);
|
|
2735
|
+
const draftScope = (row) => (row.scope === 'global' ? '总记忆' : '本项目');
|
|
2729
2736
|
const conflictBar = queued.length === 0 ? null : h('div', {
|
|
2730
2737
|
style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '7px 9px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
|
|
2731
2738
|
},
|
|
2732
|
-
h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, `${queued.length}
|
|
2733
|
-
queued.slice(0, 2).map((row) =>
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2739
|
+
h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, `${queued.length} 条保存没确认${conflictReadOnly ? ' · 只读' : ''}`),
|
|
2740
|
+
queued.slice(0, 2).map((row) => {
|
|
2741
|
+
const open = expandedConflict === row.id;
|
|
2742
|
+
const draft = row.draft || {};
|
|
2743
|
+
return h('div', {
|
|
2744
|
+
key: row.id || row.title,
|
|
2745
|
+
style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 },
|
|
2746
|
+
},
|
|
2747
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 } },
|
|
2748
|
+
h('button', {
|
|
2749
|
+
type: 'button',
|
|
2750
|
+
'aria-expanded': open,
|
|
2751
|
+
onClick: () => setExpandedConflict(open ? '' : row.id),
|
|
2752
|
+
style: {
|
|
2753
|
+
flex: 1, minWidth: 0, padding: 0, border: 0, background: 'transparent', color: 'inherit',
|
|
2754
|
+
textAlign: 'left', font: 'inherit', fontSize: '12px', lineHeight: '20px',
|
|
2755
|
+
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer',
|
|
2756
|
+
},
|
|
2757
|
+
}, row.title),
|
|
2758
|
+
conflictReadOnly ? null : h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'keep') }, '另存'),
|
|
2759
|
+
conflictReadOnly ? null : h(Action, { disabled: busy, onClick: () => resolveQueued(row, 'drop') }, '忽略'),
|
|
2760
|
+
),
|
|
2761
|
+
open ? h('div', {
|
|
2762
|
+
style: { ...PANEL_MUTED, padding: '2px 0 4px', lineHeight: '18px', overflowWrap: 'anywhere' },
|
|
2763
|
+
},
|
|
2764
|
+
h('div', null, `范围:${draftScope(row)}`),
|
|
2765
|
+
draft.when ? h('div', null, `适用:${draft.when}`) : null,
|
|
2766
|
+
h('div', null, String(draft.body || draft.summary || '(没有正文)').slice(0, 400)),
|
|
2767
|
+
) : null);
|
|
2768
|
+
}));
|
|
2741
2769
|
const memoryListPane = h('div', {
|
|
2742
2770
|
style: {
|
|
2743
2771
|
...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 0.18.30
|
|
2
|
+
|
|
3
|
+
- 待裁决入口按「当前会话项目 + 目标层 + 草稿版本」核验:跨项目请求直接拒绝,不再把别的 scope 静默解释成当前项目。
|
|
4
|
+
- 项目记忆关闭时,本入口(含选全局层另存)一律拒绝,不绕过已有总开关。
|
|
5
|
+
- 队列行增加稳定 id 与 revision;草稿被后续反馈替换后,旧面板的「另存」返回 `stale_draft`,不会保存用户没看过的新正文。
|
|
6
|
+
- 面板可展开一条待裁决,看草稿正文、适用范围和目标层;关闭或跨项目浏览时显示只读,不出现操作按钮。
|
|
7
|
+
- 回归覆盖:跨项目拒绝、关闭态拒绝、旧版本拒绝、全局草稿在项目关闭时拒绝。
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# 0.18.31
|
|
2
|
+
|
|
3
|
+
- 「继续优化收敛」这类短续句不再当成换任务:句首是续说标记、其余只由通用动作词组成时,沿用当前任务与上一句原话;带新对象的「继续优化数据库连接池」仍然换任务。
|
|
4
|
+
- 续句或没有具体动作的短问句,把上一轮助手的话和工具名并进同一份查询,只查一次;通用卡片不再抢先占满名额、挡住真正相关的动作线索。
|
|
5
|
+
- 明确换任务(换做/改用/先别管,或点了一个上一句没有的英文对象)时不并旧动作线索,任务类型认不出也不会把旧任务记忆带进新任务。
|
|
6
|
+
- 回归覆盖:短续句承接下来回、换到数据库后继续说「继续」只承接数据库、未知类型换任务不带旧工具线索、通用卡片存在时动作线索仍能命中。
|
package/index.js
CHANGED
|
@@ -553,14 +553,42 @@ function keepHandler(webServer, cwdForSession) {
|
|
|
553
553
|
const cwd = typeof cwdForSession === 'function' ? cwdForSession(sessionId) : '';
|
|
554
554
|
if (!cwd) return sendJson(res, 409, { ok: false, reason: '这个会话还没有项目目录' });
|
|
555
555
|
const projectScope = scopeOf(cwd);
|
|
556
|
+
if (!projectScope.confirmed) return sendJson(res, 409, { ok: false, reason: '项目根还没确认,先重新打开这个项目' });
|
|
557
|
+
/** 请求里的目标必须与当前会话项目一致;别的项目只读,不能借面板改过去。 */
|
|
556
558
|
const requested = typeof body.scopeKey === 'string' ? body.scopeKey : '';
|
|
559
|
+
if (!requested) return sendJson(res, 400, { ok: false, reason: '缺少目标范围' });
|
|
560
|
+
if (requested !== 'global' && requested !== projectScope.key) {
|
|
561
|
+
return sendJson(res, 409, { ok: false, reason: '别的项目只读,回到那个项目里再改' });
|
|
562
|
+
}
|
|
557
563
|
const target = requested === 'global' ? globalScope() : projectScope;
|
|
558
|
-
|
|
559
|
-
if (!
|
|
564
|
+
/** 关闭的项目记忆不能从这个入口绕过;选全局时全局总开关也必须开着。 */
|
|
565
|
+
if (!memoryEnabled(projectScope)) {
|
|
566
|
+
return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: memoryOffCopy(projectScope) });
|
|
567
|
+
}
|
|
568
|
+
if (target.key === 'global' && !memoryEnabled(target)) {
|
|
569
|
+
return sendJson(res, 409, { ok: false, code: 'memory_disabled', reason: '总记忆已关闭,先在面板打开' });
|
|
570
|
+
}
|
|
571
|
+
const op = body.op;
|
|
572
|
+
if (op !== 'keep' && op !== 'drop') return sendJson(res, 400, { ok: false, reason: '不认识这个操作' });
|
|
573
|
+
const conflictId = typeof body.conflictId === 'string' ? body.conflictId.trim() : '';
|
|
574
|
+
if (!conflictId) return sendJson(res, 400, { ok: false, reason: '缺少这条待裁决' });
|
|
575
|
+
const expectedRevision = Number(body.expectedRevision);
|
|
576
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) {
|
|
577
|
+
return sendJson(res, 409, { ok: false, reason: '这条待裁决的版本不清楚,先刷新再处理' });
|
|
578
|
+
}
|
|
560
579
|
try {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
580
|
+
const ref = { id: conflictId, expectedRevision };
|
|
581
|
+
const outcome = op === 'drop' ? dropConflict(target, ref) : resolveConflict(target, ref);
|
|
582
|
+
if (!outcome.ok) {
|
|
583
|
+
return sendJson(res, outcome.reasonCode === 'stale_draft' ? 409 : 409, {
|
|
584
|
+
ok: false, code: outcome.reasonCode || '', reason: outcome.reason || '这条待裁决还没处理成',
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return sendJson(res, 200, {
|
|
588
|
+
ok: true,
|
|
589
|
+
remaining: conflictsOf(target).length,
|
|
590
|
+
record: outcome.record ? { id: outcome.record.id, title: outcome.record.title, revision: outcome.record.revision } : null,
|
|
591
|
+
});
|
|
564
592
|
} catch (error) {
|
|
565
593
|
return sendJson(res, 409, { ok: false, reason: error instanceof Error ? error.message : '待裁决处理失败' });
|
|
566
594
|
}
|
|
@@ -2686,8 +2714,10 @@ export function apply(ctx, config) {
|
|
|
2686
2714
|
/** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
|
|
2687
2715
|
const said = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
|
|
2688
2716
|
/** 「继续」这类续说没有检索词:带上同会话上一轮原话,否则这一轮必然召回为空。 */
|
|
2689
|
-
const carried = sameSession && previous?.query
|
|
2690
|
-
|
|
2717
|
+
const carried = sameSession && previous?.query
|
|
2718
|
+
&& (view.isContinuationQuery(said) || view.isEllipticalContinuation(said))
|
|
2719
|
+
? previous.query : '';
|
|
2720
|
+
let query = (carried ? `${said} ${carried}` : said).trim().slice(0, RECALL_QUERY_CHARS);
|
|
2691
2721
|
if (win && query) win.query = query;
|
|
2692
2722
|
|
|
2693
2723
|
if (!query) {
|
|
@@ -2729,37 +2759,43 @@ export function apply(ctx, config) {
|
|
|
2729
2759
|
const remembered = runtime && runtime.enabled
|
|
2730
2760
|
? rememberFeedback(scope, sessionId, fresh, runtime.held, turn)
|
|
2731
2761
|
: null;
|
|
2732
|
-
let snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
|
|
2733
|
-
/**
|
|
2734
|
-
* 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
|
|
2735
|
-
* 第一次检索一张卡都没有时,用最近的实词/工具名补一次有界查询,预算仍是同一份。
|
|
2736
|
-
*/
|
|
2737
2762
|
const currentTask = {
|
|
2738
2763
|
kind: (runtime && runtime.held && runtime.held.task && runtime.held.task.kind) || '',
|
|
2739
2764
|
episodeId: (runtime && runtime.held && runtime.held.episodeId) || '',
|
|
2740
2765
|
};
|
|
2741
2766
|
const rememberedTask = lastTask.get(sessionId) || null;
|
|
2742
2767
|
const previousTask = previous && previous.task ? previous.task : rememberedTask;
|
|
2743
|
-
const sameTask = Boolean(sameSession || rememberedTask)
|
|
2768
|
+
const sameTask = Boolean(sameSession || rememberedTask)
|
|
2769
|
+
&& !view.isExplicitSwitch(said, previous && previous.query ? previous.query : '')
|
|
2770
|
+
&& view.sameRecallTask(previousTask, currentTask);
|
|
2744
2771
|
lastTask.set(sessionId, currentTask);
|
|
2745
2772
|
while (lastTask.size > 64) lastTask.delete(lastTask.keys().next().value);
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2773
|
+
/**
|
|
2774
|
+
* 预防型记忆约束的是「下一步动作」,词面常写在上一轮助手的话和工具调用里,用户这句话反而没提。
|
|
2775
|
+
* 续句或没有具体动作的短问句,直接把最近的动作线索并进同一份查询:只查一次,不额外占预算。
|
|
2776
|
+
* 明确换任务时不并,旧任务的动作历史不能给新任务找记忆。
|
|
2777
|
+
*/
|
|
2778
|
+
const humanTail = (() => {
|
|
2779
|
+
const messages = Array.isArray(decision.messages) ? decision.messages : [];
|
|
2780
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
2781
|
+
if (messages[i]?.source?.kind === 'user') return messages.slice(0, i);
|
|
2782
|
+
}
|
|
2783
|
+
return messages;
|
|
2784
|
+
})();
|
|
2785
|
+
const trail = sameTask ? view.actionTerms(humanTail, { limit: 12 }).slice(0, 240) : '';
|
|
2786
|
+
const trailInQuery = Boolean(trail) && (Boolean(carried) || !view.hasConcreteAction(said));
|
|
2787
|
+
if (trailInQuery) {
|
|
2788
|
+
query = `${query} ${trail}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
2789
|
+
if (win) win.query = query;
|
|
2790
|
+
}
|
|
2791
|
+
let snapshot = projectRecall(scope, query, recalledContext, win, runtime ? runtime.held : null);
|
|
2792
|
+
/** 并了动作线索仍一张卡都没有,才补一次有界查询;补到了才替换,不拿空结果盖掉原判断。 */
|
|
2793
|
+
if (!snapshot.cards.length && !carried && sameTask && trail && !trailInQuery) {
|
|
2794
|
+
const actionQuery = `${trail} ${query}`.trim().slice(0, RECALL_QUERY_CHARS);
|
|
2795
|
+
const retry = projectRecall(scope, actionQuery, recalledContext, win, runtime ? runtime.held : null);
|
|
2796
|
+
if (retry.cards.length) {
|
|
2797
|
+
snapshot = retry;
|
|
2798
|
+
if (win) win.query = actionQuery;
|
|
2763
2799
|
}
|
|
2764
2800
|
}
|
|
2765
2801
|
/** 实验规则最后才取预算:普通卡片先占,弱相关实验不能把强相关项目规则挤出去。 */
|
package/package.json
CHANGED
package/store.js
CHANGED
|
@@ -2761,20 +2761,32 @@ function queueConflictLocked(scope, draft, candidates) {
|
|
|
2761
2761
|
if (hit) {
|
|
2762
2762
|
hit.candidates = view;
|
|
2763
2763
|
hit.draft = replay;
|
|
2764
|
+
/** 草稿被后来的反馈替换过:版本必须涨,旧面板不能拿旧版直接批准。 */
|
|
2765
|
+
hit.revision = (Number(hit.revision) || 1) + 1;
|
|
2764
2766
|
hit.updatedAt = now;
|
|
2765
2767
|
} else {
|
|
2766
|
-
rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, draft: replay, createdAt: now, updatedAt: now });
|
|
2768
|
+
rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, draft: replay, revision: 1, createdAt: now, updatedAt: now });
|
|
2767
2769
|
}
|
|
2768
2770
|
index.conflicts = rows.slice(-CONFLICT_LIMIT);
|
|
2769
2771
|
saveIndex(scope, index);
|
|
2770
2772
|
return index.conflicts.length;
|
|
2771
2773
|
}
|
|
2772
2774
|
|
|
2773
|
-
|
|
2775
|
+
/** 按稳定 id 找;旧调用还传标题,只为兼容已有数据与测试。 */
|
|
2776
|
+
function conflictRowLocked(scope, ref) {
|
|
2777
|
+
const rows = Array.isArray(loadIndex(scope).conflicts) ? loadIndex(scope).conflicts : [];
|
|
2778
|
+
const layer = scope.key === 'global' ? 'global' : 'project';
|
|
2779
|
+
if (ref && typeof ref === 'object' && ref.id) return rows.find((row) => row.id === ref.id && row.scope === layer) || null;
|
|
2780
|
+
const norm = normalizeText(typeof ref === 'string' ? ref : '', 120);
|
|
2781
|
+
return rows.find((row) => row.title === norm && row.scope === layer) || null;
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function clearConflictLocked(scope, ref) {
|
|
2774
2785
|
const index = loadIndex(scope);
|
|
2775
2786
|
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2776
|
-
const
|
|
2777
|
-
const
|
|
2787
|
+
const id = ref && typeof ref === 'object' && ref.id ? ref.id : '';
|
|
2788
|
+
const norm = id ? '' : normalizeText(typeof ref === 'string' ? ref : '', 120);
|
|
2789
|
+
const next = rows.filter((row) => (id ? row.id !== id : !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project'))));
|
|
2778
2790
|
if (next.length === rows.length) return rows.length;
|
|
2779
2791
|
index.conflicts = next;
|
|
2780
2792
|
saveIndex(scope, index);
|
|
@@ -2787,28 +2799,38 @@ export function conflictsOf(scope) {
|
|
|
2787
2799
|
return Array.isArray(rows) ? JSON.parse(JSON.stringify(rows)) : [];
|
|
2788
2800
|
}
|
|
2789
2801
|
|
|
2790
|
-
/**
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2802
|
+
/** 版本对不上就拒绝:用户看到的草稿和现在这条不是同一版。 */
|
|
2803
|
+
function staleDraft(row, ref) {
|
|
2804
|
+
if (!ref || typeof ref !== 'object' || ref.expectedRevision === undefined) return false;
|
|
2805
|
+
return Number(ref.expectedRevision) !== (Number(row.revision) || 1);
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** 确认另存:用队列里的草稿按 distinct 重放一次,成功后按 id 出队。 */
|
|
2809
|
+
export function resolveConflict(scope, ref, options = {}) {
|
|
2810
|
+
const row = conflictRowLocked(scope, ref);
|
|
2795
2811
|
if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
|
|
2812
|
+
if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
|
|
2796
2813
|
const draft = { ...(row.draft || {}), resolution: 'distinct', writeMode: options.writeMode === 'append' ? 'append' : 'patch' };
|
|
2797
2814
|
if (scope.key === 'global') draft.scope = 'global';
|
|
2798
2815
|
else draft.scope = 'project';
|
|
2799
2816
|
if (!draft.title) draft.title = row.title;
|
|
2800
2817
|
const result = saveEntry(scope, draft);
|
|
2801
|
-
if (result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
|
|
2802
|
-
if (result.needsScope)
|
|
2803
|
-
|
|
2804
|
-
|
|
2818
|
+
if (result && result.needsResolution) return { ok: false, reason: '还是相近:换一个标题或等确认', result };
|
|
2819
|
+
if (result && result.needsScope) {
|
|
2820
|
+
const reason = result.reasonCode === 'memory_disabled' ? '这层记忆已关闭' : '这条记忆的适用范围还没定,先说明当前环境';
|
|
2821
|
+
return { ok: false, reason, result };
|
|
2822
|
+
}
|
|
2823
|
+
clearConflictLocked(scope, { id: row.id });
|
|
2824
|
+
return { ok: true, result, record: result && result.record };
|
|
2805
2825
|
}
|
|
2806
2826
|
|
|
2807
2827
|
/** 删掉待裁决:用户决定不再另存。 */
|
|
2808
|
-
export function dropConflict(scope,
|
|
2809
|
-
const
|
|
2810
|
-
|
|
2811
|
-
|
|
2828
|
+
export function dropConflict(scope, ref) {
|
|
2829
|
+
const row = conflictRowLocked(scope, ref);
|
|
2830
|
+
if (!row) return { ok: false, reason: '这条待裁决已经不在了,刷新面板看看' };
|
|
2831
|
+
if (staleDraft(row, ref)) return { ok: false, reasonCode: 'stale_draft', reason: '这条待裁决又更新了,刷新后再处理' };
|
|
2832
|
+
clearConflictLocked(scope, { id: row.id });
|
|
2833
|
+
return { ok: true };
|
|
2812
2834
|
}
|
|
2813
2835
|
|
|
2814
2836
|
/** 当前层还有几条没处理的冲突。 */
|
package/test/anchor.test.mjs
CHANGED
|
@@ -344,6 +344,35 @@ test('R22 真正的续改保留原任务范围', () => {
|
|
|
344
344
|
assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test('R22b 短续句「继续优化收敛」不点名新对象,仍算续作', () => {
|
|
348
|
+
const tasks = activeVideoSession();
|
|
349
|
+
const next = anchor.routeTask(tasks, {
|
|
350
|
+
sessionId: 'sess-switch',
|
|
351
|
+
batchKey: 'rb_ellipse',
|
|
352
|
+
messageIds: ['m2'],
|
|
353
|
+
query: '继续优化收敛',
|
|
354
|
+
registeredSeries: ['口播'],
|
|
355
|
+
now: 2000,
|
|
356
|
+
});
|
|
357
|
+
assert.equal(next.decision.kind, 'continuation');
|
|
358
|
+
assert.equal(next.anchor.seriesId, '口播');
|
|
359
|
+
assert.equal(next.episode.previousEpisodeId, tasks.sessions['sess-switch'].episodeId);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('R22c 短续句点名新对象时仍然换任务', () => {
|
|
363
|
+
const tasks = activeVideoSession();
|
|
364
|
+
const next = anchor.routeTask(tasks, {
|
|
365
|
+
sessionId: 'sess-switch',
|
|
366
|
+
batchKey: 'rb_ellipse_obj',
|
|
367
|
+
messageIds: ['m2'],
|
|
368
|
+
query: '继续优化数据库连接池',
|
|
369
|
+
registeredSeries: ['口播'],
|
|
370
|
+
now: 2000,
|
|
371
|
+
});
|
|
372
|
+
assert.equal(next.decision.kind, 'unbound');
|
|
373
|
+
assert.equal(next.decision.reason, 'new_task');
|
|
374
|
+
});
|
|
375
|
+
|
|
347
376
|
test('R23 只指代旧稿的局部修改不被当成换任务', () => {
|
|
348
377
|
const tasks = activeVideoSession();
|
|
349
378
|
const next = anchor.routeTask(tasks, {
|
package/test/contract.test.mjs
CHANGED
|
@@ -1213,6 +1213,7 @@ assert.equal(ovArchivedBody.body.body, '右侧留白。');
|
|
|
1213
1213
|
// 待裁决:面板看得到队列,确认后按草稿另存,不预写正文
|
|
1214
1214
|
const cfCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-'));
|
|
1215
1215
|
sessionCwds.set('session-conflict-panel', cfCwd);
|
|
1216
|
+
sessionCwds.set('session-conflict-other', fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-other-')));
|
|
1216
1217
|
const cfAgent = { id: 'session-conflict-panel', cwd: cfCwd, session: { id: 'session-conflict-panel', header: { cwd: cfCwd } } };
|
|
1217
1218
|
await defs.get('memory_save').execute({ title: '发布窗口', summary: '只在周四晚上发版', body: '周四晚上再发版。', scope: 'global' }, { agent: cfAgent });
|
|
1218
1219
|
const cfSave = await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目周五发版', body: '周五发版。' }, { agent: cfAgent });
|
|
@@ -1221,15 +1222,77 @@ assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '没确认
|
|
|
1221
1222
|
const cfBefore = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1222
1223
|
assert.equal(cfBefore.status, 200);
|
|
1223
1224
|
assert.equal(cfBefore.body.selected.conflicts.length, 1);
|
|
1224
|
-
|
|
1225
|
-
assert.equal(
|
|
1226
|
-
|
|
1225
|
+
const cfRow = cfBefore.body.selected.conflicts[0];
|
|
1226
|
+
assert.equal(cfRow.title, '发布窗口');
|
|
1227
|
+
assert.equal(cfRow.draft.body, '周五发版。');
|
|
1228
|
+
assert.ok(cfRow.id, '待裁决要有稳定 id');
|
|
1229
|
+
assert.equal(cfRow.revision, 1);
|
|
1230
|
+
const cfScopeKey = cfBefore.body.selected.key;
|
|
1231
|
+
|
|
1232
|
+
// 跨项目:当前会话是 A,请求 B 的范围必须拒绝,且不能动到 A 的草稿
|
|
1233
|
+
const cfCross = await call('/lite-memory/conflict', {
|
|
1234
|
+
sessionId: 'session-conflict-panel', scopeKey: '另一个项目', conflictId: cfRow.id, expectedRevision: 1, op: 'drop',
|
|
1235
|
+
});
|
|
1236
|
+
assert.equal(cfCross.status, 409);
|
|
1237
|
+
assert.match(cfCross.body.reason, /只读/);
|
|
1238
|
+
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).conflicts.length, 1, '跨项目请求不改数据');
|
|
1239
|
+
|
|
1240
|
+
// 关闭项目记忆后,本入口也不能落盘
|
|
1241
|
+
await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: false, operationId: 'op_cf_close' });
|
|
1242
|
+
const cfClosed = await call('/lite-memory/conflict', {
|
|
1243
|
+
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
|
|
1244
|
+
});
|
|
1245
|
+
assert.equal(cfClosed.status, 409);
|
|
1246
|
+
assert.equal(cfClosed.body.code, 'memory_disabled');
|
|
1247
|
+
assert.equal(store.loadIndex(store.scopeOf(cfCwd)).entries.length, 0, '关闭状态不写记忆');
|
|
1248
|
+
await call('/lite-memory/control', { sessionId: 'session-conflict-panel', enabled: true, operationId: 'op_cf_reopen' });
|
|
1249
|
+
|
|
1250
|
+
// 草稿被后来的反馈替换:旧版本点击必须拒绝
|
|
1251
|
+
await defs.get('memory_save').execute({ title: '发布窗口', summary: '本项目改成周六发版', body: '周六发版。' }, { agent: cfAgent });
|
|
1252
|
+
const cfStale = await call('/lite-memory/conflict', {
|
|
1253
|
+
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 1, op: 'keep',
|
|
1254
|
+
});
|
|
1255
|
+
assert.equal(cfStale.status, 409);
|
|
1256
|
+
assert.equal(cfStale.body.code, 'stale_draft');
|
|
1257
|
+
const cfStaleView = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1258
|
+
assert.equal(cfStaleView.body.selected.conflicts[0].revision, 2, '草稿换了版本要涨');
|
|
1259
|
+
assert.equal(cfStaleView.body.selected.conflicts[0].draft.body, '周六发版。');
|
|
1260
|
+
|
|
1261
|
+
const cfKeep = await call('/lite-memory/conflict', {
|
|
1262
|
+
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
|
|
1263
|
+
});
|
|
1227
1264
|
assert.equal(cfKeep.status, 200);
|
|
1228
1265
|
assert.equal(cfKeep.body.ok, true);
|
|
1229
1266
|
const cfAfter = await call('/lite-memory/overview', { sessionId: 'session-conflict-panel' });
|
|
1230
1267
|
assert.equal(cfAfter.body.selected.conflicts.length, 0);
|
|
1231
|
-
assert.ok(cfAfter.body.selected.records.some((row) => row.title === '发布窗口' && row.summary === '
|
|
1232
|
-
const cfAgain = await call('/lite-memory/conflict', {
|
|
1268
|
+
assert.ok(cfAfter.body.selected.records.some((row) => row.title === '发布窗口' && row.summary === '本项目改成周六发版'));
|
|
1269
|
+
const cfAgain = await call('/lite-memory/conflict', {
|
|
1270
|
+
sessionId: 'session-conflict-panel', scopeKey: cfScopeKey, conflictId: cfRow.id, expectedRevision: 2, op: 'keep',
|
|
1271
|
+
});
|
|
1233
1272
|
assert.equal(cfAgain.status, 409, '队列没了就明确说,不重复落库');
|
|
1234
1273
|
|
|
1274
|
+
// 项目记忆关闭时,选全局待裁决也不能借这个入口落盘
|
|
1275
|
+
const cfgCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-contract-conflict-global-'));
|
|
1276
|
+
sessionCwds.set('session-conflict-global', cfgCwd);
|
|
1277
|
+
const cfgAgent = { id: 'session-conflict-global', cwd: cfgCwd, session: { id: 'session-conflict-global', header: { cwd: cfgCwd } } };
|
|
1278
|
+
await defs.get('memory_save').execute({ title: '命名规则', summary: '项目里用短横线', body: '短横线。' }, { agent: cfgAgent });
|
|
1279
|
+
const cfgSave = await defs.get('memory_save').execute({ title: '命名规则', summary: '所有项目用下划线', body: '下划线。', scope: 'global' }, { agent: cfgAgent });
|
|
1280
|
+
assert.equal(cfgSave.needsResolution, true);
|
|
1281
|
+
const cfgView = await call('/lite-memory/overview', { sessionId: 'session-conflict-global', scopeKey: 'global' });
|
|
1282
|
+
assert.equal(cfgView.body.selected.conflicts.length, 1, '全局层也要有这条待裁决');
|
|
1283
|
+
const cfgRow = cfgView.body.selected.conflicts[0];
|
|
1284
|
+
await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: false, operationId: 'op_cfg_close' });
|
|
1285
|
+
const cfgClosed = await call('/lite-memory/conflict', {
|
|
1286
|
+
sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
|
|
1287
|
+
});
|
|
1288
|
+
assert.equal(cfgClosed.status, 409);
|
|
1289
|
+
assert.equal(cfgClosed.body.code, 'memory_disabled');
|
|
1290
|
+
assert.equal(store.conflictsOf(store.globalScope()).length, 1, '关闭状态不批准全局草稿');
|
|
1291
|
+
await call('/lite-memory/control', { sessionId: 'session-conflict-global', enabled: true, operationId: 'op_cfg_reopen' });
|
|
1292
|
+
const cfgKeep = await call('/lite-memory/conflict', {
|
|
1293
|
+
sessionId: 'session-conflict-global', scopeKey: 'global', conflictId: cfgRow.id, expectedRevision: cfgRow.revision, op: 'keep',
|
|
1294
|
+
});
|
|
1295
|
+
assert.equal(cfgKeep.status, 200);
|
|
1296
|
+
assert.equal(store.conflictsOf(store.globalScope()).length, 0);
|
|
1297
|
+
|
|
1235
1298
|
console.log('contract.test.mjs: all passed');
|
package/test/crossing.test.mjs
CHANGED
|
@@ -434,7 +434,7 @@ 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(dropConflict(scope, '不存在'), false);
|
|
437
|
+
assert.equal(dropConflict(scope, '不存在').ok, false);
|
|
438
438
|
});
|
|
439
439
|
|
|
440
440
|
console.log(`crossing.test.mjs: ${passed} passed`);
|
package/test/lexical.test.mjs
CHANGED
|
@@ -51,5 +51,18 @@ for (const text of ['继续', '继续。', '然后呢?', '接着', 'go on', 'c
|
|
|
51
51
|
for (const text of ['继续优化轻量化记忆与前端展示逻辑', '然后发布', '样式', 'next steps for release']) {
|
|
52
52
|
assert.equal(view.isContinuationQuery(text), false, text);
|
|
53
53
|
}
|
|
54
|
+
/* 短续句只由通用动作词组成、没点名新对象:沿用上一句,但不把带对象的长句也算进来。 */
|
|
55
|
+
for (const text of ['继续优化收敛', '继续打磨', '接着改', '继续看看']) {
|
|
56
|
+
assert.equal(view.isEllipticalContinuation(text), true, text);
|
|
57
|
+
}
|
|
58
|
+
for (const text of ['继续优化轻量化记忆与前端展示逻辑', '继续优化数据库连接池', '接着改数据库', '然后发布']) {
|
|
59
|
+
assert.equal(view.isEllipticalContinuation(text), false, text);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* 明确换任务:换做/先别管,或点了一个上一句没有的英文对象。 */
|
|
63
|
+
assert.equal(view.isExplicitSwitch('改用 PostgreSQL 先看连接池', '把前端按钮颜色调一下'), true);
|
|
64
|
+
assert.equal(view.isExplicitSwitch('先别管这个了,帮我看看 nginx 502', '把前端按钮颜色调一下'), true);
|
|
65
|
+
assert.equal(view.isExplicitSwitch('继续优化收敛', '把前端按钮颜色调一下'), false);
|
|
66
|
+
assert.equal(view.isExplicitSwitch('继续优化数据库连接池', '把前端按钮颜色调一下'), false);
|
|
54
67
|
|
|
55
68
|
console.log('lexical.test.mjs: all passed');
|
package/test/recall.test.mjs
CHANGED
|
@@ -588,6 +588,127 @@ console.log('recall.test.mjs: ok');
|
|
|
588
588
|
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '动作侧线索要能召回预防型记忆');
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
// A25b:短续句「继续优化收敛」没有自己的检索词,要沿用上一句,不能断在上一轮。
|
|
592
|
+
{
|
|
593
|
+
const cwdEllipse = mkdir('ellipse');
|
|
594
|
+
const scopeEllipse = store.scopeOf(cwdEllipse);
|
|
595
|
+
save(scopeEllipse, {
|
|
596
|
+
title: '轻量化记忆与前端展示逻辑先定召回顺序',
|
|
597
|
+
tags: ['轻量化记忆', '前端展示逻辑'],
|
|
598
|
+
summary: '先定召回顺序,再谈展示;触发机制跟着任务走',
|
|
599
|
+
});
|
|
600
|
+
const agent = makeAgent('agent-ellipse', cwdEllipse);
|
|
601
|
+
const runtime = boot();
|
|
602
|
+
const first = signal();
|
|
603
|
+
await runtime.preStep({ agent, signal: first, turn: 'e1' }, enter(human('继续优化轻量化记忆与前端展示逻辑 还有触发机制等')));
|
|
604
|
+
const second = signal();
|
|
605
|
+
await runtime.preStep({ agent, signal: second, turn: 'e2' }, enter(human('继续优化收敛')));
|
|
606
|
+
const prepared = runtime.debug.preparedOf(agent);
|
|
607
|
+
assert.ok(prepared, '短续句要准备召回');
|
|
608
|
+
assert.match(prepared.query, /轻量化记忆与前端展示逻辑/, '短续句沿用上一句原话');
|
|
609
|
+
assert.ok(prepared.cards.some((card) => /轻量化记忆/.test(card.title || card.line || '')), '同一话题的记忆要回来');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// A25c:上一轮有动作、这句话只有「这个怎么弄」时,通用卡片不能挡住动作线索的补查。
|
|
613
|
+
{
|
|
614
|
+
const cwdGeneric = mkdir('generic');
|
|
615
|
+
const scopeGeneric = store.scopeOf(cwdGeneric);
|
|
616
|
+
save(scopeGeneric, {
|
|
617
|
+
title: '这个怎么弄时先读现有文档',
|
|
618
|
+
tags: ['怎么弄', '文档'],
|
|
619
|
+
summary: '没头绪时先读现有文档再动手',
|
|
620
|
+
});
|
|
621
|
+
save(scopeGeneric, {
|
|
622
|
+
title: '改完 client.js 先重载宿主',
|
|
623
|
+
tags: ['client.js', '重载'],
|
|
624
|
+
summary: 'client.js 一改就要重载宿主,普通刷新覆盖不到',
|
|
625
|
+
});
|
|
626
|
+
const agent = makeAgent('agent-generic', cwdGeneric);
|
|
627
|
+
const runtime = boot();
|
|
628
|
+
const first = signal();
|
|
629
|
+
await runtime.preStep({ agent, signal: first, turn: 'g1' }, enter(human('这个怎么弄')));
|
|
630
|
+
assert.match(runtime.provider.text({ agent, signal: first }), /先读现有文档/, '第一轮命中通用卡片');
|
|
631
|
+
const assistantMessage = {
|
|
632
|
+
role: 'assistant',
|
|
633
|
+
id: 'asst-generic-1',
|
|
634
|
+
content: [
|
|
635
|
+
{ type: 'text', text: '我把改动写进文件了。' },
|
|
636
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/generic/client.js' } },
|
|
637
|
+
],
|
|
638
|
+
};
|
|
639
|
+
const second = signal();
|
|
640
|
+
await runtime.preStep({ agent, signal: second, turn: 'g2' }, enter(assistantMessage, human('这个怎么弄')));
|
|
641
|
+
assert.match(runtime.provider.text({ agent, signal: second }), /改完 client\.js 先重载宿主/, '通用卡片不能挡住动作线索');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// A25d:换到数据库任务后再说「继续」,只承接数据库这一句,不能把前端任务的记忆带回来。
|
|
645
|
+
{
|
|
646
|
+
const cwdSwitch = mkdir('switch-cont');
|
|
647
|
+
const scopeSwitch = store.scopeOf(cwdSwitch);
|
|
648
|
+
save(scopeSwitch, {
|
|
649
|
+
title: '前端按钮颜色先对齐设计稿 token',
|
|
650
|
+
tags: ['前端按钮', '颜色'],
|
|
651
|
+
summary: '按钮颜色只取设计稿 token,不要另写十六进制',
|
|
652
|
+
});
|
|
653
|
+
save(scopeSwitch, {
|
|
654
|
+
title: 'PostgreSQL 连接池先查 max_connections',
|
|
655
|
+
tags: ['PostgreSQL', '连接池'],
|
|
656
|
+
summary: '连接池调大前先看 max_connections 和实际占用',
|
|
657
|
+
});
|
|
658
|
+
const agent = makeAgent('agent-switch-cont', cwdSwitch);
|
|
659
|
+
const runtime = boot();
|
|
660
|
+
const first = signal();
|
|
661
|
+
await runtime.preStep({ agent, signal: first, turn: 'sc1' }, enter(human('前端按钮颜色怎么改')));
|
|
662
|
+
assert.match(runtime.provider.text({ agent, signal: first }), /前端按钮颜色/, '第一轮命中前端任务');
|
|
663
|
+
const second = signal();
|
|
664
|
+
await runtime.preStep({ agent, signal: second, turn: 'sc2' }, enter(human('改用 PostgreSQL,先看连接池')));
|
|
665
|
+
assert.match(runtime.provider.text({ agent, signal: second }), /PostgreSQL 连接池/, '第二轮命中数据库任务');
|
|
666
|
+
const third = signal();
|
|
667
|
+
await runtime.preStep({ agent, signal: third, turn: 'sc3' }, enter(human('继续')));
|
|
668
|
+
const prepared = runtime.debug.preparedOf(agent);
|
|
669
|
+
assert.match(prepared.query, /PostgreSQL/, '续句承接的是数据库那一句');
|
|
670
|
+
assert.ok(prepared.cards.some((card) => /PostgreSQL/.test(card.title || card.line || '')), '续句仍命中数据库记忆');
|
|
671
|
+
assert.ok(!prepared.cards.some((card) => /前端按钮/.test(card.title || card.line || '')), '不把前端任务记忆带回来');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// A25e:认不出任务类型的明确换任务,也不能把上一轮的动作线索带过来。
|
|
675
|
+
{
|
|
676
|
+
const cwdUnknown = mkdir('unknown-switch');
|
|
677
|
+
const scopeUnknown = store.scopeOf(cwdUnknown);
|
|
678
|
+
save(scopeUnknown, {
|
|
679
|
+
title: '改完 client.js 先重载宿主',
|
|
680
|
+
tags: ['client.js', '重载'],
|
|
681
|
+
summary: 'client.js 改动后必须重载宿主,普通刷新覆盖不到',
|
|
682
|
+
});
|
|
683
|
+
save(scopeUnknown, {
|
|
684
|
+
title: 'nginx 502 先看 upstream 日志',
|
|
685
|
+
tags: ['nginx', '502', 'upstream'],
|
|
686
|
+
summary: '502 先查 upstream 是否存活,再看超时配置',
|
|
687
|
+
});
|
|
688
|
+
const agent = makeAgent('agent-unknown', cwdUnknown);
|
|
689
|
+
const runtime = boot();
|
|
690
|
+
const first = signal();
|
|
691
|
+
await runtime.preStep({ agent, signal: first, turn: 'u1' }, enter(human('把前端按钮颜色调一下')));
|
|
692
|
+
const assistantMessage = {
|
|
693
|
+
role: 'assistant',
|
|
694
|
+
id: 'asst-unknown-1',
|
|
695
|
+
content: [
|
|
696
|
+
{ type: 'text', text: '我把 client.js 的样式改好了。' },
|
|
697
|
+
{ type: 'tool-call', name: 'write', input: { file_path: '/tmp/unknown/client.js' } },
|
|
698
|
+
],
|
|
699
|
+
};
|
|
700
|
+
const second = signal();
|
|
701
|
+
await runtime.preStep({ agent, signal: second, turn: 'u2' }, enter(assistantMessage, human('先别管这个了,帮我看看 nginx 502')));
|
|
702
|
+
const switchedText = runtime.provider.text({ agent, signal: second });
|
|
703
|
+
assert.doesNotMatch(switchedText, /改完 client\.js 先重载宿主/, '明确换任务后不带旧动作线索');
|
|
704
|
+
assert.match(switchedText, /nginx 502/, '新任务本身能命中');
|
|
705
|
+
const third = signal();
|
|
706
|
+
await runtime.preStep({ agent, signal: third, turn: 'u3' }, enter(human('继续')));
|
|
707
|
+
const continuedText = runtime.provider.text({ agent, signal: third });
|
|
708
|
+
assert.match(continuedText, /nginx 502/, '续句承接新任务');
|
|
709
|
+
assert.doesNotMatch(continuedText, /改完 client\.js 先重载宿主/, '续句不回旧任务');
|
|
710
|
+
}
|
|
711
|
+
|
|
591
712
|
// A26:明确换任务后,不能拿上一轮助手的话去补查、把旧任务记忆塞进新任务。
|
|
592
713
|
{
|
|
593
714
|
const cwdSwitch = mkdir('switch');
|
package/view.js
CHANGED
|
@@ -710,9 +710,44 @@ export function isContinuationQuery(text) {
|
|
|
710
710
|
return /^(继续|接着|然后|然后呢|还有|还有呢|往下|说下去|goon|continue|next|more)$/.test(raw);
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
+
/**
|
|
714
|
+
* 「继续优化收敛」这类短续句:句首是续说标记,剩下的只由通用动作词组成,
|
|
715
|
+
* 没有点名新的对象。它本身检索不出上一轮的记忆,但仍属于当前任务,要沿用上一句原话。
|
|
716
|
+
* 明确带对象(「继续优化数据库连接池」「继续优化轻量化记忆」)不算,交给换任务判断。
|
|
717
|
+
*/
|
|
718
|
+
const ELLIPTICAL_BODY = /^(?:把|将|再|接着|继续)?(?:优化|收敛|调整|完善|打磨|推进|处理|改|修|做|写|整理|检查|排查|测试|发布|跑|看看|看下|弄|试|收尾|统一|对齐|细化|补|过一遍|走一遍|一下|下)+[。.!!??…]*$/u;
|
|
719
|
+
export function isEllipticalContinuation(text) {
|
|
720
|
+
const raw = plain(text, 40).trim();
|
|
721
|
+
const cut = raw.replace(/^[\s\u3002\uff01\uff1f?!\uff0c,\u3001\u2026~\uff5e]+/u, '');
|
|
722
|
+
if (!cut || [...cut].length > 12) return false;
|
|
723
|
+
const match = cut.match(/^(?:继续|接着|再接着|往下)[,,、\s]*(.+)$/u);
|
|
724
|
+
if (!match) return false;
|
|
725
|
+
return ELLIPTICAL_BODY.test(match[1].trim());
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** 这句话有没有具体动作:只用于判断要不要把上一轮的工具线索并进这次查询。 */
|
|
729
|
+
const CONCRETE_ACTION = /(改|写|修|做|建|删|新增|添加|移除|运行|跑|测试|发布|部署|提交|推送|安装|重载|重启|启动|停止|检查|排查|优化|收敛|处理|调整|配置|设置|替换|更换|上传|下载|生成|保存|读取|打开|关闭|合并|拆分|回滚|迁移|接入|修复|定位|复现)/u;
|
|
730
|
+
export function hasConcreteAction(text) {
|
|
731
|
+
return CONCRETE_ACTION.test(plain(text, 400));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* 明确换任务:句子里有换做/改用/先别管这类词,或点了一个上一句没有的英文对象。
|
|
736
|
+
* 认不出任务类型时,上一轮的动作线索只能靠这条规则隔开,不能因为类型为空就默认同任务。
|
|
737
|
+
*/
|
|
738
|
+
const SWITCH_MARK = /(换成|换个|换一个|改用|改做|改为|改到|转到|先不管|别管|先别|不弄这个|不看这个|另一个|新任务|去做|开始做)/u;
|
|
739
|
+
export function isExplicitSwitch(text, previousText = '') {
|
|
740
|
+
const current = plain(text, 400);
|
|
741
|
+
if (!current) return false;
|
|
742
|
+
if (SWITCH_MARK.test(current)) return true;
|
|
743
|
+
const previous = plain(previousText, 400).toLowerCase();
|
|
744
|
+
const objects = current.match(/[A-Za-z][A-Za-z0-9_.-]{1,}|[0-9]{2,}/g) || [];
|
|
745
|
+
return objects.some((token) => !previous.includes(token.toLowerCase()));
|
|
746
|
+
}
|
|
747
|
+
|
|
713
748
|
/**
|
|
714
749
|
* 补查只有在「还是同一个任务」时才成立:任务类型或 episode 变了,上一轮的动作历史属于旧任务,
|
|
715
|
-
*
|
|
750
|
+
* 不能拿它给新任务找记忆。类型缺失时保留补查,由调用方的明确换任务判断兜底。
|
|
716
751
|
*/
|
|
717
752
|
export function sameRecallTask(previous, current) {
|
|
718
753
|
if (!previous || !current) return true;
|