dsh-lite-memory 0.11.1 → 0.15.0
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 +44 -18
- package/anchor.js +110 -13
- package/client.js +1078 -117
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +579 -78
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +107 -8
- package/store.js +302 -29
- package/test/anchor.test.mjs +221 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +278 -0
- package/test/evolution-read.test.mjs +63 -8
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +11 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +25 -3
- package/view.js +16 -1
package/store.js
CHANGED
|
@@ -96,6 +96,7 @@ function toFlat(entry) {
|
|
|
96
96
|
revision: Number(entry.revision) || 1,
|
|
97
97
|
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
98
98
|
origin: entry.origin || 'user_explicit',
|
|
99
|
+
ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
|
|
99
100
|
hits: Number(maintenance.hits) || 0,
|
|
100
101
|
merges: Number(maintenance.merges) || 0,
|
|
101
102
|
versions: Array.isArray(maintenance.versions) ? cloneJson(maintenance.versions) : [],
|
|
@@ -115,6 +116,7 @@ function fromFlat(record, status) {
|
|
|
115
116
|
revision: Number(record.revision) || 1,
|
|
116
117
|
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
117
118
|
origin: typeof record.origin === 'string' && record.origin ? record.origin : 'user_explicit',
|
|
119
|
+
ownerProjectId: record.ownerProjectId || '',
|
|
118
120
|
createdAt: record.created || at,
|
|
119
121
|
updatedAt: at,
|
|
120
122
|
current: {
|
|
@@ -154,7 +156,7 @@ function fromFlat(record, status) {
|
|
|
154
156
|
* 由 state.js 拦成报告,这里不猜、不补空正文。
|
|
155
157
|
*/
|
|
156
158
|
export function loadIndex(scope, options = {}) {
|
|
157
|
-
if (!fs.existsSync(path.join(scope.dir, 'state.json'))) {
|
|
159
|
+
if (!options.readOnly && !fs.existsSync(path.join(scope.dir, 'state.json'))) {
|
|
158
160
|
const report = state.migrationReport(scope);
|
|
159
161
|
if (report.required || report.adoptable.length) state.migrateLegacy(scope);
|
|
160
162
|
}
|
|
@@ -176,6 +178,7 @@ export function loadIndex(scope, options = {}) {
|
|
|
176
178
|
suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
|
|
177
179
|
__evolution: state.evolutionOf(loaded.state),
|
|
178
180
|
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
181
|
+
__control: state.controlOf(loaded.state),
|
|
179
182
|
__exists: loaded.exists === true,
|
|
180
183
|
};
|
|
181
184
|
index.__meta = {
|
|
@@ -273,6 +276,14 @@ function cleanTitle(value) {
|
|
|
273
276
|
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
274
277
|
}
|
|
275
278
|
|
|
279
|
+
/** 正文里的实现参数(尺寸/层级/字号)不算记忆:数到三次就要求重写成原则。 */
|
|
280
|
+
const UI_SPEC_PATTERNS = [
|
|
281
|
+
/\bz-index\s*:\s*-?\d+/gi,
|
|
282
|
+
/\b(?:padding|margin|gap|font-size|line-height|border-radius|width|height|min-width|max-width|min-height|max-height|box-shadow)\s*:[^;\n]{0,32}\d/gi,
|
|
283
|
+
/\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi,
|
|
284
|
+
/\bmin\(\s*\d/gi,
|
|
285
|
+
];
|
|
286
|
+
|
|
276
287
|
export function validSummary(summary) {
|
|
277
288
|
const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
|
|
278
289
|
if (!text) return { ok: false, reason: '摘要为空' };
|
|
@@ -311,7 +322,11 @@ export function normalizeDraft(source, options = {}) {
|
|
|
311
322
|
const body = String(draft.body ?? '').trim();
|
|
312
323
|
const bodyBytes = Buffer.byteLength(body);
|
|
313
324
|
if (bodyBytes > LIMITS.entryBytes) {
|
|
314
|
-
throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes}
|
|
325
|
+
throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
326
|
+
}
|
|
327
|
+
if (has('body')) {
|
|
328
|
+
const concrete = UI_SPEC_PATTERNS.reduce((count, pattern) => count + (body.match(pattern) || []).length, 0);
|
|
329
|
+
if (concrete >= 3) throw new Error('正文太具体:记忆只留原则和触发条件,像素、字号、层级这类实现参数留在代码或文档里');
|
|
315
330
|
}
|
|
316
331
|
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
317
332
|
if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
|
|
@@ -434,6 +449,17 @@ function bigrams(text) {
|
|
|
434
449
|
return out;
|
|
435
450
|
}
|
|
436
451
|
|
|
452
|
+
/** 只靠这类高频两字词重合不算相关:查询里的实词必须自己命中。 */
|
|
453
|
+
const STOP_GRAMS = new Set([
|
|
454
|
+
'这个', '那个', '怎么', '什么', '可以', '需要', '我们', '你们', '一下', '现在',
|
|
455
|
+
'然后', '还是', '就是', '已经', '没有', '不是', '要是', '如果', '因为', '所以',
|
|
456
|
+
'的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
const asciiWordsOf = (text) => normalizeText(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
|
|
460
|
+
|
|
461
|
+
const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
|
|
462
|
+
|
|
437
463
|
/** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
|
|
438
464
|
function queryRuns(query) {
|
|
439
465
|
return normalizeText(query, 400)
|
|
@@ -463,6 +489,13 @@ function matchesRun(item, runs) {
|
|
|
463
489
|
let qualified = 0;
|
|
464
490
|
let substantial = 0;
|
|
465
491
|
for (const run of runs) {
|
|
492
|
+
/** 英文按整词匹配:git 不能因为共享 gi/it 就命中 digital。 */
|
|
493
|
+
if (/^[a-z][a-z0-9_-]+$/.test(run)) {
|
|
494
|
+
if (!wordIn(pool, run)) continue;
|
|
495
|
+
qualified += 1;
|
|
496
|
+
substantial += 1;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
466
499
|
const grams = bigrams(run);
|
|
467
500
|
if (grams.size === 0) continue;
|
|
468
501
|
let hit = 0;
|
|
@@ -842,23 +875,66 @@ function scoreQuery(item, grams, raw) {
|
|
|
842
875
|
[(item.triggers || []).join(' '), MATCH_WEIGHT.tags],
|
|
843
876
|
];
|
|
844
877
|
let score = 0;
|
|
878
|
+
let substantive = 0;
|
|
845
879
|
for (const [text, weight] of fields) {
|
|
846
880
|
if (!text) continue;
|
|
847
881
|
const pool = bigrams(text);
|
|
848
882
|
let shared = 0;
|
|
849
|
-
for (const gram of grams)
|
|
883
|
+
for (const gram of grams) {
|
|
884
|
+
if (!pool.has(gram)) continue;
|
|
885
|
+
shared += 1;
|
|
886
|
+
if (!STOP_GRAMS.has(gram)) substantive += 1;
|
|
887
|
+
}
|
|
850
888
|
score += shared * weight;
|
|
851
889
|
}
|
|
852
890
|
const plain = normalizeText(raw, 200).toLowerCase().replace(/\s+/g, '');
|
|
853
891
|
if (plain && normalizeText(item.title, 200).toLowerCase().replace(/\s+/g, '').includes(plain)) score += 5;
|
|
854
892
|
/** 触发词命中按整词算:用户说的那句话里带着它,就该搜得到。 */
|
|
855
|
-
|
|
893
|
+
const triggerHit = Boolean(plain) && (item.triggers || []).some((trigger) => {
|
|
856
894
|
const text = normalizeText(trigger, 200).toLowerCase().replace(/\s+/g, '');
|
|
857
895
|
return Boolean(text) && plain.includes(text);
|
|
858
|
-
})
|
|
896
|
+
});
|
|
897
|
+
if (triggerHit) score += 4;
|
|
898
|
+
/** 纯英文查询必须按整词命中,避免 git 命中 digital 这种字面巧合;混合查询仍按中文实词判断。 */
|
|
899
|
+
const words = asciiWordsOf(raw);
|
|
900
|
+
const hasCjk = /[\u4e00-\u9fff]/.test(String(raw || ''));
|
|
901
|
+
if (words.length && !hasCjk) {
|
|
902
|
+
const pool = fields.map(([text]) => fieldText(text)).join('\n');
|
|
903
|
+
if (!words.some((word) => wordIn(pool, word))) return 0;
|
|
904
|
+
}
|
|
905
|
+
/** 只重合了「这个」「怎么」这类词,不算命中;触发词命中仍算。 */
|
|
906
|
+
if (substantive === 0 && !triggerHit && words.length === 0) return 0;
|
|
859
907
|
return score;
|
|
860
908
|
}
|
|
861
909
|
|
|
910
|
+
/** 项目记忆总开关:读当前层,缺字段按开启。 */
|
|
911
|
+
export function controlStatus(scope) {
|
|
912
|
+
const index = loadIndex(scope);
|
|
913
|
+
return { ...(index.__control || state.controlOf(null)) };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export function setMemoryEnabled(scope, enabled, options = {}) {
|
|
917
|
+
return withLock(scope, () => {
|
|
918
|
+
const before = loadIndex(scope);
|
|
919
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
920
|
+
let applied = null;
|
|
921
|
+
try {
|
|
922
|
+
state.transact(scope, {
|
|
923
|
+
operationId,
|
|
924
|
+
expectCreate: !before.__exists,
|
|
925
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
926
|
+
}, (draft) => {
|
|
927
|
+
applied = state.applyControl(draft, enabled);
|
|
928
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
929
|
+
});
|
|
930
|
+
} catch (error) {
|
|
931
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
932
|
+
return { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' };
|
|
933
|
+
}
|
|
934
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
862
938
|
/**
|
|
863
939
|
* 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
|
|
864
940
|
* 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
|
|
@@ -872,13 +948,26 @@ export function evolutionStatus(scope) {
|
|
|
872
948
|
const phase = record.learning && typeof record.learning === 'object' ? record.learning.state : '';
|
|
873
949
|
if (phase === 'active' || phase === 'trial' || phase === 'paused') counts[phase] += 1;
|
|
874
950
|
}
|
|
875
|
-
|
|
951
|
+
const control = index.__control || state.controlOf(null);
|
|
952
|
+
return {
|
|
953
|
+
...config,
|
|
954
|
+
/** 模型可见的 enabled 是有效值:总开关关掉时,自进化一律不运行。 */
|
|
955
|
+
enabled: config.enabled === true && control.enabled === true,
|
|
956
|
+
configuredEnabled: config.enabled === true,
|
|
957
|
+
memoryEnabled: control.enabled === true,
|
|
958
|
+
ownerProject: config.ownerProject || '',
|
|
959
|
+
counts,
|
|
960
|
+
};
|
|
876
961
|
}
|
|
877
962
|
|
|
878
963
|
export function setEvolution(scope, enabled, options = {}) {
|
|
879
964
|
return withLock(scope, () => {
|
|
880
965
|
const before = loadIndex(scope);
|
|
881
966
|
const config = before.__evolution || {};
|
|
967
|
+
/** 总开关关掉后,自进化开关也不能改:先恢复本项目记忆。 */
|
|
968
|
+
if (!(before.__control || state.controlOf(null)).enabled) {
|
|
969
|
+
return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
970
|
+
}
|
|
882
971
|
if (config.compatible === false) {
|
|
883
972
|
return { ok: false, reason: '实验配置版本不认,先不要改动开关', compatible: false };
|
|
884
973
|
}
|
|
@@ -913,6 +1002,7 @@ export function taskState(scope) {
|
|
|
913
1002
|
export function saveTaskState(scope, tasks, options = {}) {
|
|
914
1003
|
return withLock(scope, () => {
|
|
915
1004
|
const before = loadIndex(scope);
|
|
1005
|
+
if (!(before.__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
916
1006
|
if (!before.__evolution.enabled) return { ok: false, reason: '实验还没开启' };
|
|
917
1007
|
const next = anchor.normalizeTasks(tasks);
|
|
918
1008
|
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
@@ -964,6 +1054,9 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
964
1054
|
const collect = (pool, archived) => {
|
|
965
1055
|
for (const item of pool) {
|
|
966
1056
|
if (!readableRecord(item, options, index)) continue;
|
|
1057
|
+
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1058
|
+
if (options.allowExperimental === true && options.history !== true
|
|
1059
|
+
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
967
1060
|
const score = scoreQuery(item, grams, query);
|
|
968
1061
|
if (score <= 0) continue;
|
|
969
1062
|
if (keywordMode && !matchesRun(item, runs)) continue;
|
|
@@ -1184,7 +1277,7 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1184
1277
|
if (!record) continue;
|
|
1185
1278
|
/** 旧回执不能撤掉别人后来的修改:版本对不上就明确拒绝。 */
|
|
1186
1279
|
if (options.expectedRevision !== undefined && Number(record.revision) !== Number(options.expectedRevision)) {
|
|
1187
|
-
throw new state.StateError('conflict',
|
|
1280
|
+
throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
|
|
1188
1281
|
}
|
|
1189
1282
|
const versions = record.versions || [];
|
|
1190
1283
|
const last = versions[versions.length - 1];
|
|
@@ -1282,9 +1375,9 @@ export function findAnywhere(id) {
|
|
|
1282
1375
|
}
|
|
1283
1376
|
|
|
1284
1377
|
/** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
|
|
1285
|
-
export function locate(scope, id) {
|
|
1378
|
+
export function locate(scope, id, options = {}) {
|
|
1286
1379
|
for (const source of [scope, globalScope()]) {
|
|
1287
|
-
const index = loadIndex(source);
|
|
1380
|
+
const index = loadIndex(source, options);
|
|
1288
1381
|
const active = index.entries.find((item) => item.id === id);
|
|
1289
1382
|
if (active) return { scope: source, record: active, archived: false };
|
|
1290
1383
|
const archived = index.archived.find((item) => item.id === id);
|
|
@@ -1571,7 +1664,8 @@ export function renderIndex(entries, budget = 400) {
|
|
|
1571
1664
|
const stale = now - last > staleMs;
|
|
1572
1665
|
const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
|
|
1573
1666
|
const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
|
|
1574
|
-
const
|
|
1667
|
+
const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
|
|
1668
|
+
const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
|
|
1575
1669
|
const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
|
|
1576
1670
|
if (used + line.length > budget) break;
|
|
1577
1671
|
lines.push(line);
|
|
@@ -1882,7 +1976,7 @@ function bindSameTopic(projectScope, ref) {
|
|
|
1882
1976
|
const record = loadIndex(source).entries.find((item) => item.id === ref.id);
|
|
1883
1977
|
if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
|
|
1884
1978
|
if (Number(ref.revision) !== Number(record.revision || 1)) {
|
|
1885
|
-
return { error: `sameTopicAs
|
|
1979
|
+
return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
|
|
1886
1980
|
}
|
|
1887
1981
|
return { topicKey: view.topicSlot(record) };
|
|
1888
1982
|
}
|
|
@@ -1893,6 +1987,10 @@ function bindSameTopic(projectScope, ref) {
|
|
|
1893
1987
|
*/
|
|
1894
1988
|
export function saveEntry(projectScope, source) {
|
|
1895
1989
|
return withLocks([projectScope, globalScope()], () => {
|
|
1990
|
+
const control = loadIndex(projectScope).__control || state.controlOf(null);
|
|
1991
|
+
if (!control.enabled) {
|
|
1992
|
+
return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
|
|
1993
|
+
}
|
|
1896
1994
|
const routed = routeScope(source, { project: projectScope });
|
|
1897
1995
|
const draft = (() => {
|
|
1898
1996
|
try { return normalizeDraft({ ...source, title: source && source.title }); } catch { return null; }
|
|
@@ -2166,6 +2264,9 @@ function driveMove(projectScope, intent, options = {}) {
|
|
|
2166
2264
|
* 协调文件里带项目身份与两侧 epoch:换项目续跑会被拒绝,续跑也不再靠猜。
|
|
2167
2265
|
*/
|
|
2168
2266
|
export function move(projectScope, id, options = {}) {
|
|
2267
|
+
if (!(loadIndex(projectScope).__control || state.controlOf(null)).enabled) {
|
|
2268
|
+
throw new state.StateError('conflict', '本项目记忆已关闭,先开启再整理');
|
|
2269
|
+
}
|
|
2169
2270
|
return state.withRootLock(() => {
|
|
2170
2271
|
const existing = state.readMoveIntent();
|
|
2171
2272
|
if (existing) {
|
|
@@ -2183,6 +2284,10 @@ export function move(projectScope, id, options = {}) {
|
|
|
2183
2284
|
const sourceScope = projectRecord ? projectScope : globalScope();
|
|
2184
2285
|
const sourceIndex = projectRecord ? projectIndex : globalIndex;
|
|
2185
2286
|
const record = projectRecord || globalRecord;
|
|
2287
|
+
/** 实验记录只能在原项目内用:普通移动不能把它带进全局层。 */
|
|
2288
|
+
if (record.origin === EXPERIMENTAL_ORIGIN && options.target === 'global') {
|
|
2289
|
+
throw new state.StateError('conflict', '实验规则不能直接搬到全局,先采纳为普通项目规范');
|
|
2290
|
+
}
|
|
2186
2291
|
const targetScope = options.target === 'global' ? globalScope() : projectScope;
|
|
2187
2292
|
if (targetScope.dir === sourceScope.dir) throw new state.StateError('conflict', '源和目标在同一层');
|
|
2188
2293
|
const targetIndex = loadIndex(targetScope);
|
|
@@ -2289,7 +2394,7 @@ function learningRuleView(record) {
|
|
|
2289
2394
|
* 普通记忆读取面不会调用这个入口。
|
|
2290
2395
|
*/
|
|
2291
2396
|
export function learningSnapshot(scope, options = {}) {
|
|
2292
|
-
const index = loadIndex(scope);
|
|
2397
|
+
const index = loadIndex(scope, options);
|
|
2293
2398
|
const evolution = index.__evolution;
|
|
2294
2399
|
const rules = learningRulesOf(index).map(learningRuleView);
|
|
2295
2400
|
return {
|
|
@@ -2403,6 +2508,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2403
2508
|
if (!operationId) return { ok: false, reason: '缺少提交标识' };
|
|
2404
2509
|
if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
2405
2510
|
return withLock(scope, () => {
|
|
2511
|
+
if (!(loadIndex(scope).__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
2406
2512
|
let receipt = null;
|
|
2407
2513
|
try {
|
|
2408
2514
|
const result = state.transact(scope, {
|
|
@@ -2467,6 +2573,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2467
2573
|
existing: flat,
|
|
2468
2574
|
change,
|
|
2469
2575
|
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2576
|
+
specChanged: merged.specChanged === true,
|
|
2470
2577
|
now,
|
|
2471
2578
|
}),
|
|
2472
2579
|
specDigest: merged.digest,
|
|
@@ -2486,10 +2593,6 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2486
2593
|
entry.updatedAt = at;
|
|
2487
2594
|
entry.maintenance = { ...(entry.maintenance || {}), learning: merged.learning };
|
|
2488
2595
|
} else {
|
|
2489
|
-
if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
|
|
2490
|
-
const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
|
|
2491
|
-
if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
|
|
2492
|
-
const merged = learning.mergeChange(null, change, { now });
|
|
2493
2596
|
const evidence = {
|
|
2494
2597
|
sourceType: 'human_feedback',
|
|
2495
2598
|
feedbackType: change.feedbackType,
|
|
@@ -2498,11 +2601,57 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2498
2601
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2499
2602
|
at,
|
|
2500
2603
|
};
|
|
2604
|
+
const merged = learning.mergeChange(null, change, { now });
|
|
2605
|
+
/** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
|
|
2606
|
+
const signature = (list) => (Array.isArray(list) ? list : [])
|
|
2607
|
+
.map((row) => `${row.key}=${row.value}`).sort().join('\u0000');
|
|
2608
|
+
const duplicate = merged.current.topicKey
|
|
2609
|
+
? currentRules.find((row) => {
|
|
2610
|
+
const flat = toFlat(row);
|
|
2611
|
+
return flat.topicKey === merged.current.topicKey
|
|
2612
|
+
&& signature(flat.conditions) === signature(merged.current.conditions)
|
|
2613
|
+
&& flat.applicability === merged.current.applicability;
|
|
2614
|
+
})
|
|
2615
|
+
: null;
|
|
2616
|
+
if (duplicate) {
|
|
2617
|
+
const flat = toFlat(duplicate);
|
|
2618
|
+
const rem = learning.mergeChange(flat, change, { now });
|
|
2619
|
+
rem.learning = {
|
|
2620
|
+
...learning.nextLearningState({
|
|
2621
|
+
existing: flat,
|
|
2622
|
+
change,
|
|
2623
|
+
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2624
|
+
specChanged: rem.specChanged === true,
|
|
2625
|
+
now,
|
|
2626
|
+
}),
|
|
2627
|
+
specDigest: rem.digest,
|
|
2628
|
+
versions: rem.learning.versions,
|
|
2629
|
+
};
|
|
2630
|
+
duplicate.current = {
|
|
2631
|
+
...duplicate.current,
|
|
2632
|
+
title: rem.current.title,
|
|
2633
|
+
summary: rem.current.summary,
|
|
2634
|
+
body: rem.current.body,
|
|
2635
|
+
when: rem.current.when,
|
|
2636
|
+
applicability: rem.current.applicability,
|
|
2637
|
+
conditions: rem.current.conditions,
|
|
2638
|
+
topicKey: rem.current.topicKey,
|
|
2639
|
+
};
|
|
2640
|
+
duplicate.revision = Number(duplicate.revision) + 1;
|
|
2641
|
+
duplicate.updatedAt = at;
|
|
2642
|
+
duplicate.maintenance = { ...(duplicate.maintenance || {}), learning: rem.learning };
|
|
2643
|
+
touched += 1;
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
|
|
2647
|
+
const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
|
|
2648
|
+
if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
|
|
2501
2649
|
merged.learning = {
|
|
2502
2650
|
...learning.nextLearningState({
|
|
2503
2651
|
existing: { learning: merged.learning },
|
|
2504
2652
|
change,
|
|
2505
2653
|
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2654
|
+
specChanged: merged.specChanged === true,
|
|
2506
2655
|
now,
|
|
2507
2656
|
}),
|
|
2508
2657
|
specDigest: merged.digest,
|
|
@@ -2523,6 +2672,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2523
2672
|
selection: null,
|
|
2524
2673
|
links: [],
|
|
2525
2674
|
origin: EXPERIMENTAL_ORIGIN,
|
|
2675
|
+
ownerProjectId: scope.key,
|
|
2526
2676
|
reviewState: 'active',
|
|
2527
2677
|
created: at,
|
|
2528
2678
|
updated: at,
|
|
@@ -2536,8 +2686,9 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2536
2686
|
checks: [],
|
|
2537
2687
|
learning: merged.learning,
|
|
2538
2688
|
};
|
|
2539
|
-
|
|
2540
|
-
|
|
2689
|
+
const created = fromFlat(flat);
|
|
2690
|
+
draft.entries.push(created);
|
|
2691
|
+
currentRules.push(created);
|
|
2541
2692
|
touched += 1;
|
|
2542
2693
|
continue;
|
|
2543
2694
|
}
|
|
@@ -2603,12 +2754,19 @@ export function recordLearningFailure(scope, options = {}) {
|
|
|
2603
2754
|
});
|
|
2604
2755
|
}
|
|
2605
2756
|
|
|
2757
|
+
/** 管理面写操作的统一闸门:总开关关掉时服务端直接拒绝,不写盘也不返回旧正文。 */
|
|
2758
|
+
function managementClosed(scope) {
|
|
2759
|
+
const index = loadIndex(scope);
|
|
2760
|
+
return !(index.__control || state.controlOf(null)).enabled;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2606
2763
|
/** 删除:仍按管理面看到的版本比对,删掉正文与旧版本,只留审计回执。 */
|
|
2607
2764
|
export function removeLearningRule(scope, id, options = {}) {
|
|
2608
2765
|
const expectedRevision = Number(options.expectedRevision);
|
|
2609
2766
|
const operationId = String(options.operationId || '');
|
|
2610
2767
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2611
2768
|
return withLock(scope, () => {
|
|
2769
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2612
2770
|
try {
|
|
2613
2771
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2614
2772
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2621,18 +2779,19 @@ export function removeLearningRule(scope, id, options = {}) {
|
|
|
2621
2779
|
entry.status = 'archived';
|
|
2622
2780
|
entry.updatedAt = at;
|
|
2623
2781
|
entry.current = { ...entry.current, body: '', summary: '' };
|
|
2624
|
-
|
|
2625
|
-
...(
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
versions: [],
|
|
2633
|
-
specDigest: '',
|
|
2634
|
-
},
|
|
2782
|
+
const deletedLearning = {
|
|
2783
|
+
...(flat.learning || {}),
|
|
2784
|
+
state: 'paused',
|
|
2785
|
+
blockedReason: 'deleted',
|
|
2786
|
+
pausedBy: 'management',
|
|
2787
|
+
pausedAt: at,
|
|
2788
|
+
versions: [],
|
|
2789
|
+
specDigest: '',
|
|
2635
2790
|
};
|
|
2791
|
+
/** 删掉的规范正文不能再从 lastText 里漏出来。 */
|
|
2792
|
+
delete deletedLearning.lastText;
|
|
2793
|
+
delete deletedLearning.trialUntil;
|
|
2794
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: deletedLearning };
|
|
2636
2795
|
addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'deleted', changes: 1, at }));
|
|
2637
2796
|
return { changed: true, kind: 'learning_delete' };
|
|
2638
2797
|
});
|
|
@@ -2651,6 +2810,7 @@ export function undoLearningChange(scope, id, options = {}) {
|
|
|
2651
2810
|
const now = options.now || Date.now();
|
|
2652
2811
|
const at = new Date(now).toISOString();
|
|
2653
2812
|
return withLock(scope, () => {
|
|
2813
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2654
2814
|
try {
|
|
2655
2815
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2656
2816
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2696,6 +2856,7 @@ export function pinLearningRule(scope, id, options = {}) {
|
|
|
2696
2856
|
const operationId = String(options.operationId || '');
|
|
2697
2857
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2698
2858
|
return withLock(scope, () => {
|
|
2859
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2699
2860
|
try {
|
|
2700
2861
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2701
2862
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2719,12 +2880,122 @@ export function pinLearningRule(scope, id, options = {}) {
|
|
|
2719
2880
|
});
|
|
2720
2881
|
}
|
|
2721
2882
|
|
|
2883
|
+
/**
|
|
2884
|
+
* 管理面编辑:只保存内容,不改变学习状态;正文变了就清掉旧证据并留上一版。
|
|
2885
|
+
* 已生效规则改写后降回试用,避免拿新正文沿用旧证据直接当默认。
|
|
2886
|
+
*/
|
|
2887
|
+
export function editLearningRule(scope, id, options = {}) {
|
|
2888
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2889
|
+
const operationId = String(options.operationId || '');
|
|
2890
|
+
const now = options.now || Date.now();
|
|
2891
|
+
const at = new Date(now).toISOString();
|
|
2892
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2893
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2894
|
+
if (!title && !body) throw new Error('没有要保存的内容');
|
|
2895
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2896
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2897
|
+
return withLock(scope, () => {
|
|
2898
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2899
|
+
try {
|
|
2900
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2901
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2902
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2903
|
+
if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再保存');
|
|
2904
|
+
const flat = toFlat(entry);
|
|
2905
|
+
const nextTitle = title || flat.title;
|
|
2906
|
+
const nextBody = body || flat.body;
|
|
2907
|
+
if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
|
|
2908
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2909
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2910
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2911
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2912
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2913
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2914
|
+
learningState.supportedEpisodeIds = [];
|
|
2915
|
+
learningState.evidence = [];
|
|
2916
|
+
learningState.updatedAt = at;
|
|
2917
|
+
if (learningState.state === 'active') {
|
|
2918
|
+
learningState.state = 'trial';
|
|
2919
|
+
learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
|
|
2920
|
+
}
|
|
2921
|
+
if (learningState.state === 'paused') {
|
|
2922
|
+
learningState.blockedReason = 'edited';
|
|
2923
|
+
learningState.pausedAt = at;
|
|
2924
|
+
learningState.pausedBy = 'management';
|
|
2925
|
+
}
|
|
2926
|
+
entry.current = nextCurrent;
|
|
2927
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
2928
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2929
|
+
entry.updatedAt = at;
|
|
2930
|
+
return { changed: true, kind: 'learning_edit' };
|
|
2931
|
+
});
|
|
2932
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2935
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有保存' };
|
|
2936
|
+
}
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** 采纳:候选/试用转为生效,默认在适用任务中参与召回;可选同时保存改过的标题或正文。 */
|
|
2941
|
+
export function adoptLearningRule(scope, id, options = {}) {
|
|
2942
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2943
|
+
const operationId = String(options.operationId || '');
|
|
2944
|
+
const now = options.now || Date.now();
|
|
2945
|
+
const at = new Date(now).toISOString();
|
|
2946
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2947
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2948
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('缺少版本,先刷新');
|
|
2949
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2950
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2951
|
+
return withLock(scope, () => {
|
|
2952
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2953
|
+
try {
|
|
2954
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2955
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2956
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2957
|
+
if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再采纳');
|
|
2958
|
+
const flat = toFlat(entry);
|
|
2959
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2960
|
+
if (learningState.state === 'active' && !title && !body) return { changed: false };
|
|
2961
|
+
const nextTitle = title || flat.title;
|
|
2962
|
+
const nextBody = body || flat.body;
|
|
2963
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2964
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2965
|
+
if (nextBody !== flat.body || nextTitle !== flat.title) {
|
|
2966
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2967
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2968
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2969
|
+
learningState.supportedEpisodeIds = [];
|
|
2970
|
+
learningState.evidence = [];
|
|
2971
|
+
}
|
|
2972
|
+
learningState.state = 'active';
|
|
2973
|
+
delete learningState.trialUntil;
|
|
2974
|
+
delete learningState.blockedReason;
|
|
2975
|
+
delete learningState.pausedAt;
|
|
2976
|
+
delete learningState.pausedBy;
|
|
2977
|
+
learningState.updatedAt = at;
|
|
2978
|
+
entry.current = nextCurrent;
|
|
2979
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
2980
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2981
|
+
entry.updatedAt = at;
|
|
2982
|
+
return { changed: true, kind: 'learning_adopt' };
|
|
2983
|
+
});
|
|
2984
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
2985
|
+
} catch (error) {
|
|
2986
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2987
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有采纳' };
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2722
2992
|
/** 结构化负反馈先在本地暂停命中的试用规则,再决定是否调用整理器。 */
|
|
2723
2993
|
export function pauseLearningRules(scope, ids, options = {}) {
|
|
2724
2994
|
const targets = Array.isArray(ids) ? ids.filter(Boolean).slice(0, 12) : [];
|
|
2725
2995
|
if (!targets.length) return { ok: true, changed: 0 };
|
|
2726
2996
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2727
2997
|
return withLock(scope, () => {
|
|
2998
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2728
2999
|
try {
|
|
2729
3000
|
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
2730
3001
|
let changed = 0;
|
|
@@ -2754,6 +3025,7 @@ export function resumeLearningRule(scope, id, options = {}) {
|
|
|
2754
3025
|
const operationId = String(options.operationId || '');
|
|
2755
3026
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2756
3027
|
return withLock(scope, () => {
|
|
3028
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2757
3029
|
try {
|
|
2758
3030
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2759
3031
|
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
@@ -2780,6 +3052,7 @@ export function expireLearning(scope, options = {}) {
|
|
|
2780
3052
|
const now = options.now || Date.now();
|
|
2781
3053
|
const at = new Date(now).toISOString();
|
|
2782
3054
|
return withLock(scope, () => {
|
|
3055
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2783
3056
|
try {
|
|
2784
3057
|
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
2785
3058
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|