dsh-lite-memory 0.11.1 → 0.16.2
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 +152 -13
- package/client.js +1083 -120
- 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/docs/RELEASE-0.16.0.md +10 -0
- package/index.js +704 -102
- package/learning.js +58 -32
- package/package.json +2 -2
- package/state.js +112 -11
- package/store.js +343 -44
- package/test/anchor.test.mjs +311 -0
- package/test/client.test.mjs +48 -6
- package/test/contract.test.mjs +214 -11
- package/test/control.test.mjs +297 -0
- package/test/evolution-read.test.mjs +196 -11
- package/test/learning.test.mjs +4 -2
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +25 -9
- package/test/state.test.mjs +21 -0
- package/test/store.test.mjs +98 -8
- package/view.js +16 -1
package/store.js
CHANGED
|
@@ -27,7 +27,6 @@ const SECRET_PATTERNS = [
|
|
|
27
27
|
];
|
|
28
28
|
|
|
29
29
|
/** A summary is a noun phrase. An imperative turns a note into an instruction. */
|
|
30
|
-
const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
|
|
31
30
|
|
|
32
31
|
const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
|
|
33
32
|
|
|
@@ -96,6 +95,7 @@ function toFlat(entry) {
|
|
|
96
95
|
revision: Number(entry.revision) || 1,
|
|
97
96
|
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
98
97
|
origin: entry.origin || 'user_explicit',
|
|
98
|
+
ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
|
|
99
99
|
hits: Number(maintenance.hits) || 0,
|
|
100
100
|
merges: Number(maintenance.merges) || 0,
|
|
101
101
|
versions: Array.isArray(maintenance.versions) ? cloneJson(maintenance.versions) : [],
|
|
@@ -115,6 +115,7 @@ function fromFlat(record, status) {
|
|
|
115
115
|
revision: Number(record.revision) || 1,
|
|
116
116
|
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
117
117
|
origin: typeof record.origin === 'string' && record.origin ? record.origin : 'user_explicit',
|
|
118
|
+
ownerProjectId: record.ownerProjectId || '',
|
|
118
119
|
createdAt: record.created || at,
|
|
119
120
|
updatedAt: at,
|
|
120
121
|
current: {
|
|
@@ -154,7 +155,7 @@ function fromFlat(record, status) {
|
|
|
154
155
|
* 由 state.js 拦成报告,这里不猜、不补空正文。
|
|
155
156
|
*/
|
|
156
157
|
export function loadIndex(scope, options = {}) {
|
|
157
|
-
if (!fs.existsSync(path.join(scope.dir, 'state.json'))) {
|
|
158
|
+
if (!options.readOnly && !fs.existsSync(path.join(scope.dir, 'state.json'))) {
|
|
158
159
|
const report = state.migrationReport(scope);
|
|
159
160
|
if (report.required || report.adoptable.length) state.migrateLegacy(scope);
|
|
160
161
|
}
|
|
@@ -176,6 +177,7 @@ export function loadIndex(scope, options = {}) {
|
|
|
176
177
|
suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
|
|
177
178
|
__evolution: state.evolutionOf(loaded.state),
|
|
178
179
|
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
180
|
+
__control: state.controlOf(loaded.state),
|
|
179
181
|
__exists: loaded.exists === true,
|
|
180
182
|
};
|
|
181
183
|
index.__meta = {
|
|
@@ -274,10 +276,8 @@ function cleanTitle(value) {
|
|
|
274
276
|
}
|
|
275
277
|
|
|
276
278
|
export function validSummary(summary) {
|
|
277
|
-
const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
|
|
279
|
+
const text = String(summary ?? '').replace(/\s+/g, ' ').trim().slice(0, LIMITS.summaryChars);
|
|
278
280
|
if (!text) return { ok: false, reason: '摘要为空' };
|
|
279
|
-
if (text.length > LIMITS.summaryChars) return { ok: false, reason: `摘要超过 ${LIMITS.summaryChars} 字,先写短些` };
|
|
280
|
-
if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
|
|
281
281
|
return { ok: true, text };
|
|
282
282
|
}
|
|
283
283
|
|
|
@@ -297,21 +297,32 @@ export function normalizeDraft(source, options = {}) {
|
|
|
297
297
|
const mayOmitTitle = Boolean(id);
|
|
298
298
|
if (!title && !mayOmitTitle) throw new Error('标题为空');
|
|
299
299
|
let summary = '';
|
|
300
|
+
let summaryShortened = false;
|
|
300
301
|
const rawSummary = String(draft.summary ?? '').replace(/\s+/g, ' ').trim();
|
|
301
302
|
if (rawSummary) {
|
|
302
303
|
const check = validSummary(rawSummary);
|
|
303
304
|
if (!check.ok) throw new Error(check.reason);
|
|
304
305
|
summary = check.text;
|
|
306
|
+
summaryShortened = summary.length < rawSummary.length;
|
|
305
307
|
} else if (has('summary')) {
|
|
306
308
|
throw new Error('摘要为空');
|
|
307
309
|
}
|
|
308
310
|
/** 更新(带 id)与候选草稿允许省略摘要;省略表示保持原值,不拿标题补。 */
|
|
309
311
|
const mayOmitSummary = options.requireSummary === false || Boolean(id);
|
|
310
312
|
if (!summary && !mayOmitSummary) throw new Error('摘要为空');
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
/**
|
|
314
|
+
* 正文是否由调用方显式给出,和「摘要太长所以自动补一份」必须分开。
|
|
315
|
+
* 否则同名更新会被自动补出的正文当成显式覆盖,把原正文冲掉。
|
|
316
|
+
*/
|
|
317
|
+
const bodyExplicit = has('body');
|
|
318
|
+
const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
|
|
319
|
+
const autoBody = !bodyExplicit && summaryShortened ? rawSummary : '';
|
|
320
|
+
const checked = bodyExplicit ? body : autoBody;
|
|
321
|
+
const bodyBytes = Buffer.byteLength(checked);
|
|
313
322
|
if (bodyBytes > LIMITS.entryBytes) {
|
|
314
|
-
throw new Error(
|
|
323
|
+
throw new Error(autoBody
|
|
324
|
+
? `摘要 ${rawSummary.length} 字,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
|
|
325
|
+
: `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
315
326
|
}
|
|
316
327
|
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
317
328
|
if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
|
|
@@ -334,6 +345,10 @@ export function normalizeDraft(source, options = {}) {
|
|
|
334
345
|
summary,
|
|
335
346
|
when,
|
|
336
347
|
body,
|
|
348
|
+
summaryShortened,
|
|
349
|
+
summaryRaw: rawSummary,
|
|
350
|
+
autoBody,
|
|
351
|
+
fullTextInBody: false,
|
|
337
352
|
kind: kind || 'note',
|
|
338
353
|
tags,
|
|
339
354
|
triggers,
|
|
@@ -346,7 +361,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
346
361
|
title: has('title') && Boolean(title),
|
|
347
362
|
summary: has('summary'),
|
|
348
363
|
when: has('when'),
|
|
349
|
-
body:
|
|
364
|
+
body: bodyExplicit,
|
|
350
365
|
kind: has('kind'),
|
|
351
366
|
tags: has('tags'),
|
|
352
367
|
triggers: triggers !== undefined,
|
|
@@ -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;
|
|
@@ -618,6 +651,14 @@ function rulesOf(draft, body, index, foreign) {
|
|
|
618
651
|
return rules;
|
|
619
652
|
}
|
|
620
653
|
|
|
654
|
+
/** 长摘要的尾部不能丢:正文里没有这段时补在末尾,已有则不动。 */
|
|
655
|
+
function bodyWithSummaryTail(body, draft) {
|
|
656
|
+
const raw = String(draft.summaryRaw || '');
|
|
657
|
+
const current = String(body || '');
|
|
658
|
+
if (!raw || !draft.summaryShortened || current.includes(raw)) return { body: current, added: false };
|
|
659
|
+
return { body: current ? `${current}\n\n摘要原文:${raw}` : raw, added: true };
|
|
660
|
+
}
|
|
661
|
+
|
|
621
662
|
function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
|
|
622
663
|
if (writeMode === 'replace_current') {
|
|
623
664
|
const next = {
|
|
@@ -631,8 +672,9 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
631
672
|
selection: draft.has.selection ? draft.selection : null,
|
|
632
673
|
links: draft.has.links ? draft.links : [],
|
|
633
674
|
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
634
|
-
body: draft.body,
|
|
675
|
+
body: bodyWithSummaryTail(draft.body, draft).body,
|
|
635
676
|
};
|
|
677
|
+
if (next.body !== draft.body) draft.fullTextInBody = true;
|
|
636
678
|
next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
637
679
|
next.conditions, next.selection, next.when);
|
|
638
680
|
assertLinksVisible(next.links, index, foreign);
|
|
@@ -710,6 +752,9 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
710
752
|
if (record) {
|
|
711
753
|
const oldBody = readBody(scope, record.id);
|
|
712
754
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
755
|
+
const tail = bodyWithSummaryTail(next.body, draft);
|
|
756
|
+
next.body = tail.body;
|
|
757
|
+
if (tail.added) draft.fullTextInBody = true;
|
|
713
758
|
const noop = next.title === record.title && next.summary === record.summary
|
|
714
759
|
&& next.when === (record.when || '') && next.kind === (record.kind || 'note')
|
|
715
760
|
&& JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
|
|
@@ -744,7 +789,11 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
744
789
|
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
|
|
745
790
|
}
|
|
746
791
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
747
|
-
const
|
|
792
|
+
const seed = draft.autoBody || draft.body;
|
|
793
|
+
const tail = bodyWithSummaryTail(seed, draft);
|
|
794
|
+
const createdBody = tail.body;
|
|
795
|
+
if (tail.added || draft.autoBody) draft.fullTextInBody = true;
|
|
796
|
+
const rules = rulesOf(draft, createdBody, index, foreign);
|
|
748
797
|
const created = {
|
|
749
798
|
id: newId(),
|
|
750
799
|
title: draft.title,
|
|
@@ -758,7 +807,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
758
807
|
selection: rules.selection,
|
|
759
808
|
links: rules.links,
|
|
760
809
|
topicKey: rules.topicKey,
|
|
761
|
-
body:
|
|
810
|
+
body: createdBody,
|
|
762
811
|
aliases: [],
|
|
763
812
|
created: now,
|
|
764
813
|
updated: now,
|
|
@@ -784,8 +833,9 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
784
833
|
* 结果经 index.__archived 回填,调用方在工具结果里点名。
|
|
785
834
|
*/
|
|
786
835
|
function assertFits(scope, index, record) {
|
|
787
|
-
|
|
788
|
-
|
|
836
|
+
const bodyBytes = Buffer.byteLength(record.body || '');
|
|
837
|
+
if (bodyBytes > LIMITS.entryBytes) {
|
|
838
|
+
throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
|
|
789
839
|
}
|
|
790
840
|
const size = recordSize(record);
|
|
791
841
|
if (size > LIMITS.recordBytes) {
|
|
@@ -842,23 +892,66 @@ function scoreQuery(item, grams, raw) {
|
|
|
842
892
|
[(item.triggers || []).join(' '), MATCH_WEIGHT.tags],
|
|
843
893
|
];
|
|
844
894
|
let score = 0;
|
|
895
|
+
let substantive = 0;
|
|
845
896
|
for (const [text, weight] of fields) {
|
|
846
897
|
if (!text) continue;
|
|
847
898
|
const pool = bigrams(text);
|
|
848
899
|
let shared = 0;
|
|
849
|
-
for (const gram of grams)
|
|
900
|
+
for (const gram of grams) {
|
|
901
|
+
if (!pool.has(gram)) continue;
|
|
902
|
+
shared += 1;
|
|
903
|
+
if (!STOP_GRAMS.has(gram)) substantive += 1;
|
|
904
|
+
}
|
|
850
905
|
score += shared * weight;
|
|
851
906
|
}
|
|
852
907
|
const plain = normalizeText(raw, 200).toLowerCase().replace(/\s+/g, '');
|
|
853
908
|
if (plain && normalizeText(item.title, 200).toLowerCase().replace(/\s+/g, '').includes(plain)) score += 5;
|
|
854
909
|
/** 触发词命中按整词算:用户说的那句话里带着它,就该搜得到。 */
|
|
855
|
-
|
|
910
|
+
const triggerHit = Boolean(plain) && (item.triggers || []).some((trigger) => {
|
|
856
911
|
const text = normalizeText(trigger, 200).toLowerCase().replace(/\s+/g, '');
|
|
857
912
|
return Boolean(text) && plain.includes(text);
|
|
858
|
-
})
|
|
913
|
+
});
|
|
914
|
+
if (triggerHit) score += 4;
|
|
915
|
+
/** 纯英文查询必须按整词命中,避免 git 命中 digital 这种字面巧合;混合查询仍按中文实词判断。 */
|
|
916
|
+
const words = asciiWordsOf(raw);
|
|
917
|
+
const hasCjk = /[\u4e00-\u9fff]/.test(String(raw || ''));
|
|
918
|
+
if (words.length && !hasCjk) {
|
|
919
|
+
const pool = fields.map(([text]) => fieldText(text)).join('\n');
|
|
920
|
+
if (!words.some((word) => wordIn(pool, word))) return 0;
|
|
921
|
+
}
|
|
922
|
+
/** 只重合了「这个」「怎么」这类词,不算命中;触发词命中仍算。 */
|
|
923
|
+
if (substantive === 0 && !triggerHit && words.length === 0) return 0;
|
|
859
924
|
return score;
|
|
860
925
|
}
|
|
861
926
|
|
|
927
|
+
/** 项目记忆总开关:读当前层,缺字段按开启。 */
|
|
928
|
+
export function controlStatus(scope) {
|
|
929
|
+
const index = loadIndex(scope);
|
|
930
|
+
return { ...(index.__control || state.controlOf(null)) };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function setMemoryEnabled(scope, enabled, options = {}) {
|
|
934
|
+
return withLock(scope, () => {
|
|
935
|
+
const before = loadIndex(scope);
|
|
936
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
937
|
+
let applied = null;
|
|
938
|
+
try {
|
|
939
|
+
state.transact(scope, {
|
|
940
|
+
operationId,
|
|
941
|
+
expectCreate: !before.__exists,
|
|
942
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
943
|
+
}, (draft) => {
|
|
944
|
+
applied = state.applyControl(draft, enabled);
|
|
945
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
946
|
+
});
|
|
947
|
+
} catch (error) {
|
|
948
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
949
|
+
return { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' };
|
|
950
|
+
}
|
|
951
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
|
|
862
955
|
/**
|
|
863
956
|
* 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
|
|
864
957
|
* 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
|
|
@@ -872,13 +965,26 @@ export function evolutionStatus(scope) {
|
|
|
872
965
|
const phase = record.learning && typeof record.learning === 'object' ? record.learning.state : '';
|
|
873
966
|
if (phase === 'active' || phase === 'trial' || phase === 'paused') counts[phase] += 1;
|
|
874
967
|
}
|
|
875
|
-
|
|
968
|
+
const control = index.__control || state.controlOf(null);
|
|
969
|
+
return {
|
|
970
|
+
...config,
|
|
971
|
+
/** 模型可见的 enabled 是有效值:总开关关掉时,自进化一律不运行。 */
|
|
972
|
+
enabled: config.enabled === true && control.enabled === true,
|
|
973
|
+
configuredEnabled: config.enabled === true,
|
|
974
|
+
memoryEnabled: control.enabled === true,
|
|
975
|
+
ownerProject: config.ownerProject || '',
|
|
976
|
+
counts,
|
|
977
|
+
};
|
|
876
978
|
}
|
|
877
979
|
|
|
878
980
|
export function setEvolution(scope, enabled, options = {}) {
|
|
879
981
|
return withLock(scope, () => {
|
|
880
982
|
const before = loadIndex(scope);
|
|
881
983
|
const config = before.__evolution || {};
|
|
984
|
+
/** 总开关关掉后,自进化开关也不能改:先恢复本项目记忆。 */
|
|
985
|
+
if (!(before.__control || state.controlOf(null)).enabled) {
|
|
986
|
+
return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
987
|
+
}
|
|
882
988
|
if (config.compatible === false) {
|
|
883
989
|
return { ok: false, reason: '实验配置版本不认,先不要改动开关', compatible: false };
|
|
884
990
|
}
|
|
@@ -913,6 +1019,7 @@ export function taskState(scope) {
|
|
|
913
1019
|
export function saveTaskState(scope, tasks, options = {}) {
|
|
914
1020
|
return withLock(scope, () => {
|
|
915
1021
|
const before = loadIndex(scope);
|
|
1022
|
+
if (!(before.__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
916
1023
|
if (!before.__evolution.enabled) return { ok: false, reason: '实验还没开启' };
|
|
917
1024
|
const next = anchor.normalizeTasks(tasks);
|
|
918
1025
|
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
@@ -964,6 +1071,9 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
964
1071
|
const collect = (pool, archived) => {
|
|
965
1072
|
for (const item of pool) {
|
|
966
1073
|
if (!readableRecord(item, options, index)) continue;
|
|
1074
|
+
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1075
|
+
if (options.allowExperimental === true && options.history !== true
|
|
1076
|
+
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
967
1077
|
const score = scoreQuery(item, grams, query);
|
|
968
1078
|
if (score <= 0) continue;
|
|
969
1079
|
if (keywordMode && !matchesRun(item, runs)) continue;
|
|
@@ -980,8 +1090,11 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
980
1090
|
const group = resolved.get(view.topicSlot(item));
|
|
981
1091
|
return Boolean(group && group.status === 'ok' && group.record && group.record.id !== item.id);
|
|
982
1092
|
};
|
|
983
|
-
const visible = rows.filter((row) => row.archived || !shadowed(row.item))
|
|
984
|
-
|
|
1093
|
+
const visible = rows.filter((row) => row.archived || !shadowed(row.item))
|
|
1094
|
+
.map((row) => ({ ...row, usable: row.archived || view.statusOf(row.item, options.context || {}) === 'ok' }));
|
|
1095
|
+
/** 先按「本轮能不能用」分组再按相关度:高相关但当前不适用的条目不能挤掉后面真正能用的。 */
|
|
1096
|
+
visible.sort((a, b) => Number(b.usable) - Number(a.usable)
|
|
1097
|
+
|| b.score - a.score
|
|
985
1098
|
|| Number(a.archived) - Number(b.archived)
|
|
986
1099
|
|| String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
|
|
987
1100
|
const chosen = visible.slice(0, limit);
|
|
@@ -1162,13 +1275,16 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1162
1275
|
const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
|
|
1163
1276
|
if (!record) continue;
|
|
1164
1277
|
if (!readableRecord(record, options, index)) continue;
|
|
1278
|
+
/** 关联目标也按本轮上下文判适用;条件未知、过期或暂停的目标不给标题。 */
|
|
1279
|
+
if (view.statusOf(record, options.context || {}) !== 'ok') continue;
|
|
1280
|
+
if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
|
|
1165
1281
|
return {
|
|
1166
1282
|
id: record.id,
|
|
1167
1283
|
title: record.title,
|
|
1168
1284
|
summary: record.summary || '',
|
|
1169
1285
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1170
1286
|
revision: record.revision || 1,
|
|
1171
|
-
status:
|
|
1287
|
+
status: 'ok',
|
|
1172
1288
|
};
|
|
1173
1289
|
}
|
|
1174
1290
|
return null;
|
|
@@ -1184,7 +1300,7 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1184
1300
|
if (!record) continue;
|
|
1185
1301
|
/** 旧回执不能撤掉别人后来的修改:版本对不上就明确拒绝。 */
|
|
1186
1302
|
if (options.expectedRevision !== undefined && Number(record.revision) !== Number(options.expectedRevision)) {
|
|
1187
|
-
throw new state.StateError('conflict',
|
|
1303
|
+
throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
|
|
1188
1304
|
}
|
|
1189
1305
|
const versions = record.versions || [];
|
|
1190
1306
|
const last = versions[versions.length - 1];
|
|
@@ -1282,9 +1398,9 @@ export function findAnywhere(id) {
|
|
|
1282
1398
|
}
|
|
1283
1399
|
|
|
1284
1400
|
/** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
|
|
1285
|
-
export function locate(scope, id) {
|
|
1401
|
+
export function locate(scope, id, options = {}) {
|
|
1286
1402
|
for (const source of [scope, globalScope()]) {
|
|
1287
|
-
const index = loadIndex(source);
|
|
1403
|
+
const index = loadIndex(source, options);
|
|
1288
1404
|
const active = index.entries.find((item) => item.id === id);
|
|
1289
1405
|
if (active) return { scope: source, record: active, archived: false };
|
|
1290
1406
|
const archived = index.archived.find((item) => item.id === id);
|
|
@@ -1571,7 +1687,8 @@ export function renderIndex(entries, budget = 400) {
|
|
|
1571
1687
|
const stale = now - last > staleMs;
|
|
1572
1688
|
const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
|
|
1573
1689
|
const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
|
|
1574
|
-
const
|
|
1690
|
+
const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
|
|
1691
|
+
const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
|
|
1575
1692
|
const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
|
|
1576
1693
|
if (used + line.length > budget) break;
|
|
1577
1694
|
lines.push(line);
|
|
@@ -1882,7 +1999,7 @@ function bindSameTopic(projectScope, ref) {
|
|
|
1882
1999
|
const record = loadIndex(source).entries.find((item) => item.id === ref.id);
|
|
1883
2000
|
if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
|
|
1884
2001
|
if (Number(ref.revision) !== Number(record.revision || 1)) {
|
|
1885
|
-
return { error: `sameTopicAs
|
|
2002
|
+
return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
|
|
1886
2003
|
}
|
|
1887
2004
|
return { topicKey: view.topicSlot(record) };
|
|
1888
2005
|
}
|
|
@@ -1893,6 +2010,10 @@ function bindSameTopic(projectScope, ref) {
|
|
|
1893
2010
|
*/
|
|
1894
2011
|
export function saveEntry(projectScope, source) {
|
|
1895
2012
|
return withLocks([projectScope, globalScope()], () => {
|
|
2013
|
+
const control = loadIndex(projectScope).__control || state.controlOf(null);
|
|
2014
|
+
if (!control.enabled) {
|
|
2015
|
+
return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
|
|
2016
|
+
}
|
|
1896
2017
|
const routed = routeScope(source, { project: projectScope });
|
|
1897
2018
|
const draft = (() => {
|
|
1898
2019
|
try { return normalizeDraft({ ...source, title: source && source.title }); } catch { return null; }
|
|
@@ -2166,6 +2287,9 @@ function driveMove(projectScope, intent, options = {}) {
|
|
|
2166
2287
|
* 协调文件里带项目身份与两侧 epoch:换项目续跑会被拒绝,续跑也不再靠猜。
|
|
2167
2288
|
*/
|
|
2168
2289
|
export function move(projectScope, id, options = {}) {
|
|
2290
|
+
if (!(loadIndex(projectScope).__control || state.controlOf(null)).enabled) {
|
|
2291
|
+
throw new state.StateError('conflict', '本项目记忆已关闭,先开启再整理');
|
|
2292
|
+
}
|
|
2169
2293
|
return state.withRootLock(() => {
|
|
2170
2294
|
const existing = state.readMoveIntent();
|
|
2171
2295
|
if (existing) {
|
|
@@ -2183,6 +2307,10 @@ export function move(projectScope, id, options = {}) {
|
|
|
2183
2307
|
const sourceScope = projectRecord ? projectScope : globalScope();
|
|
2184
2308
|
const sourceIndex = projectRecord ? projectIndex : globalIndex;
|
|
2185
2309
|
const record = projectRecord || globalRecord;
|
|
2310
|
+
/** 实验记录只能在原项目内用:普通移动不能把它带进全局层。 */
|
|
2311
|
+
if (record.origin === EXPERIMENTAL_ORIGIN && options.target === 'global') {
|
|
2312
|
+
throw new state.StateError('conflict', '实验规则不能直接搬到全局,先采纳为普通项目规范');
|
|
2313
|
+
}
|
|
2186
2314
|
const targetScope = options.target === 'global' ? globalScope() : projectScope;
|
|
2187
2315
|
if (targetScope.dir === sourceScope.dir) throw new state.StateError('conflict', '源和目标在同一层');
|
|
2188
2316
|
const targetIndex = loadIndex(targetScope);
|
|
@@ -2289,7 +2417,7 @@ function learningRuleView(record) {
|
|
|
2289
2417
|
* 普通记忆读取面不会调用这个入口。
|
|
2290
2418
|
*/
|
|
2291
2419
|
export function learningSnapshot(scope, options = {}) {
|
|
2292
|
-
const index = loadIndex(scope);
|
|
2420
|
+
const index = loadIndex(scope, options);
|
|
2293
2421
|
const evolution = index.__evolution;
|
|
2294
2422
|
const rules = learningRulesOf(index).map(learningRuleView);
|
|
2295
2423
|
return {
|
|
@@ -2403,6 +2531,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2403
2531
|
if (!operationId) return { ok: false, reason: '缺少提交标识' };
|
|
2404
2532
|
if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
2405
2533
|
return withLock(scope, () => {
|
|
2534
|
+
if (!(loadIndex(scope).__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
2406
2535
|
let receipt = null;
|
|
2407
2536
|
try {
|
|
2408
2537
|
const result = state.transact(scope, {
|
|
@@ -2467,6 +2596,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2467
2596
|
existing: flat,
|
|
2468
2597
|
change,
|
|
2469
2598
|
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2599
|
+
specChanged: merged.specChanged === true,
|
|
2470
2600
|
now,
|
|
2471
2601
|
}),
|
|
2472
2602
|
specDigest: merged.digest,
|
|
@@ -2486,10 +2616,6 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2486
2616
|
entry.updatedAt = at;
|
|
2487
2617
|
entry.maintenance = { ...(entry.maintenance || {}), learning: merged.learning };
|
|
2488
2618
|
} 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
2619
|
const evidence = {
|
|
2494
2620
|
sourceType: 'human_feedback',
|
|
2495
2621
|
feedbackType: change.feedbackType,
|
|
@@ -2498,11 +2624,57 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2498
2624
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2499
2625
|
at,
|
|
2500
2626
|
};
|
|
2627
|
+
const merged = learning.mergeChange(null, change, { now });
|
|
2628
|
+
/** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
|
|
2629
|
+
const signature = (list) => (Array.isArray(list) ? list : [])
|
|
2630
|
+
.map((row) => `${row.key}=${row.value}`).sort().join('\u0000');
|
|
2631
|
+
const duplicate = merged.current.topicKey
|
|
2632
|
+
? currentRules.find((row) => {
|
|
2633
|
+
const flat = toFlat(row);
|
|
2634
|
+
return flat.topicKey === merged.current.topicKey
|
|
2635
|
+
&& signature(flat.conditions) === signature(merged.current.conditions)
|
|
2636
|
+
&& flat.applicability === merged.current.applicability;
|
|
2637
|
+
})
|
|
2638
|
+
: null;
|
|
2639
|
+
if (duplicate) {
|
|
2640
|
+
const flat = toFlat(duplicate);
|
|
2641
|
+
const rem = learning.mergeChange(flat, change, { now });
|
|
2642
|
+
rem.learning = {
|
|
2643
|
+
...learning.nextLearningState({
|
|
2644
|
+
existing: flat,
|
|
2645
|
+
change,
|
|
2646
|
+
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2647
|
+
specChanged: rem.specChanged === true,
|
|
2648
|
+
now,
|
|
2649
|
+
}),
|
|
2650
|
+
specDigest: rem.digest,
|
|
2651
|
+
versions: rem.learning.versions,
|
|
2652
|
+
};
|
|
2653
|
+
duplicate.current = {
|
|
2654
|
+
...duplicate.current,
|
|
2655
|
+
title: rem.current.title,
|
|
2656
|
+
summary: rem.current.summary,
|
|
2657
|
+
body: rem.current.body,
|
|
2658
|
+
when: rem.current.when,
|
|
2659
|
+
applicability: rem.current.applicability,
|
|
2660
|
+
conditions: rem.current.conditions,
|
|
2661
|
+
topicKey: rem.current.topicKey,
|
|
2662
|
+
};
|
|
2663
|
+
duplicate.revision = Number(duplicate.revision) + 1;
|
|
2664
|
+
duplicate.updatedAt = at;
|
|
2665
|
+
duplicate.maintenance = { ...(duplicate.maintenance || {}), learning: rem.learning };
|
|
2666
|
+
touched += 1;
|
|
2667
|
+
continue;
|
|
2668
|
+
}
|
|
2669
|
+
if (currentRules.length + 1 > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
|
|
2670
|
+
const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
|
|
2671
|
+
if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
|
|
2501
2672
|
merged.learning = {
|
|
2502
2673
|
...learning.nextLearningState({
|
|
2503
2674
|
existing: { learning: merged.learning },
|
|
2504
2675
|
change,
|
|
2505
2676
|
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2677
|
+
specChanged: merged.specChanged === true,
|
|
2506
2678
|
now,
|
|
2507
2679
|
}),
|
|
2508
2680
|
specDigest: merged.digest,
|
|
@@ -2523,6 +2695,7 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2523
2695
|
selection: null,
|
|
2524
2696
|
links: [],
|
|
2525
2697
|
origin: EXPERIMENTAL_ORIGIN,
|
|
2698
|
+
ownerProjectId: scope.key,
|
|
2526
2699
|
reviewState: 'active',
|
|
2527
2700
|
created: at,
|
|
2528
2701
|
updated: at,
|
|
@@ -2536,8 +2709,9 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2536
2709
|
checks: [],
|
|
2537
2710
|
learning: merged.learning,
|
|
2538
2711
|
};
|
|
2539
|
-
|
|
2540
|
-
|
|
2712
|
+
const created = fromFlat(flat);
|
|
2713
|
+
draft.entries.push(created);
|
|
2714
|
+
currentRules.push(created);
|
|
2541
2715
|
touched += 1;
|
|
2542
2716
|
continue;
|
|
2543
2717
|
}
|
|
@@ -2603,12 +2777,19 @@ export function recordLearningFailure(scope, options = {}) {
|
|
|
2603
2777
|
});
|
|
2604
2778
|
}
|
|
2605
2779
|
|
|
2780
|
+
/** 管理面写操作的统一闸门:总开关关掉时服务端直接拒绝,不写盘也不返回旧正文。 */
|
|
2781
|
+
function managementClosed(scope) {
|
|
2782
|
+
const index = loadIndex(scope);
|
|
2783
|
+
return !(index.__control || state.controlOf(null)).enabled;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2606
2786
|
/** 删除:仍按管理面看到的版本比对,删掉正文与旧版本,只留审计回执。 */
|
|
2607
2787
|
export function removeLearningRule(scope, id, options = {}) {
|
|
2608
2788
|
const expectedRevision = Number(options.expectedRevision);
|
|
2609
2789
|
const operationId = String(options.operationId || '');
|
|
2610
2790
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2611
2791
|
return withLock(scope, () => {
|
|
2792
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2612
2793
|
try {
|
|
2613
2794
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2614
2795
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2621,18 +2802,19 @@ export function removeLearningRule(scope, id, options = {}) {
|
|
|
2621
2802
|
entry.status = 'archived';
|
|
2622
2803
|
entry.updatedAt = at;
|
|
2623
2804
|
entry.current = { ...entry.current, body: '', summary: '' };
|
|
2624
|
-
|
|
2625
|
-
...(
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
versions: [],
|
|
2633
|
-
specDigest: '',
|
|
2634
|
-
},
|
|
2805
|
+
const deletedLearning = {
|
|
2806
|
+
...(flat.learning || {}),
|
|
2807
|
+
state: 'paused',
|
|
2808
|
+
blockedReason: 'deleted',
|
|
2809
|
+
pausedBy: 'management',
|
|
2810
|
+
pausedAt: at,
|
|
2811
|
+
versions: [],
|
|
2812
|
+
specDigest: '',
|
|
2635
2813
|
};
|
|
2814
|
+
/** 删掉的规范正文不能再从 lastText 里漏出来。 */
|
|
2815
|
+
delete deletedLearning.lastText;
|
|
2816
|
+
delete deletedLearning.trialUntil;
|
|
2817
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: deletedLearning };
|
|
2636
2818
|
addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'deleted', changes: 1, at }));
|
|
2637
2819
|
return { changed: true, kind: 'learning_delete' };
|
|
2638
2820
|
});
|
|
@@ -2651,6 +2833,7 @@ export function undoLearningChange(scope, id, options = {}) {
|
|
|
2651
2833
|
const now = options.now || Date.now();
|
|
2652
2834
|
const at = new Date(now).toISOString();
|
|
2653
2835
|
return withLock(scope, () => {
|
|
2836
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2654
2837
|
try {
|
|
2655
2838
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2656
2839
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2696,6 +2879,7 @@ export function pinLearningRule(scope, id, options = {}) {
|
|
|
2696
2879
|
const operationId = String(options.operationId || '');
|
|
2697
2880
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2698
2881
|
return withLock(scope, () => {
|
|
2882
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2699
2883
|
try {
|
|
2700
2884
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2701
2885
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
@@ -2719,12 +2903,125 @@ export function pinLearningRule(scope, id, options = {}) {
|
|
|
2719
2903
|
});
|
|
2720
2904
|
}
|
|
2721
2905
|
|
|
2906
|
+
/**
|
|
2907
|
+
* 管理面编辑:只保存内容,不改变学习状态;正文变了就清掉旧证据并留上一版。
|
|
2908
|
+
* 已生效规则改写后降回试用,避免拿新正文沿用旧证据直接当默认。
|
|
2909
|
+
*/
|
|
2910
|
+
export function editLearningRule(scope, id, options = {}) {
|
|
2911
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2912
|
+
const operationId = String(options.operationId || '');
|
|
2913
|
+
const now = options.now || Date.now();
|
|
2914
|
+
const at = new Date(now).toISOString();
|
|
2915
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2916
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2917
|
+
if (!title && !body) throw new Error('没有要保存的内容');
|
|
2918
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2919
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2920
|
+
return withLock(scope, () => {
|
|
2921
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2922
|
+
try {
|
|
2923
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2924
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2925
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2926
|
+
if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再保存');
|
|
2927
|
+
const flat = toFlat(entry);
|
|
2928
|
+
const nextTitle = title || flat.title;
|
|
2929
|
+
const nextBody = body || flat.body;
|
|
2930
|
+
if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
|
|
2931
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2932
|
+
const bodyChanged = nextBody !== flat.body;
|
|
2933
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2934
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2935
|
+
if (bodyChanged) {
|
|
2936
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2937
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2938
|
+
learningState.supportedEpisodeIds = [];
|
|
2939
|
+
learningState.evidence = [];
|
|
2940
|
+
}
|
|
2941
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2942
|
+
learningState.updatedAt = at;
|
|
2943
|
+
if (bodyChanged && learningState.state === 'active') {
|
|
2944
|
+
learningState.state = 'trial';
|
|
2945
|
+
learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
|
|
2946
|
+
}
|
|
2947
|
+
if (learningState.state === 'paused') {
|
|
2948
|
+
learningState.blockedReason = 'edited';
|
|
2949
|
+
learningState.pausedAt = at;
|
|
2950
|
+
learningState.pausedBy = 'management';
|
|
2951
|
+
}
|
|
2952
|
+
entry.current = nextCurrent;
|
|
2953
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
2954
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2955
|
+
entry.updatedAt = at;
|
|
2956
|
+
return { changed: true, kind: 'learning_edit' };
|
|
2957
|
+
});
|
|
2958
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
2959
|
+
} catch (error) {
|
|
2960
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2961
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有保存' };
|
|
2962
|
+
}
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/** 采纳:候选/试用转为生效,默认在适用任务中参与召回;可选同时保存改过的标题或正文。 */
|
|
2967
|
+
export function adoptLearningRule(scope, id, options = {}) {
|
|
2968
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2969
|
+
const operationId = String(options.operationId || '');
|
|
2970
|
+
const now = options.now || Date.now();
|
|
2971
|
+
const at = new Date(now).toISOString();
|
|
2972
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2973
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2974
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('缺少版本,先刷新');
|
|
2975
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2976
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2977
|
+
return withLock(scope, () => {
|
|
2978
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2979
|
+
try {
|
|
2980
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2981
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2982
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2983
|
+
if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再采纳');
|
|
2984
|
+
const flat = toFlat(entry);
|
|
2985
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2986
|
+
if (learningState.state === 'active' && !title && !body) return { changed: false };
|
|
2987
|
+
const nextTitle = title || flat.title;
|
|
2988
|
+
const nextBody = body || flat.body;
|
|
2989
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2990
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2991
|
+
if (nextBody !== flat.body || nextTitle !== flat.title) {
|
|
2992
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2993
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2994
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2995
|
+
learningState.supportedEpisodeIds = [];
|
|
2996
|
+
learningState.evidence = [];
|
|
2997
|
+
}
|
|
2998
|
+
learningState.state = 'active';
|
|
2999
|
+
delete learningState.trialUntil;
|
|
3000
|
+
delete learningState.blockedReason;
|
|
3001
|
+
delete learningState.pausedAt;
|
|
3002
|
+
delete learningState.pausedBy;
|
|
3003
|
+
learningState.updatedAt = at;
|
|
3004
|
+
entry.current = nextCurrent;
|
|
3005
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
3006
|
+
entry.revision = Number(entry.revision) + 1;
|
|
3007
|
+
entry.updatedAt = at;
|
|
3008
|
+
return { changed: true, kind: 'learning_adopt' };
|
|
3009
|
+
});
|
|
3010
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
3011
|
+
} catch (error) {
|
|
3012
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
3013
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有采纳' };
|
|
3014
|
+
}
|
|
3015
|
+
});
|
|
3016
|
+
}
|
|
3017
|
+
|
|
2722
3018
|
/** 结构化负反馈先在本地暂停命中的试用规则,再决定是否调用整理器。 */
|
|
2723
3019
|
export function pauseLearningRules(scope, ids, options = {}) {
|
|
2724
3020
|
const targets = Array.isArray(ids) ? ids.filter(Boolean).slice(0, 12) : [];
|
|
2725
3021
|
if (!targets.length) return { ok: true, changed: 0 };
|
|
2726
3022
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2727
3023
|
return withLock(scope, () => {
|
|
3024
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2728
3025
|
try {
|
|
2729
3026
|
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
2730
3027
|
let changed = 0;
|
|
@@ -2754,6 +3051,7 @@ export function resumeLearningRule(scope, id, options = {}) {
|
|
|
2754
3051
|
const operationId = String(options.operationId || '');
|
|
2755
3052
|
const at = new Date(options.now || Date.now()).toISOString();
|
|
2756
3053
|
return withLock(scope, () => {
|
|
3054
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2757
3055
|
try {
|
|
2758
3056
|
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2759
3057
|
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
@@ -2780,6 +3078,7 @@ export function expireLearning(scope, options = {}) {
|
|
|
2780
3078
|
const now = options.now || Date.now();
|
|
2781
3079
|
const at = new Date(now).toISOString();
|
|
2782
3080
|
return withLock(scope, () => {
|
|
3081
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2783
3082
|
try {
|
|
2784
3083
|
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
2785
3084
|
const evolution = draft.experimental && draft.experimental.projectEvolution;
|