dsh-lite-memory 0.17.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/client.js +164 -15
- package/docs/BENCH.md +10 -8
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- package/index.js +63 -15
- package/package.json +6 -3
- package/state.js +9 -2
- package/store.js +181 -15
- package/test/client.test.mjs +42 -2
- package/test/contract.test.mjs +12 -9
- package/test/crossing.test.mjs +22 -1
- package/test/evolution-read.test.mjs +23 -0
- package/test/ha.test.mjs +136 -0
- package/test/recall.test.mjs +13 -0
- package/test/record.test.mjs +130 -0
- package/test/safety.test.mjs +119 -0
- package/view.js +11 -0
package/index.js
CHANGED
|
@@ -644,6 +644,8 @@ function refsOf(hits) {
|
|
|
644
644
|
/** 界面要按类型分开展示(偏好/约定/流程/资料),维护字段不进正文。 */
|
|
645
645
|
kind: typeof item.kind === 'string' && item.kind ? item.kind : 'note',
|
|
646
646
|
when: typeof item.when === 'string' ? item.when : '',
|
|
647
|
+
/** 界面要能说「N 天前更新」,只给时间不给正文。 */
|
|
648
|
+
updated: typeof item.updated === 'string' ? item.updated : '',
|
|
647
649
|
}));
|
|
648
650
|
}
|
|
649
651
|
|
|
@@ -657,6 +659,10 @@ const SAVE_PARAMS = {
|
|
|
657
659
|
when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
|
|
658
660
|
kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
|
|
659
661
|
tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
|
|
662
|
+
supersedes: {
|
|
663
|
+
type: 'string',
|
|
664
|
+
description: '要取代的记忆 id:新内容写成新条目,旧条目标为已被取代并保留在历史里;同层才有效,已被取代过的不能再取代',
|
|
665
|
+
},
|
|
660
666
|
resolution: {
|
|
661
667
|
type: 'string',
|
|
662
668
|
enum: ['distinct'],
|
|
@@ -795,6 +801,29 @@ function withTag(value, text) {
|
|
|
795
801
|
return tag ? `${tag} ${text}` : text;
|
|
796
802
|
}
|
|
797
803
|
|
|
804
|
+
/** 候选要能自己解释「什么时候的、为什么像」,模型才好判断是更新还是另一条。 */
|
|
805
|
+
function humanAge(iso) {
|
|
806
|
+
const at = Date.parse(String(iso || ''));
|
|
807
|
+
if (!Number.isFinite(at)) return '';
|
|
808
|
+
const days = Math.floor((Date.now() - at) / 86400000);
|
|
809
|
+
if (days <= 0) return '今天';
|
|
810
|
+
if (days === 1) return '昨天';
|
|
811
|
+
if (days < 30) return `${days} 天前`;
|
|
812
|
+
if (days < 365) return `${Math.floor(days / 30)} 个月前`;
|
|
813
|
+
return `${Math.floor(days / 365)} 年前`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function describeCandidate(item, options = {}) {
|
|
817
|
+
const age = humanAge(item.updated);
|
|
818
|
+
const why = options.sameTopic ? '' : item.reason === 'similar_summary' ? '摘要相近' : '标题相近';
|
|
819
|
+
const parts = [`${item.id}「${item.title}」`];
|
|
820
|
+
if (age) parts.push(`${age}更新`);
|
|
821
|
+
if (item.when) parts.push(item.when);
|
|
822
|
+
if (why) parts.push(why);
|
|
823
|
+
if (item.summary) parts.push(item.summary);
|
|
824
|
+
return parts.join(' · ');
|
|
825
|
+
}
|
|
826
|
+
|
|
798
827
|
function statusLabel(status) {
|
|
799
828
|
if (status === 'archived') return ' · 已归档';
|
|
800
829
|
if (status === 'needs_context') return ' · 适用范围待确认';
|
|
@@ -907,6 +936,16 @@ export function apply(ctx, config) {
|
|
|
907
936
|
return win.bytes + bytes <= view.REQUEST_BYTES;
|
|
908
937
|
}
|
|
909
938
|
|
|
939
|
+
/** 续写轮没有新消息:沿用上一次能核对的请求上下文。 */
|
|
940
|
+
const lastRuntime = new Map();
|
|
941
|
+
const rememberRuntime = (scope, sessionId, held) => {
|
|
942
|
+
if (!held || !sessionId) return;
|
|
943
|
+
const key = `${scope.key}\u0000${sessionId}`;
|
|
944
|
+
lastRuntime.delete(key);
|
|
945
|
+
lastRuntime.set(key, held);
|
|
946
|
+
if (lastRuntime.size > 20) lastRuntime.delete(lastRuntime.keys().next().value);
|
|
947
|
+
};
|
|
948
|
+
|
|
910
949
|
function openWindow(agent, { sessionId, turn, messageIds }) {
|
|
911
950
|
const key = agentKey(agent);
|
|
912
951
|
if (!key) return null;
|
|
@@ -1394,11 +1433,13 @@ export function apply(ctx, config) {
|
|
|
1394
1433
|
const contextId = typeof optionalContextId === 'string' ? optionalContextId.trim() : '';
|
|
1395
1434
|
if (!contextId) {
|
|
1396
1435
|
/** 没有显式上下文时,用本请求已经路由出来的任务;这层不依赖自进化开关。 */
|
|
1397
|
-
const
|
|
1436
|
+
const live = win && win.runtime ? win.runtime : null;
|
|
1437
|
+
const runtime = live
|
|
1438
|
+
|| (win && win.sessionId ? lastRuntime.get(`${scope.key}\u0000${win.sessionId}`) : null);
|
|
1398
1439
|
const sameRequest = runtime
|
|
1399
1440
|
&& runtime.projectKey === scope.key
|
|
1400
1441
|
&& (!runtime.sessionId || runtime.sessionId === String(win.sessionId || ''))
|
|
1401
|
-
&& runtime.batchKey === anchor.requestBatchKey(win.messageIds || []);
|
|
1442
|
+
&& (runtime === live ? runtime.batchKey === anchor.requestBatchKey(win.messageIds || []) : true);
|
|
1402
1443
|
if (sameRequest) {
|
|
1403
1444
|
/** 普通记忆可以直接用本请求上下文;实验正文仍要求显式 contextId,不能顺手放行。 */
|
|
1404
1445
|
return { ok: true, scope, win, context: runtime.context, held: runtime, experimental: false };
|
|
@@ -1585,7 +1626,9 @@ export function apply(ctx, config) {
|
|
|
1585
1626
|
sameTopic: { type: 'boolean' },
|
|
1586
1627
|
similarIds: { type: 'string' },
|
|
1587
1628
|
similar: { type: 'string' },
|
|
1629
|
+
supersededId: { type: 'string' },
|
|
1588
1630
|
archived: { type: 'string' },
|
|
1631
|
+
pendingConflicts: { type: 'integer' },
|
|
1589
1632
|
},
|
|
1590
1633
|
required: ['id', 'title', 'summary', 'when', 'revision', 'merged', 'changed', 'scope', 'needsResolution', 'archived'],
|
|
1591
1634
|
additionalProperties: false,
|
|
@@ -1599,12 +1642,12 @@ export function apply(ctx, config) {
|
|
|
1599
1642
|
? `memory · 当前目录还不能确认属于哪个项目:「${value.title}」\n先确认项目根目录,或显式 scope="global"`
|
|
1600
1643
|
: `memory · 归属还没定:「${value.title}」\n补 memoryClass(personal_preference / project_fact / environment_fact / portable_procedure)或显式 scope;设备事实还要给设备条件`
|
|
1601
1644
|
: value.existingId
|
|
1602
|
-
? `memory · 已有相同记忆:「${value.title}
|
|
1645
|
+
? `memory · 已有相同记忆:「${value.title}」(${value.existingScope})\n要改成另一条,加 resolution="distinct"`
|
|
1603
1646
|
: value.needsResolution
|
|
1604
1647
|
? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
|
|
1605
1648
|
: value.changed === false
|
|
1606
|
-
? `memory · unchanged ${withTag(value, `"${value.title}"`)}
|
|
1607
|
-
: `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}
|
|
1649
|
+
? `memory · unchanged ${withTag(value, `"${value.title}"`)}`
|
|
1650
|
+
: `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextSaved ? ' · 完整原句已另存' : ''}${value.supersededId ? ' · 已取代旧说法' : ''}`,
|
|
1608
1651
|
}],
|
|
1609
1652
|
},
|
|
1610
1653
|
execute: async (args, execution) => {
|
|
@@ -1672,8 +1715,9 @@ export function apply(ctx, config) {
|
|
|
1672
1715
|
crossLayer: Boolean(saved.crossLayer),
|
|
1673
1716
|
sameTopic: Boolean(saved.sameTopic),
|
|
1674
1717
|
similarIds: saved.similarList.map((item) => item.id).join('、'),
|
|
1675
|
-
similar: saved.similarList.map((item) =>
|
|
1718
|
+
similar: saved.similarList.map((item) => describeCandidate(item, { sameTopic: saved.sameTopic })).join(';'),
|
|
1676
1719
|
archived: '',
|
|
1720
|
+
pendingConflicts: Number(saved.pendingConflicts) || 0,
|
|
1677
1721
|
};
|
|
1678
1722
|
}
|
|
1679
1723
|
recordCitations(scope, sessionIdOf(execution), [saved.record]);
|
|
@@ -1693,8 +1737,10 @@ export function apply(ctx, config) {
|
|
|
1693
1737
|
needsScope: false,
|
|
1694
1738
|
needsResolution: false,
|
|
1695
1739
|
similarIds: saved.similarList.map((item) => item.id).join('、'),
|
|
1740
|
+
supersededId: String(args.supersedes || ''),
|
|
1696
1741
|
archived: (saved.archived || []).map((item) => item.title).join('、'),
|
|
1697
|
-
|
|
1742
|
+
pendingConflicts: Number(saved.pendingConflicts) || 0,
|
|
1743
|
+
...(saved.similar ? { similar: `「${saved.similar.title}」` } : {}),
|
|
1698
1744
|
};
|
|
1699
1745
|
},
|
|
1700
1746
|
}), 'lite-memory: memory_save');
|
|
@@ -1721,7 +1767,7 @@ export function apply(ctx, config) {
|
|
|
1721
1767
|
render: (_args, value) => [{
|
|
1722
1768
|
type: 'text',
|
|
1723
1769
|
text: value.asked
|
|
1724
|
-
? `memory · suggested "${value.title}"
|
|
1770
|
+
? `memory · suggested "${value.title}"${value.targetId ? ` · 确认后更新「${value.targetTitle}」` : ''}`
|
|
1725
1771
|
: `memory · skipped${value.reason ? `\n${value.reason}` : ''}`,
|
|
1726
1772
|
}],
|
|
1727
1773
|
},
|
|
@@ -1771,6 +1817,7 @@ export function apply(ctx, config) {
|
|
|
1771
1817
|
origin: { type: 'string' },
|
|
1772
1818
|
kind: { type: 'string' },
|
|
1773
1819
|
when: { type: 'string' },
|
|
1820
|
+
updated: { type: 'string' }, // ui-copy-ignore
|
|
1774
1821
|
},
|
|
1775
1822
|
required: ['id', 'title', 'scope', 'revision', 'status', 'origin', 'kind', 'when'],
|
|
1776
1823
|
additionalProperties: false,
|
|
@@ -1914,11 +1961,11 @@ export function apply(ctx, config) {
|
|
|
1914
1961
|
: value.repeat
|
|
1915
1962
|
? 'memory · 本请求已读过这条记忆'
|
|
1916
1963
|
: value.body
|
|
1917
|
-
? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''}
|
|
1964
|
+
? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''}${statusLabel(value.status)}\n${value.body}`
|
|
1918
1965
|
: value.links !== undefined && value.status === 'ok'
|
|
1919
1966
|
? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
|
|
1920
1967
|
: value.status === 'needs_context'
|
|
1921
|
-
? 'memory ·
|
|
1968
|
+
? 'memory · 这条记忆的适用条件还没确认,先说明当前任务再读'
|
|
1922
1969
|
: value.status === 'not_applicable'
|
|
1923
1970
|
? 'memory · 这条记忆不适用于当前任务'
|
|
1924
1971
|
: value.status === 'archived'
|
|
@@ -2047,12 +2094,12 @@ export function apply(ctx, config) {
|
|
|
2047
2094
|
text: value.mode === 'memory_disabled'
|
|
2048
2095
|
? 'memory · 本项目记忆已关闭,先在面板打开'
|
|
2049
2096
|
: value.mode === 'purged'
|
|
2050
|
-
? `memory · forgotten "${value.title}"
|
|
2097
|
+
? `memory · forgotten "${value.title}"`
|
|
2051
2098
|
: value.mode === 'purge_pending'
|
|
2052
|
-
? `memory · 清除未完成 ·
|
|
2099
|
+
? `memory · 清除未完成 · 「${value.title || '这条记忆'}」,重启后会继续`
|
|
2053
2100
|
: value.mode === 'needs_resolution'
|
|
2054
|
-
? `memory · 先确认是归档还是彻底忘掉 ·
|
|
2055
|
-
: `memory · archived ${withTag(value, `"${value.title}"`)}
|
|
2101
|
+
? `memory · 先确认是归档还是彻底忘掉 · 「${value.title || '这条记忆'}」`
|
|
2102
|
+
: `memory · archived ${withTag(value, `"${value.title}"`)},可找回`,
|
|
2056
2103
|
}],
|
|
2057
2104
|
presentationMeta: (_args, value) => ({
|
|
2058
2105
|
kind: 'memory-forget',
|
|
@@ -2069,7 +2116,7 @@ export function apply(ctx, config) {
|
|
|
2069
2116
|
const located = locate(scope, args.id);
|
|
2070
2117
|
if (!located && !args.mode) throw new Error(`没有 ${args.id} 这条记忆`);
|
|
2071
2118
|
if (!args.mode) {
|
|
2072
|
-
return { id: args.id, mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
|
|
2119
|
+
return { id: args.id, title: located.record.title || '', mode: 'needs_resolution', scope: located.scope.key === 'global' ? 'global' : 'project' };
|
|
2073
2120
|
}
|
|
2074
2121
|
let dropped;
|
|
2075
2122
|
try {
|
|
@@ -2269,6 +2316,7 @@ export function apply(ctx, config) {
|
|
|
2269
2316
|
*/
|
|
2270
2317
|
const runtime = runtimeFor(scope, { sessionId, query, batchKey, messageIds: ids, autoRoute: recallMode() === 'adaptive' });
|
|
2271
2318
|
if (win && runtime) win.runtime = runtime.held;
|
|
2319
|
+
rememberRuntime(scope, sessionId, runtime && runtime.held);
|
|
2272
2320
|
if (recallMode() !== 'adaptive') {
|
|
2273
2321
|
clearPrepared(agent);
|
|
2274
2322
|
return decision;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lite-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"dsh": ">=0.1.5"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs",
|
|
11
|
+
"test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs",
|
|
12
12
|
"bench": "node bench/bench.mjs",
|
|
13
13
|
"bench:evolution": "node bench/evolution.mjs"
|
|
14
14
|
},
|
|
@@ -66,6 +66,9 @@
|
|
|
66
66
|
"bugs": {
|
|
67
67
|
"url": "https://github.com/regrevia/dsh-lite-memory/issues"
|
|
68
68
|
},
|
|
69
|
-
"author":
|
|
69
|
+
"author": {
|
|
70
|
+
"name": "regrevia",
|
|
71
|
+
"url": "https://github.com/regrevia"
|
|
72
|
+
},
|
|
70
73
|
"license": "MIT"
|
|
71
74
|
}
|
package/state.js
CHANGED
|
@@ -535,13 +535,17 @@ export function emptyState(scope) {
|
|
|
535
535
|
candidates: [],
|
|
536
536
|
receipts: [],
|
|
537
537
|
suppressions: [],
|
|
538
|
+
conflicts: [],
|
|
538
539
|
control: { enabled: true, epoch: crypto.randomUUID(), changedAt: now },
|
|
539
540
|
experimental: { projectEvolution: newEvolution() },
|
|
540
541
|
};
|
|
541
542
|
}
|
|
542
543
|
|
|
544
|
+
/** 坏文件不自动覆盖(可能还能人工找回),但错误里必须给出下一步,不能只说「坏了」。 */
|
|
543
545
|
function fail(file, why, code = 'corrupt') {
|
|
544
|
-
|
|
546
|
+
const name = path.basename(file);
|
|
547
|
+
const prev = name === 'state.json' ? 'state.prev.json' : `${name}.prev`;
|
|
548
|
+
return new StateError(code, `记忆状态${why},已停止读写:${file}。恢复:把 ${name} 改名成 ${name}.bad 后重试,会自动从上一份完好的 ${prev} 恢复;${prev} 也不在就删掉整个目录重建(旧文件可人工找回)`);
|
|
545
549
|
}
|
|
546
550
|
|
|
547
551
|
function validEntry(entry) {
|
|
@@ -566,7 +570,7 @@ export function validateState(data) {
|
|
|
566
570
|
if (typeof data[key] !== 'string' || !data[key]) throw new StateError('invalid', `记忆状态缺少 ${key}`);
|
|
567
571
|
}
|
|
568
572
|
if (!Number.isInteger(data.generation) || data.generation < 0) throw new StateError('invalid', '记忆状态 generation 不对');
|
|
569
|
-
for (const key of ['entries', 'candidates', 'receipts', 'suppressions']) {
|
|
573
|
+
for (const key of ['entries', 'candidates', 'receipts', 'suppressions', 'conflicts']) {
|
|
570
574
|
if (!Array.isArray(data[key])) throw new StateError('invalid', `记忆状态 ${key} 不是数组`);
|
|
571
575
|
}
|
|
572
576
|
const ids = new Set();
|
|
@@ -662,9 +666,12 @@ function readEnvelope(file) {
|
|
|
662
666
|
if (upgraded.experimental === undefined) {
|
|
663
667
|
upgraded.experimental = { projectEvolution: { enabled: false, epoch: '', policyVersion: EVOLUTION_POLICY_VERSION, feedbackGeneration: 0, learning: emptyLearning() } };
|
|
664
668
|
}
|
|
669
|
+
if (!Array.isArray(upgraded.conflicts)) upgraded.conflicts = [];
|
|
665
670
|
validateState(upgraded);
|
|
666
671
|
return upgraded;
|
|
667
672
|
}
|
|
673
|
+
/** 旧库没有冲突队列:读时补空数组。 */
|
|
674
|
+
if (!Array.isArray(data.payload.conflicts)) data.payload.conflicts = [];
|
|
668
675
|
validateState(data.payload);
|
|
669
676
|
return data.payload;
|
|
670
677
|
}
|
package/store.js
CHANGED
|
@@ -103,6 +103,10 @@ function toFlat(entry) {
|
|
|
103
103
|
reviewState: maintenance.reviewState || 'active',
|
|
104
104
|
created: entry.createdAt || '',
|
|
105
105
|
updated: entry.updatedAt || '',
|
|
106
|
+
validFrom: typeof entry.validFrom === 'string' && entry.validFrom ? entry.validFrom : (entry.createdAt || ''),
|
|
107
|
+
validUntil: typeof entry.validUntil === 'string' ? entry.validUntil : '',
|
|
108
|
+
supersededBy: typeof entry.supersededBy === 'string' ? entry.supersededBy : '',
|
|
109
|
+
supersedeReason: typeof entry.supersedeReason === 'string' ? entry.supersedeReason : '',
|
|
106
110
|
lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
|
|
107
111
|
revision: Number(entry.revision) || 1,
|
|
108
112
|
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
@@ -130,6 +134,10 @@ function fromFlat(record, status) {
|
|
|
130
134
|
ownerProjectId: record.ownerProjectId || '',
|
|
131
135
|
createdAt: record.created || at,
|
|
132
136
|
updatedAt: at,
|
|
137
|
+
validFrom: typeof record.validFrom === 'string' && record.validFrom ? record.validFrom : (record.created || at),
|
|
138
|
+
validUntil: typeof record.validUntil === 'string' ? record.validUntil : '',
|
|
139
|
+
supersededBy: typeof record.supersededBy === 'string' ? record.supersededBy : '',
|
|
140
|
+
supersedeReason: typeof record.supersedeReason === 'string' ? record.supersedeReason : '',
|
|
133
141
|
current: {
|
|
134
142
|
title: record.title || '',
|
|
135
143
|
summary: record.summary || '',
|
|
@@ -203,6 +211,7 @@ export function loadIndex(scope, options = {}) {
|
|
|
203
211
|
receipts: JSON.parse(JSON.stringify(loaded.state.receipts || [])),
|
|
204
212
|
lastSuggestedAt: loaded.state.lastSuggestedAt || '',
|
|
205
213
|
suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
|
|
214
|
+
conflicts: JSON.parse(JSON.stringify(loaded.state.conflicts || [])),
|
|
206
215
|
__evolution: state.evolutionOf(loaded.state),
|
|
207
216
|
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
208
217
|
__control: state.controlOf(loaded.state),
|
|
@@ -228,6 +237,7 @@ function applyFlat(scope, draft, index) {
|
|
|
228
237
|
draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
|
|
229
238
|
draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
|
|
230
239
|
draft.suppressions = Array.isArray(index.suppressions) ? JSON.parse(JSON.stringify(index.suppressions)) : [];
|
|
240
|
+
draft.conflicts = Array.isArray(index.conflicts) ? JSON.parse(JSON.stringify(index.conflicts)) : [];
|
|
231
241
|
draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
|
|
232
242
|
draft.scopeKey = draft.scopeKey || index.scope;
|
|
233
243
|
}
|
|
@@ -240,9 +250,10 @@ export function saveIndex(scope, index) {
|
|
|
240
250
|
const meta = index.__meta || { exists: false };
|
|
241
251
|
const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
|
|
242
252
|
const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
|
|
243
|
-
const
|
|
253
|
+
const stamp = () => JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.conflicts, draft.lastSuggestedAt]);
|
|
254
|
+
const before = stamp();
|
|
244
255
|
applyFlat(scope, draft, index);
|
|
245
|
-
const after =
|
|
256
|
+
const after = stamp();
|
|
246
257
|
return { changed: before !== after };
|
|
247
258
|
});
|
|
248
259
|
const published = result.state;
|
|
@@ -321,6 +332,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
321
332
|
const has = (key) => Object.prototype.hasOwnProperty.call(draft, key) && draft[key] !== undefined;
|
|
322
333
|
const id = typeof draft.id === 'string' && /^mem_[a-z0-9]+$/.test(draft.id) ? draft.id : '';
|
|
323
334
|
if (has('id') && !id) throw new Error('id 格式不对');
|
|
335
|
+
if (has('supersedes') && !/^mem_[a-z0-9]+$/.test(String(draft.supersedes ?? ''))) throw new Error('supersedes 要给被取代的 id');
|
|
324
336
|
const when = has('when') ? textField(draft.when, LIMITS.whenChars, '前置条件') : '';
|
|
325
337
|
const title = textField(cleanTitle(draft.title), LIMITS.titleChars, '标题');
|
|
326
338
|
/** 带 id 是更新:省略的字段保持原值,标题也一样。 */
|
|
@@ -370,7 +382,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
370
382
|
const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
|
|
371
383
|
/** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
|
|
372
384
|
const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
|
|
373
|
-
if (secret) throw new
|
|
385
|
+
if (secret) throw new state.StateError('unsafe', `疑似凭据(${secret}),只记位置,不记值:正文里写「去哪取、怎么取」,不抄值`);
|
|
374
386
|
return {
|
|
375
387
|
id,
|
|
376
388
|
title,
|
|
@@ -392,6 +404,8 @@ export function normalizeDraft(source, options = {}) {
|
|
|
392
404
|
links,
|
|
393
405
|
applicability,
|
|
394
406
|
topicKey,
|
|
407
|
+
/** 显式取代:新记录写好后再给旧记录打 supersededBy/validUntil。 */
|
|
408
|
+
supersedes: has('supersedes') ? textField(draft.supersedes, 60, '取代目标') : '',
|
|
395
409
|
has: {
|
|
396
410
|
title: has('title') && Boolean(title),
|
|
397
411
|
summary: has('summary'),
|
|
@@ -405,6 +419,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
405
419
|
links: links !== undefined,
|
|
406
420
|
applicability: applicability !== undefined,
|
|
407
421
|
topicKey: topicKey !== undefined,
|
|
422
|
+
supersedes: has('supersedes'),
|
|
408
423
|
},
|
|
409
424
|
};
|
|
410
425
|
}
|
|
@@ -695,9 +710,9 @@ const STOP_GRAMS = new Set([
|
|
|
695
710
|
'的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
|
|
696
711
|
]);
|
|
697
712
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const wordIn =
|
|
713
|
+
/** 英文整词规则只有一份,放在 view:自动召回和手动搜索必须同一个判断。 */
|
|
714
|
+
const asciiWordsOf = view.asciiWordsOf;
|
|
715
|
+
const wordIn = view.wordIn;
|
|
701
716
|
|
|
702
717
|
/** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
|
|
703
718
|
function queryRuns(query) {
|
|
@@ -812,7 +827,16 @@ export function similarEntries(entries, title, summary, limit = 2) {
|
|
|
812
827
|
const titleScore = Math.max(0, ...names.map((name) => similarity(name, title)));
|
|
813
828
|
const summaryScore = similarity(item.summary, summary);
|
|
814
829
|
const score = Math.max(titleScore, summaryScore);
|
|
815
|
-
if (score >= MERGE.near) rows.push({
|
|
830
|
+
if (score >= MERGE.near) rows.push({
|
|
831
|
+
id: item.id,
|
|
832
|
+
title: item.title,
|
|
833
|
+
summary: item.summary,
|
|
834
|
+
when: item.when || '',
|
|
835
|
+
/** 候选要能自己解释「为什么像」:标题相近,还是同一主题槽位。 */
|
|
836
|
+
updated: item.updated || item.created || '',
|
|
837
|
+
reason: titleScore >= MERGE.near ? 'similar_title' : 'similar_summary',
|
|
838
|
+
score,
|
|
839
|
+
});
|
|
816
840
|
}
|
|
817
841
|
rows.sort((a, b) => b.score - a.score);
|
|
818
842
|
return rows.slice(0, limit);
|
|
@@ -946,36 +970,74 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
946
970
|
return next;
|
|
947
971
|
}
|
|
948
972
|
|
|
973
|
+
/** 已被取代或过了有效期:默认检索看不见,明确要 history 时仍可查到并读正文。 */
|
|
974
|
+
function staleRecord(record, now = Date.now()) {
|
|
975
|
+
if (record.supersededBy) return true;
|
|
976
|
+
const until = Date.parse(record.validUntil || '');
|
|
977
|
+
return Number.isFinite(until) && until <= now;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** 给旧记录打上取代链:正文快照交回调用方,放进新记录的 versions 链头。 */
|
|
981
|
+
function stampSupersede(scope, target, nextId, now) {
|
|
982
|
+
if (target.supersededBy) {
|
|
983
|
+
throw new state.StateError('conflict', `这条记忆已经被 ${target.supersededBy} 取代,不能再取代一次`);
|
|
984
|
+
}
|
|
985
|
+
if (target.id === nextId) throw new Error('不能取代自己');
|
|
986
|
+
const versions = snapshot(target, readBody(scope, target.id));
|
|
987
|
+
target.validUntil = now;
|
|
988
|
+
target.supersededBy = nextId;
|
|
989
|
+
target.supersedeReason = 'user_replaced';
|
|
990
|
+
return versions;
|
|
991
|
+
}
|
|
992
|
+
|
|
949
993
|
/**
|
|
950
994
|
* 一次写入的纯业务部分:校验、身份、字节预算、版本。不改盘(正文写入由调用方在同一把锁里做)。
|
|
951
995
|
*/
|
|
952
996
|
function applyDraft(scope, index, source, now, options = {}) {
|
|
953
997
|
const draft = normalizeDraft(source);
|
|
998
|
+
scanUnsafe(draft);
|
|
954
999
|
const foreign = options.foreignEntries || [];
|
|
955
1000
|
const distinct = source && source.resolution === 'distinct';
|
|
1001
|
+
/** 取代/过期记录不参与合并判断:新内容不该再合进一条已经不是现行的记录。 */
|
|
1002
|
+
const live = index.entries.filter((item) => !staleRecord(item));
|
|
1003
|
+
const supersedeTarget = (selfId) => {
|
|
1004
|
+
if (!draft.supersedes) return null;
|
|
1005
|
+
const target = index.entries.find((item) => item.id === draft.supersedes);
|
|
1006
|
+
if (!target) throw new Error(`要取代的记忆不在这一层:${draft.supersedes}`);
|
|
1007
|
+
if (target.id === selfId) throw new Error('不能取代自己');
|
|
1008
|
+
return target;
|
|
1009
|
+
};
|
|
956
1010
|
let record = draft.id ? index.entries.find((item) => item.id === draft.id) : null;
|
|
957
1011
|
if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
|
|
958
1012
|
let mergedFrom = null;
|
|
959
1013
|
let similarList = [];
|
|
960
1014
|
if (!record) {
|
|
961
1015
|
/** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
|
|
962
|
-
const hit = distinct ? null : resolveTarget(
|
|
1016
|
+
const hit = distinct ? null : resolveTarget(live, draft);
|
|
963
1017
|
if (hit) {
|
|
964
1018
|
record = hit.target;
|
|
965
1019
|
mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
|
|
966
1020
|
} else if (draft.has.topicKey && draft.topicKey && !distinct) {
|
|
967
1021
|
/** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
|
|
968
|
-
const slot =
|
|
1022
|
+
const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
|
|
969
1023
|
&& view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
|
|
970
1024
|
if (slot) {
|
|
971
|
-
const row = {
|
|
1025
|
+
const row = {
|
|
1026
|
+
id: slot.id,
|
|
1027
|
+
title: slot.title,
|
|
1028
|
+
summary: slot.summary || '',
|
|
1029
|
+
when: slot.when || '',
|
|
1030
|
+
updated: slot.updated || slot.created || '',
|
|
1031
|
+
reason: 'same_topic',
|
|
1032
|
+
revision: slot.revision || 1,
|
|
1033
|
+
};
|
|
972
1034
|
return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
973
1035
|
}
|
|
974
|
-
similarList = similarEntries(
|
|
1036
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
975
1037
|
} else {
|
|
976
|
-
similarList = similarEntries(
|
|
1038
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
977
1039
|
/** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
|
|
978
|
-
const sameName =
|
|
1040
|
+
const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
|
|
979
1041
|
.some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
|
|
980
1042
|
if (similarList.length > 0 && !distinct && !sameName) {
|
|
981
1043
|
return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
@@ -989,6 +1051,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
989
1051
|
}
|
|
990
1052
|
}
|
|
991
1053
|
if (record) {
|
|
1054
|
+
const target = supersedeTarget(record.id);
|
|
992
1055
|
const oldBody = readBody(scope, record.id);
|
|
993
1056
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
994
1057
|
/** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
|
|
@@ -1009,7 +1072,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1009
1072
|
&& next.applicability === (record.applicability || 'all_in_scope')
|
|
1010
1073
|
&& next.body === oldBody
|
|
1011
1074
|
&& summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
1012
|
-
if (noop) {
|
|
1075
|
+
if (noop && !target) {
|
|
1013
1076
|
return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
|
|
1014
1077
|
}
|
|
1015
1078
|
const versions = snapshot(record, oldBody);
|
|
@@ -1036,10 +1099,18 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1036
1099
|
const archived = assertFits(scope, index, nextRecord);
|
|
1037
1100
|
writeBody(scope, record.id, next.body);
|
|
1038
1101
|
Object.assign(record, nextRecord);
|
|
1039
|
-
|
|
1102
|
+
/** 更新让这条重新成为现行版本:旧的取代链标记要摘掉。 */
|
|
1103
|
+
delete record.supersededBy;
|
|
1104
|
+
record.validUntil = '';
|
|
1105
|
+
if (target) {
|
|
1106
|
+
const chain = stampSupersede(scope, target, record.id, now);
|
|
1107
|
+
record.versions = [...chain, ...(record.versions || [])].slice(0, 2);
|
|
1108
|
+
}
|
|
1109
|
+
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: Boolean(noop), previousBody: oldBody, archived };
|
|
1040
1110
|
}
|
|
1041
1111
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
1042
1112
|
/** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
|
|
1113
|
+
const target = supersedeTarget('');
|
|
1043
1114
|
const createdBody = draft.body;
|
|
1044
1115
|
draft.fullTextInBody = false;
|
|
1045
1116
|
draft.fullTextSaved = Boolean(draft.summaryFull);
|
|
@@ -1062,6 +1133,10 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1062
1133
|
aliases: [],
|
|
1063
1134
|
created: now,
|
|
1064
1135
|
updated: now,
|
|
1136
|
+
validFrom: now,
|
|
1137
|
+
validUntil: '',
|
|
1138
|
+
supersededBy: '',
|
|
1139
|
+
supersedeReason: '',
|
|
1065
1140
|
lastUsed: now,
|
|
1066
1141
|
revision: 1,
|
|
1067
1142
|
status: 'active',
|
|
@@ -1079,9 +1154,42 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1079
1154
|
const archived = assertFits(scope, index, created);
|
|
1080
1155
|
writeBody(scope, created.id, created.body);
|
|
1081
1156
|
index.entries.push(created);
|
|
1157
|
+
if (target) created.versions = stampSupersede(scope, target, created.id, now);
|
|
1082
1158
|
return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
|
|
1083
1159
|
}
|
|
1084
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* 写入前兜底扫描:记忆会被 memory_read 原样喂回模型,所以只拦形状明确的三类——
|
|
1163
|
+
* 提示注入指令、凭据原文、不可见 Unicode;提到「凭据 / 指令」的正文不算。
|
|
1164
|
+
*/
|
|
1165
|
+
const UNSAFE_TEXT = [
|
|
1166
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|rules?|prompts?)/i },
|
|
1167
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /忽略(?:上面|以上|此前|之前)?(?:的)?(?:所有)?(?:指令|指示|要求|规则)/ },
|
|
1168
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /<\/?(?:system|instructions?|prompt|tool_call)[^>]*>/i },
|
|
1169
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /(?:^|\n)\s*(?:system|assistant)\s*[::]/i },
|
|
1170
|
+
];
|
|
1171
|
+
const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
|
|
1172
|
+
const SCAN_FIELD_NAMES = { title: '标题', summary: '摘要', when: '适用场景', body: '正文', tags: '标签', triggers: '触发词', conditions: '条件', selection: '适用范围' };
|
|
1173
|
+
|
|
1174
|
+
function scanUnsafe(draft) {
|
|
1175
|
+
const fields = [
|
|
1176
|
+
['title', draft.title], ['summary', draft.summary], ['when', draft.when], ['body', draft.body],
|
|
1177
|
+
['tags', (draft.tags || []).join('\n')], ['triggers', (draft.triggers || []).join('\n')],
|
|
1178
|
+
['conditions', JSON.stringify(draft.conditions || [])], ['selection', JSON.stringify(draft.selection || null)],
|
|
1179
|
+
];
|
|
1180
|
+
for (const [field, raw] of fields) {
|
|
1181
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
1182
|
+
if (!text) continue;
|
|
1183
|
+
const name = SCAN_FIELD_NAMES[field] || field;
|
|
1184
|
+
if (INVISIBLE.test(text)) {
|
|
1185
|
+
throw new state.StateError('unsafe', `${name}里有看不见的字符(零宽或方向控制);它们会跟着记忆原样回到模型,删掉再存`);
|
|
1186
|
+
}
|
|
1187
|
+
for (const rule of UNSAFE_TEXT) {
|
|
1188
|
+
if (rule.test.test(text)) throw new state.StateError('unsafe', `${name}${rule.why};${rule.fix}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1085
1193
|
/**
|
|
1086
1194
|
* 单条与记录序列化两档预算:新增与更新走同一套。
|
|
1087
1195
|
* 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
|
|
@@ -1359,6 +1467,8 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1359
1467
|
const collect = (pool, archived) => {
|
|
1360
1468
|
for (const item of pool) {
|
|
1361
1469
|
if (!readableRecord(item, options, index)) continue;
|
|
1470
|
+
/** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
|
|
1471
|
+
if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
|
|
1362
1472
|
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1363
1473
|
if (options.allowExperimental === true && options.history !== true
|
|
1364
1474
|
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
@@ -1594,6 +1704,8 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1594
1704
|
summary: record.summary || '',
|
|
1595
1705
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1596
1706
|
revision: record.revision || 1,
|
|
1707
|
+
validUntil: record.validUntil || '',
|
|
1708
|
+
supersededBy: record.supersededBy || '',
|
|
1597
1709
|
status: 'ok',
|
|
1598
1710
|
};
|
|
1599
1711
|
}
|
|
@@ -2353,6 +2465,9 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
|
|
|
2353
2465
|
id: item.id,
|
|
2354
2466
|
title: item.title,
|
|
2355
2467
|
summary: item.summary || '',
|
|
2468
|
+
when: item.when || '',
|
|
2469
|
+
updated: item.updated || '',
|
|
2470
|
+
reason: item.reason || 'similar_title',
|
|
2356
2471
|
revision: item.revision || 1,
|
|
2357
2472
|
scope: other.key === 'global' ? 'global' : 'project',
|
|
2358
2473
|
}));
|
|
@@ -2369,6 +2484,49 @@ function bindSameTopic(projectScope, ref) {
|
|
|
2369
2484
|
return { topicKey: view.topicSlot(record) };
|
|
2370
2485
|
}
|
|
2371
2486
|
|
|
2487
|
+
|
|
2488
|
+
/** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
|
|
2489
|
+
const CONFLICT_LIMIT = 20;
|
|
2490
|
+
|
|
2491
|
+
function queueConflictLocked(scope, draft, candidates) {
|
|
2492
|
+
const index = loadIndex(scope);
|
|
2493
|
+
const title = normalizeText(draft && draft.title, 120);
|
|
2494
|
+
if (!title) return Array.isArray(index.conflicts) ? index.conflicts.length : 0;
|
|
2495
|
+
const scopeKey = scope.key === 'global' ? 'global' : 'project';
|
|
2496
|
+
const now = new Date().toISOString();
|
|
2497
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2498
|
+
const view = candidates.slice(0, 2).map((item) => ({
|
|
2499
|
+
id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
|
|
2500
|
+
}));
|
|
2501
|
+
const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
|
|
2502
|
+
if (hit) {
|
|
2503
|
+
hit.candidates = view;
|
|
2504
|
+
hit.updatedAt = now;
|
|
2505
|
+
} else {
|
|
2506
|
+
rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
|
|
2507
|
+
}
|
|
2508
|
+
index.conflicts = rows.slice(-CONFLICT_LIMIT);
|
|
2509
|
+
saveIndex(scope, index);
|
|
2510
|
+
return index.conflicts.length;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function clearConflictLocked(scope, title) {
|
|
2514
|
+
const index = loadIndex(scope);
|
|
2515
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2516
|
+
const norm = normalizeText(title, 120);
|
|
2517
|
+
const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
|
|
2518
|
+
if (next.length === rows.length) return rows.length;
|
|
2519
|
+
index.conflicts = next;
|
|
2520
|
+
saveIndex(scope, index);
|
|
2521
|
+
return next.length;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/** 当前层还有几条没处理的冲突。 */
|
|
2525
|
+
export function pendingConflictsOf(scope) {
|
|
2526
|
+
const rows = loadIndex(scope).conflicts;
|
|
2527
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2372
2530
|
/**
|
|
2373
2531
|
* 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
|
|
2374
2532
|
* 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
|
|
@@ -2412,6 +2570,7 @@ export function saveEntry(projectScope, source) {
|
|
|
2412
2570
|
}
|
|
2413
2571
|
const near = crossLayerSimilar(projectScope, targetScope, draft);
|
|
2414
2572
|
if (near.length) {
|
|
2573
|
+
const pendingConflicts = queueConflictLocked(targetScope, draft, near);
|
|
2415
2574
|
return {
|
|
2416
2575
|
needsResolution: true,
|
|
2417
2576
|
crossLayer: true,
|
|
@@ -2419,12 +2578,19 @@ export function saveEntry(projectScope, source) {
|
|
|
2419
2578
|
similar: near[0],
|
|
2420
2579
|
draft,
|
|
2421
2580
|
needsScope: false,
|
|
2581
|
+
pendingConflicts,
|
|
2422
2582
|
chosenScope: targetScope.key === 'global' ? 'global' : 'project',
|
|
2423
2583
|
};
|
|
2424
2584
|
}
|
|
2425
2585
|
}
|
|
2426
2586
|
const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
|
|
2427
2587
|
const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
|
|
2588
|
+
if (!result.needsResolution) clearConflictLocked(targetScope, draft ? draft.title : next.title);
|
|
2589
|
+
if (result.needsResolution) {
|
|
2590
|
+
const rows = Array.isArray(result.similarList) ? result.similarList : [];
|
|
2591
|
+
const pendingConflicts = queueConflictLocked(targetScope, result.draft || draft, rows);
|
|
2592
|
+
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode, pendingConflicts };
|
|
2593
|
+
}
|
|
2428
2594
|
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
|
|
2429
2595
|
});
|
|
2430
2596
|
}
|