dsh-lite-memory 0.17.0 → 0.18.19
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 +15 -15
- package/TRIGGERS.md +1 -1
- package/client.js +343 -44
- 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/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +403 -131
- package/learning.js +65 -6
- package/package.json +7 -4
- package/state.js +56 -8
- package/store.js +361 -33
- package/test/client.test.mjs +79 -17
- package/test/contract.test.mjs +231 -93
- package/test/control.test.mjs +2 -2
- package/test/crossing.test.mjs +22 -1
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +33 -10
- package/test/ha.test.mjs +186 -0
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +80 -9
- package/test/record.test.mjs +130 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +97 -13
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,92 @@ 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 = [];
|
|
1014
|
+
/**
|
|
1015
|
+
* 同名就是同一条:标题是唯一句柄,重复存同一个标题必须是更新,不能悄悄另建一条
|
|
1016
|
+
* (否则按标题读、按标题忘都会撞上两条)。前置条件明确不同的同名才允许是另一条。
|
|
1017
|
+
*/
|
|
1018
|
+
if (!record && !distinct) {
|
|
1019
|
+
const wantedConditions = view.conditionsKey(draft.conditions || []);
|
|
1020
|
+
/** 只改摘要/正文时没重写适用条件,别因此把有条件的那条挤成另一条。 */
|
|
1021
|
+
const statedConditions = Boolean(draft.has.when || draft.has.conditions || draft.has.applicability);
|
|
1022
|
+
const named = live.find((item) => [item.title, ...(item.aliases || [])]
|
|
1023
|
+
.some((name) => String(name || '').trim().toLowerCase() === String(draft.title || '').trim().toLowerCase())
|
|
1024
|
+
&& (!statedConditions || view.conditionsKey(item.conditions || []) === wantedConditions)
|
|
1025
|
+
/** 同名但两边都明确写了不同的 when,是用户表达的另一种情况,算另一条。 */
|
|
1026
|
+
&& !(draft.has.when && String(item.when || '') && String(item.when || '') !== String(draft.when || '')));
|
|
1027
|
+
if (named) {
|
|
1028
|
+
record = named;
|
|
1029
|
+
mergedFrom = named.title === draft.title ? null : named.title;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
960
1032
|
if (!record) {
|
|
961
1033
|
/** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
|
|
962
|
-
const hit = distinct ? null : resolveTarget(
|
|
1034
|
+
const hit = distinct ? null : resolveTarget(live, draft);
|
|
963
1035
|
if (hit) {
|
|
964
1036
|
record = hit.target;
|
|
965
1037
|
mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
|
|
966
1038
|
} else if (draft.has.topicKey && draft.topicKey && !distinct) {
|
|
967
1039
|
/** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
|
|
968
|
-
const slot =
|
|
1040
|
+
const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
|
|
969
1041
|
&& view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
|
|
970
1042
|
if (slot) {
|
|
971
|
-
const row = {
|
|
1043
|
+
const row = {
|
|
1044
|
+
id: slot.id,
|
|
1045
|
+
title: slot.title,
|
|
1046
|
+
summary: slot.summary || '',
|
|
1047
|
+
when: slot.when || '',
|
|
1048
|
+
updated: slot.updated || slot.created || '',
|
|
1049
|
+
reason: 'same_topic',
|
|
1050
|
+
revision: slot.revision || 1,
|
|
1051
|
+
};
|
|
972
1052
|
return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
973
1053
|
}
|
|
974
|
-
similarList = similarEntries(
|
|
1054
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
975
1055
|
} else {
|
|
976
|
-
similarList = similarEntries(
|
|
1056
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
977
1057
|
/** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
|
|
978
|
-
const sameName =
|
|
1058
|
+
const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
|
|
979
1059
|
.some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
|
|
980
1060
|
if (similarList.length > 0 && !distinct && !sameName) {
|
|
981
1061
|
return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
@@ -983,12 +1063,20 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
983
1063
|
}
|
|
984
1064
|
}
|
|
985
1065
|
const writeMode = source && source.writeMode === 'replace_current' ? 'replace_current' : 'patch';
|
|
1066
|
+
/** 改名不能撞上另一条现行记忆:标题是唯一句柄,撞了以后按标题读会分不清。 */
|
|
1067
|
+
if (record && draft.has.title && cleanTitle(draft.title) !== record.title) {
|
|
1068
|
+
const wanted = cleanTitle(draft.title).toLowerCase();
|
|
1069
|
+
const clash = live.find((item) => item.id !== record.id
|
|
1070
|
+
&& [item.title, ...(item.aliases || [])].some((name) => String(name || '').trim().toLowerCase() === wanted));
|
|
1071
|
+
if (clash) throw new Error(`已经有同名记忆「${clash.title}」,先改那一条的标题`);
|
|
1072
|
+
}
|
|
986
1073
|
if (record && writeMode === 'replace_current') {
|
|
987
1074
|
for (const key of ['title', 'summary', 'when', 'body', 'kind', 'tags']) {
|
|
988
1075
|
if (!draft.has[key]) throw new Error(`整份替换要给出 ${key},空值也要显式写`);
|
|
989
1076
|
}
|
|
990
1077
|
}
|
|
991
1078
|
if (record) {
|
|
1079
|
+
const target = supersedeTarget(record.id);
|
|
992
1080
|
const oldBody = readBody(scope, record.id);
|
|
993
1081
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
994
1082
|
/** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
|
|
@@ -1009,7 +1097,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1009
1097
|
&& next.applicability === (record.applicability || 'all_in_scope')
|
|
1010
1098
|
&& next.body === oldBody
|
|
1011
1099
|
&& summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
1012
|
-
if (noop) {
|
|
1100
|
+
if (noop && !target) {
|
|
1013
1101
|
return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
|
|
1014
1102
|
}
|
|
1015
1103
|
const versions = snapshot(record, oldBody);
|
|
@@ -1036,10 +1124,18 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1036
1124
|
const archived = assertFits(scope, index, nextRecord);
|
|
1037
1125
|
writeBody(scope, record.id, next.body);
|
|
1038
1126
|
Object.assign(record, nextRecord);
|
|
1039
|
-
|
|
1127
|
+
/** 更新让这条重新成为现行版本:旧的取代链标记要摘掉。 */
|
|
1128
|
+
delete record.supersededBy;
|
|
1129
|
+
record.validUntil = '';
|
|
1130
|
+
if (target) {
|
|
1131
|
+
const chain = stampSupersede(scope, target, record.id, now);
|
|
1132
|
+
record.versions = [...chain, ...(record.versions || [])].slice(0, 2);
|
|
1133
|
+
}
|
|
1134
|
+
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: Boolean(noop), previousBody: oldBody, archived };
|
|
1040
1135
|
}
|
|
1041
1136
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
1042
1137
|
/** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
|
|
1138
|
+
const target = supersedeTarget('');
|
|
1043
1139
|
const createdBody = draft.body;
|
|
1044
1140
|
draft.fullTextInBody = false;
|
|
1045
1141
|
draft.fullTextSaved = Boolean(draft.summaryFull);
|
|
@@ -1062,6 +1158,10 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1062
1158
|
aliases: [],
|
|
1063
1159
|
created: now,
|
|
1064
1160
|
updated: now,
|
|
1161
|
+
validFrom: now,
|
|
1162
|
+
validUntil: '',
|
|
1163
|
+
supersededBy: '',
|
|
1164
|
+
supersedeReason: '',
|
|
1065
1165
|
lastUsed: now,
|
|
1066
1166
|
revision: 1,
|
|
1067
1167
|
status: 'active',
|
|
@@ -1079,9 +1179,42 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1079
1179
|
const archived = assertFits(scope, index, created);
|
|
1080
1180
|
writeBody(scope, created.id, created.body);
|
|
1081
1181
|
index.entries.push(created);
|
|
1182
|
+
if (target) created.versions = stampSupersede(scope, target, created.id, now);
|
|
1082
1183
|
return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
|
|
1083
1184
|
}
|
|
1084
1185
|
|
|
1186
|
+
/**
|
|
1187
|
+
* 写入前兜底扫描:记忆会被 memory_read 原样喂回模型,所以只拦形状明确的三类——
|
|
1188
|
+
* 提示注入指令、凭据原文、不可见 Unicode;提到「凭据 / 指令」的正文不算。
|
|
1189
|
+
*/
|
|
1190
|
+
const UNSAFE_TEXT = [
|
|
1191
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|rules?|prompts?)/i },
|
|
1192
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /忽略(?:上面|以上|此前|之前)?(?:的)?(?:所有)?(?:指令|指示|要求|规则)/ },
|
|
1193
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /<\/?(?:system|instructions?|prompt|tool_call)[^>]*>/i },
|
|
1194
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /(?:^|\n)\s*(?:system|assistant)\s*[::]/i },
|
|
1195
|
+
];
|
|
1196
|
+
const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
|
|
1197
|
+
const SCAN_FIELD_NAMES = { title: '标题', summary: '摘要', when: '适用场景', body: '正文', tags: '标签', triggers: '触发词', conditions: '条件', selection: '适用范围' };
|
|
1198
|
+
|
|
1199
|
+
function scanUnsafe(draft) {
|
|
1200
|
+
const fields = [
|
|
1201
|
+
['title', draft.title], ['summary', draft.summary], ['when', draft.when], ['body', draft.body],
|
|
1202
|
+
['tags', (draft.tags || []).join('\n')], ['triggers', (draft.triggers || []).join('\n')],
|
|
1203
|
+
['conditions', JSON.stringify(draft.conditions || [])], ['selection', JSON.stringify(draft.selection || null)],
|
|
1204
|
+
];
|
|
1205
|
+
for (const [field, raw] of fields) {
|
|
1206
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
1207
|
+
if (!text) continue;
|
|
1208
|
+
const name = SCAN_FIELD_NAMES[field] || field;
|
|
1209
|
+
if (INVISIBLE.test(text)) {
|
|
1210
|
+
throw new state.StateError('unsafe', `${name}里有看不见的字符(零宽或方向控制);它们会跟着记忆原样回到模型,删掉再存`);
|
|
1211
|
+
}
|
|
1212
|
+
for (const rule of UNSAFE_TEXT) {
|
|
1213
|
+
if (rule.test.test(text)) throw new state.StateError('unsafe', `${name}${rule.why};${rule.fix}`);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1085
1218
|
/**
|
|
1086
1219
|
* 单条与记录序列化两档预算:新增与更新走同一套。
|
|
1087
1220
|
* 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
|
|
@@ -1359,6 +1492,8 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1359
1492
|
const collect = (pool, archived) => {
|
|
1360
1493
|
for (const item of pool) {
|
|
1361
1494
|
if (!readableRecord(item, options, index)) continue;
|
|
1495
|
+
/** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
|
|
1496
|
+
if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
|
|
1362
1497
|
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1363
1498
|
if (options.allowExperimental === true && options.history !== true
|
|
1364
1499
|
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
@@ -1443,8 +1578,10 @@ export function peek(scope, query, limit = 3, options = {}) {
|
|
|
1443
1578
|
}
|
|
1444
1579
|
|
|
1445
1580
|
export function readEntry(scope, id, options = {}) {
|
|
1446
|
-
|
|
1447
|
-
|
|
1581
|
+
/** 显式指定层时只读那一层;默认仍只读当前项目和全局。 */
|
|
1582
|
+
const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : runtimeLayers(scope);
|
|
1583
|
+
const found = withLocks(sources, () => {
|
|
1584
|
+
for (const source of sources) {
|
|
1448
1585
|
const index = loadIndex(source);
|
|
1449
1586
|
const record = index.entries.find((item) => item.id === id);
|
|
1450
1587
|
const archived = record ? null : index.archived.find((item) => item.id === id);
|
|
@@ -1465,8 +1602,9 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1465
1602
|
return null;
|
|
1466
1603
|
});
|
|
1467
1604
|
if (!found) return null;
|
|
1468
|
-
/**
|
|
1469
|
-
|
|
1605
|
+
/** 读过才算用过;归档、被取代、过期或条件不匹配的读取都不算「在用」。 */
|
|
1606
|
+
const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
|
|
1607
|
+
if (!found.archived && countsAsUsage) touchUsed(found.source, [id]);
|
|
1470
1608
|
return found.record;
|
|
1471
1609
|
}
|
|
1472
1610
|
|
|
@@ -1477,11 +1615,14 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1477
1615
|
export function forget(scope, id, options = {}) {
|
|
1478
1616
|
const mode = options.mode;
|
|
1479
1617
|
if (mode !== 'archive' && mode !== 'purge') return { id, mode: 'needs_resolution' };
|
|
1480
|
-
|
|
1618
|
+
/** 显式指定的层就是唯一范围;不然才按运行时「当前项目 + 全局」找。 */
|
|
1619
|
+
const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : null;
|
|
1620
|
+
const scopeList = sources || [scope, globalScope()];
|
|
1621
|
+
return withLocks(scopeList, () => {
|
|
1481
1622
|
/** 屏障优先:上次彻底删除没做完时,先按原层 + 原 operationId 收尾,再决定这条在哪。 */
|
|
1482
1623
|
let replayed = null;
|
|
1483
1624
|
if (mode === 'purge') {
|
|
1484
|
-
for (const source of
|
|
1625
|
+
for (const source of scopeList) {
|
|
1485
1626
|
const pending = state.purgeIntent(source);
|
|
1486
1627
|
if (!pending) continue;
|
|
1487
1628
|
/** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
|
|
@@ -1502,7 +1643,7 @@ export function forget(scope, id, options = {}) {
|
|
|
1502
1643
|
}
|
|
1503
1644
|
if (replayed) return replayed;
|
|
1504
1645
|
}
|
|
1505
|
-
for (const source of
|
|
1646
|
+
for (const source of scopeList) {
|
|
1506
1647
|
const index = loadIndex(source);
|
|
1507
1648
|
const active = index.entries.find((item) => item.id === id);
|
|
1508
1649
|
const archived = active ? null : index.archived.find((item) => item.id === id);
|
|
@@ -1554,8 +1695,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1554
1695
|
|
|
1555
1696
|
/** 直接读邻居:只给当前投影,不缓存目标正文;目标不在了就标 invalid,不拿旧版本顶上。 */
|
|
1556
1697
|
export function readLinks(scope, id, options = {}) {
|
|
1557
|
-
return withLocks(
|
|
1558
|
-
for (const source of
|
|
1698
|
+
return withLocks(runtimeLayers(scope), () => {
|
|
1699
|
+
for (const source of runtimeLayers(scope)) {
|
|
1559
1700
|
const index = loadIndex(source);
|
|
1560
1701
|
const record = index.entries.find((item) => item.id === id);
|
|
1561
1702
|
if (!record) continue;
|
|
@@ -1574,7 +1715,7 @@ export function readLinks(scope, id, options = {}) {
|
|
|
1574
1715
|
|
|
1575
1716
|
/** 关联目标按当前投影现场解引用:先项目层再全局层,归档和删除都算不存在。 */
|
|
1576
1717
|
function findCurrent(id, scope, options = {}) {
|
|
1577
|
-
for (const source of
|
|
1718
|
+
for (const source of runtimeLayers(scope)) {
|
|
1578
1719
|
let index;
|
|
1579
1720
|
try {
|
|
1580
1721
|
index = loadIndex(source);
|
|
@@ -1594,6 +1735,8 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1594
1735
|
summary: record.summary || '',
|
|
1595
1736
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1596
1737
|
revision: record.revision || 1,
|
|
1738
|
+
validUntil: record.validUntil || '',
|
|
1739
|
+
supersededBy: record.supersededBy || '',
|
|
1597
1740
|
status: 'ok',
|
|
1598
1741
|
};
|
|
1599
1742
|
}
|
|
@@ -1711,9 +1854,51 @@ export function findAnywhere(id) {
|
|
|
1711
1854
|
return null;
|
|
1712
1855
|
}
|
|
1713
1856
|
|
|
1857
|
+
/** 运行时只允许当前项目和全局层:别的项目仅供人类管理面只读枚举。 */
|
|
1858
|
+
export function runtimeLayers(scope) {
|
|
1859
|
+
const out = [];
|
|
1860
|
+
const seen = new Set();
|
|
1861
|
+
const push = (item) => {
|
|
1862
|
+
if (!item || !item.dir || seen.has(item.dir)) return;
|
|
1863
|
+
seen.add(item.dir);
|
|
1864
|
+
out.push(item);
|
|
1865
|
+
};
|
|
1866
|
+
push(scope);
|
|
1867
|
+
push(globalScope());
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1714
1871
|
/** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
|
|
1872
|
+
/**
|
|
1873
|
+
* 标题是唯一对外的句柄:当前项目找不到,就去全局和别的项目层找。
|
|
1874
|
+
* 只用于读路径(读正文、查关联、解析标题);写入仍然只动自己那层。
|
|
1875
|
+
*/
|
|
1876
|
+
export function layersFor(scope) {
|
|
1877
|
+
const out = [];
|
|
1878
|
+
const seen = new Set();
|
|
1879
|
+
const push = (item) => {
|
|
1880
|
+
if (!item || !item.dir || seen.has(item.dir)) return;
|
|
1881
|
+
seen.add(item.dir);
|
|
1882
|
+
out.push(item);
|
|
1883
|
+
};
|
|
1884
|
+
push(scope);
|
|
1885
|
+
push(globalScope());
|
|
1886
|
+
try {
|
|
1887
|
+
for (const row of state.knownProjectScopes()) push({ key: row.key, dir: row.dir, cwd: row.cwd });
|
|
1888
|
+
} catch { /* 层列表读不出来就只查当前层和全局,不影响读取 */ }
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1715
1892
|
export function locate(scope, id, options = {}) {
|
|
1716
|
-
|
|
1893
|
+
/**
|
|
1894
|
+
* 默认只看当前层和全局:面板/HTTP 用 id 探正文时跨层就是越权。
|
|
1895
|
+
* 工具侧按标题读,明确传 crossLayer 才跨层(标题是用户给的句柄);
|
|
1896
|
+
* 显式指定某一层时只给那一层,绝不拿项目层去命中全局条目。
|
|
1897
|
+
*/
|
|
1898
|
+
const sources = Array.isArray(options.layers) && options.layers.length
|
|
1899
|
+
? options.layers
|
|
1900
|
+
: options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
|
|
1901
|
+
for (const source of sources) {
|
|
1717
1902
|
const index = loadIndex(source, options);
|
|
1718
1903
|
const active = index.entries.find((item) => item.id === id);
|
|
1719
1904
|
if (active) return { scope: source, record: active, archived: false };
|
|
@@ -1723,6 +1908,35 @@ export function locate(scope, id, options = {}) {
|
|
|
1723
1908
|
return null;
|
|
1724
1909
|
}
|
|
1725
1910
|
|
|
1911
|
+
/**
|
|
1912
|
+
* 标题是界面和模型唯一的句柄:在给定范围里按标题(含别名)找那一条。
|
|
1913
|
+
* 同层可能同名但适用条件不同,所以带了版次就先按版次收敛,再退回第一条。
|
|
1914
|
+
*/
|
|
1915
|
+
export function locateByTitle(scope, title, options = {}) {
|
|
1916
|
+
const wanted = normalizeText(title, 120).toLowerCase();
|
|
1917
|
+
if (!wanted) return null;
|
|
1918
|
+
const sources = options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
|
|
1919
|
+
const want = Number.isInteger(options.revision) ? options.revision : null;
|
|
1920
|
+
for (const source of sources) {
|
|
1921
|
+
const index = loadIndex(source, options);
|
|
1922
|
+
const pick = (list) => {
|
|
1923
|
+
const rows = list.filter((item) => [item.title, ...(Array.isArray(item.aliases) ? item.aliases : [])]
|
|
1924
|
+
.some((name) => normalizeText(name, 120).toLowerCase() === wanted));
|
|
1925
|
+
if (rows.length === 0) return null;
|
|
1926
|
+
if (want !== null) {
|
|
1927
|
+
const exact = rows.find((item) => (Number(item.revision) || 1) === want);
|
|
1928
|
+
if (exact) return exact;
|
|
1929
|
+
}
|
|
1930
|
+
return rows[0];
|
|
1931
|
+
};
|
|
1932
|
+
const active = pick(index.entries);
|
|
1933
|
+
if (active) return { scope: source, record: active, archived: false };
|
|
1934
|
+
const archived = pick(index.archived);
|
|
1935
|
+
if (archived) return { scope: source, record: archived, archived: true };
|
|
1936
|
+
}
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1726
1940
|
function forgottenFingerprints(index) {
|
|
1727
1941
|
const out = new Set();
|
|
1728
1942
|
for (const row of index.suppressions || []) {
|
|
@@ -2002,7 +2216,7 @@ export function renderIndex(entries, budget = 400) {
|
|
|
2002
2216
|
const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
|
|
2003
2217
|
const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
|
|
2004
2218
|
const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
|
|
2005
|
-
const head = `${tag ? `${tag} ` : ''}
|
|
2219
|
+
const head = `${tag ? `${tag} ` : ''}「${item.title}」${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
|
|
2006
2220
|
const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
|
|
2007
2221
|
if (used + line.length > budget) break;
|
|
2008
2222
|
lines.push(line);
|
|
@@ -2176,6 +2390,30 @@ export function readCitationBody(sessionId, id) {
|
|
|
2176
2390
|
const data = readSessionFile(sessionId);
|
|
2177
2391
|
const row = ((data && data.items) || []).find((item) => item && item.id === id);
|
|
2178
2392
|
if (!row) return null;
|
|
2393
|
+
return readCitationRow(sessionId, row);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** 按标题读引用正文:界面手里只有标题,id 留在引用文件里。 */
|
|
2397
|
+
export function readCitationBodyByTitle(sessionId, title, options = {}) {
|
|
2398
|
+
const wanted = normalizeText(title, 120).toLowerCase();
|
|
2399
|
+
if (!wanted) return null;
|
|
2400
|
+
const data = readSessionFile(sessionId);
|
|
2401
|
+
const want = Number.isInteger(options.revision) ? options.revision : null;
|
|
2402
|
+
const scopeWanted = options.scope === 'global' || options.scope === 'project' ? options.scope : '';
|
|
2403
|
+
/** 引用行只存身份,标题要从当前索引解析出来再比;行上没有 title 字段。 */
|
|
2404
|
+
const matches = ((data && data.items) || [])
|
|
2405
|
+
.map((row) => ({ row, found: resolveCitation(row) }))
|
|
2406
|
+
.filter((item) => item.found
|
|
2407
|
+
&& (!scopeWanted || (item.found.scope.key === 'global' ? 'global' : 'project') === scopeWanted)
|
|
2408
|
+
&& normalizeText(item.found.row.title || '', 120).toLowerCase() === wanted);
|
|
2409
|
+
if (matches.length === 0) return null;
|
|
2410
|
+
const chosen = (want !== null
|
|
2411
|
+
&& matches.find((item) => (Number(item.found.entry.revision) || 1) === want)) || matches[0];
|
|
2412
|
+
return readCitationRow(sessionId, chosen.row);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function readCitationRow(sessionId, row) {
|
|
2416
|
+
const id = row.id;
|
|
2179
2417
|
const found = resolveCitation(row);
|
|
2180
2418
|
if (!found) return null;
|
|
2181
2419
|
let body;
|
|
@@ -2188,6 +2426,7 @@ export function readCitationBody(sessionId, id) {
|
|
|
2188
2426
|
}
|
|
2189
2427
|
return {
|
|
2190
2428
|
...found.row,
|
|
2429
|
+
status: found.entry.status || 'active',
|
|
2191
2430
|
scopeLabel: found.scope.key === 'global' ? '全局' : path.basename(found.scope.cwd || '') || found.scope.key,
|
|
2192
2431
|
body: body.slice(0, LIMITS.entryBytes),
|
|
2193
2432
|
};
|
|
@@ -2353,28 +2592,90 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
|
|
|
2353
2592
|
id: item.id,
|
|
2354
2593
|
title: item.title,
|
|
2355
2594
|
summary: item.summary || '',
|
|
2595
|
+
when: item.when || '',
|
|
2596
|
+
updated: item.updated || '',
|
|
2597
|
+
reason: item.reason || 'similar_title',
|
|
2356
2598
|
revision: item.revision || 1,
|
|
2357
2599
|
scope: other.key === 'global' ? 'global' : 'project',
|
|
2358
2600
|
}));
|
|
2359
2601
|
}
|
|
2360
2602
|
|
|
2361
2603
|
function bindSameTopic(projectScope, ref) {
|
|
2362
|
-
if (!ref || typeof ref !== 'object'
|
|
2604
|
+
if (!ref || typeof ref !== 'object') return { error: 'sameTopicAs 要写成 {scope, title}' };
|
|
2363
2605
|
const source = ref.scope === 'global' ? globalScope() : projectScope;
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2606
|
+
/** 句柄统一用标题:id 是底层实现,模型手里不该出现;老调用传 id 仍认。 */
|
|
2607
|
+
const byTitle = typeof ref.title === 'string' ? normalizeText(ref.title, 120).toLowerCase() : '';
|
|
2608
|
+
const record = loadIndex(source).entries.find((item) => (typeof ref.id === 'string' && item.id === ref.id)
|
|
2609
|
+
|| (byTitle && [item.title, ...(item.aliases || [])].some((name) => normalizeText(name, 120).toLowerCase() === byTitle)));
|
|
2610
|
+
if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.title || ref.id || ''}` };
|
|
2611
|
+
if (ref.revision !== undefined && Number(ref.revision) !== Number(record.revision || 1)) {
|
|
2612
|
+
return { error: `sameTopicAs 的目标已经改过:「${record.title}」` };
|
|
2368
2613
|
}
|
|
2369
2614
|
return { topicKey: view.topicSlot(record) };
|
|
2370
2615
|
}
|
|
2371
2616
|
|
|
2617
|
+
|
|
2618
|
+
/** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
|
|
2619
|
+
const CONFLICT_LIMIT = 20;
|
|
2620
|
+
|
|
2621
|
+
function queueConflictLocked(scope, draft, candidates) {
|
|
2622
|
+
const index = loadIndex(scope);
|
|
2623
|
+
const title = normalizeText(draft && draft.title, 120);
|
|
2624
|
+
if (!title) return Array.isArray(index.conflicts) ? index.conflicts.length : 0;
|
|
2625
|
+
const scopeKey = scope.key === 'global' ? 'global' : 'project';
|
|
2626
|
+
const now = new Date().toISOString();
|
|
2627
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2628
|
+
const view = candidates.slice(0, 2).map((item) => ({
|
|
2629
|
+
id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
|
|
2630
|
+
}));
|
|
2631
|
+
const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
|
|
2632
|
+
if (hit) {
|
|
2633
|
+
hit.candidates = view;
|
|
2634
|
+
hit.updatedAt = now;
|
|
2635
|
+
} else {
|
|
2636
|
+
rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
|
|
2637
|
+
}
|
|
2638
|
+
index.conflicts = rows.slice(-CONFLICT_LIMIT);
|
|
2639
|
+
saveIndex(scope, index);
|
|
2640
|
+
return index.conflicts.length;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
function clearConflictLocked(scope, title) {
|
|
2644
|
+
const index = loadIndex(scope);
|
|
2645
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2646
|
+
const norm = normalizeText(title, 120);
|
|
2647
|
+
const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
|
|
2648
|
+
if (next.length === rows.length) return rows.length;
|
|
2649
|
+
index.conflicts = next;
|
|
2650
|
+
saveIndex(scope, index);
|
|
2651
|
+
return next.length;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/** 当前层还有几条没处理的冲突。 */
|
|
2655
|
+
export function pendingConflictsOf(scope) {
|
|
2656
|
+
const rows = loadIndex(scope).conflicts;
|
|
2657
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
2658
|
+
}
|
|
2659
|
+
|
|
2372
2660
|
/**
|
|
2373
2661
|
* 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
|
|
2374
2662
|
* 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
|
|
2375
2663
|
*/
|
|
2664
|
+
function saveTargetOf(projectScope, source) {
|
|
2665
|
+
/** 更新已有条目:先按 id 认出它到底在哪一层,只锁那一层。 */
|
|
2666
|
+
const id = source && typeof source.id === 'string' ? source.id.trim() : '';
|
|
2667
|
+
if (id) {
|
|
2668
|
+
const found = locateInLayers(projectScope, id);
|
|
2669
|
+
if (found) return found.scope;
|
|
2670
|
+
}
|
|
2671
|
+
/** 新增条目:归属由路由定,写全局就不该占住项目锁,反之亦然。 */
|
|
2672
|
+
const routed = routeScope(source, { project: projectScope });
|
|
2673
|
+
return routed.chosenScope === 'global' ? globalScope() : projectScope;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2376
2676
|
export function saveEntry(projectScope, source) {
|
|
2377
|
-
|
|
2677
|
+
/** 只锁真正要写的那一层:别的项目写它自己的条目时,不会因为共享全局锁互相等待。 */
|
|
2678
|
+
return withLocks([saveTargetOf(projectScope, source)], () => {
|
|
2378
2679
|
const control = loadIndex(projectScope).__control || state.controlOf(null);
|
|
2379
2680
|
if (!control.enabled) {
|
|
2380
2681
|
return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
|
|
@@ -2396,7 +2697,23 @@ export function saveEntry(projectScope, source) {
|
|
|
2396
2697
|
if (next.id) {
|
|
2397
2698
|
const found = locateInLayers(projectScope, next.id);
|
|
2398
2699
|
if (!found) throw new Error('没有这条记忆,改用新增');
|
|
2700
|
+
/** 写了 scope 就只改那一层:同名条目在别层时不能悄悄改到别处。 */
|
|
2701
|
+
const explicitScope = String(source.scope || '').trim();
|
|
2702
|
+
if (explicitScope === 'global' && found.scope.key !== 'global') {
|
|
2703
|
+
throw new Error('这条记忆在项目层;scope="global" 只改全局同名条目');
|
|
2704
|
+
}
|
|
2705
|
+
if (explicitScope === 'project' && found.scope.key === 'global') {
|
|
2706
|
+
throw new Error('这条记忆在全局层;只有明确说「所有项目都这样」才改它');
|
|
2707
|
+
}
|
|
2399
2708
|
targetScope = found.scope;
|
|
2709
|
+
/** 面板拿旧版本改正文:中间被别处改过就拒绝,不覆盖别人的修改。 */
|
|
2710
|
+
if (source.expectedRevision !== undefined) {
|
|
2711
|
+
const current = loadIndex(targetScope).entries.find((item) => item.id === next.id && item.status !== 'archived');
|
|
2712
|
+
if (!current) throw new Error('没有这条记忆,改用新增');
|
|
2713
|
+
if (!Number.isInteger(Number(source.expectedRevision)) || Number(current.revision) !== Number(source.expectedRevision)) {
|
|
2714
|
+
throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2400
2717
|
}
|
|
2401
2718
|
/** 更新已有项目条目也必须落在确认过的根目录里。 */
|
|
2402
2719
|
if (targetScope.key !== 'global' && targetScope.confirmed === false) {
|
|
@@ -2412,19 +2729,29 @@ export function saveEntry(projectScope, source) {
|
|
|
2412
2729
|
}
|
|
2413
2730
|
const near = crossLayerSimilar(projectScope, targetScope, draft);
|
|
2414
2731
|
if (near.length) {
|
|
2732
|
+
const pendingConflicts = queueConflictLocked(targetScope, draft, near);
|
|
2415
2733
|
return {
|
|
2416
2734
|
needsResolution: true,
|
|
2417
2735
|
crossLayer: true,
|
|
2736
|
+
/** 相近那条在哪一层:跨层「用同一个标题再存一次」得落在它那层,否则会另建一条。 */
|
|
2737
|
+
crossScope: near[0] && near[0].scope === 'global' ? 'global' : 'project',
|
|
2418
2738
|
similarList: near,
|
|
2419
2739
|
similar: near[0],
|
|
2420
2740
|
draft,
|
|
2421
2741
|
needsScope: false,
|
|
2742
|
+
pendingConflicts,
|
|
2422
2743
|
chosenScope: targetScope.key === 'global' ? 'global' : 'project',
|
|
2423
2744
|
};
|
|
2424
2745
|
}
|
|
2425
2746
|
}
|
|
2426
2747
|
const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
|
|
2427
2748
|
const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
|
|
2749
|
+
if (!result.needsResolution) clearConflictLocked(targetScope, draft ? draft.title : next.title);
|
|
2750
|
+
if (result.needsResolution) {
|
|
2751
|
+
const rows = Array.isArray(result.similarList) ? result.similarList : [];
|
|
2752
|
+
const pendingConflicts = queueConflictLocked(targetScope, result.draft || draft, rows);
|
|
2753
|
+
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode, pendingConflicts };
|
|
2754
|
+
}
|
|
2428
2755
|
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
|
|
2429
2756
|
});
|
|
2430
2757
|
}
|
|
@@ -2760,6 +3087,7 @@ function learningRuleView(record) {
|
|
|
2760
3087
|
conditions: Array.isArray(record.conditions) ? record.conditions : [],
|
|
2761
3088
|
topicKey: record.topicKey || '',
|
|
2762
3089
|
origin: record.origin,
|
|
3090
|
+
reviewState: record.reviewState === 'legacy_unreviewed' ? 'legacy_unreviewed' : 'active',
|
|
2763
3091
|
state: record.learning?.state || 'pending',
|
|
2764
3092
|
samples: (Array.isArray(record.learning?.samples) ? record.learning.samples : []).slice(-4).map((row) => ({
|
|
2765
3093
|
dimension: row.dimension || '作品',
|