dsh-lite-memory 0.16.4 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/TRIGGERS.md +14 -13
- package/anchor.js +259 -24
- package/client.js +1334 -215
- package/docs/BENCH.md +10 -8
- package/docs/CONVENTIONS.md +143 -0
- package/docs/RELEASE-0.17.0.md +8 -0
- package/docs/RELEASE-0.18.0.md +35 -0
- package/docs/RELEASE-0.18.1.md +18 -0
- package/index.js +395 -105
- package/learning.js +456 -48
- package/package.json +9 -3
- package/state.js +119 -7
- package/store.js +627 -75
- package/test/anchor.test.mjs +240 -0
- package/test/client.test.mjs +216 -6
- package/test/contract.test.mjs +20 -9
- package/test/crossing.test.mjs +22 -1
- package/test/evolution-read.test.mjs +23 -0
- package/test/ha.test.mjs +136 -0
- package/test/learning.test.mjs +483 -1
- package/test/palace.test.mjs +102 -0
- package/test/recall.test.mjs +13 -0
- package/test/record.test.mjs +130 -0
- package/test/runtime-context.test.mjs +186 -0
- package/test/safety.test.mjs +119 -0
- package/test/state.test.mjs +121 -2
- package/test/store.test.mjs +454 -15
- package/view.js +71 -3
package/store.js
CHANGED
|
@@ -30,6 +30,12 @@ const SECRET_PATTERNS = [
|
|
|
30
30
|
|
|
31
31
|
const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 旧版把超长摘要的原文托管在正文末尾,用这个标记分隔。
|
|
35
|
+
* 现在只拿它做保守识别:旧正文可以标记待整理,但绝不改写;新写入也不再产生尾巴。
|
|
36
|
+
*/
|
|
37
|
+
const LEGACY_SUMMARY_TAIL = '摘要原文:';
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* 实验来源的条目默认不属于普通记忆读取面:开关关闭、暂停或身份未知时,
|
|
35
41
|
* 老 ID、病史查询和关联跳转都不能把正文带出来。管理面显式传 allowExperimental。
|
|
@@ -54,7 +60,11 @@ const bodyCache = new Map();
|
|
|
54
60
|
|
|
55
61
|
function cacheBodies(scope, entries) {
|
|
56
62
|
const map = new Map();
|
|
57
|
-
for (const entry of entries)
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const body = entry.current && entry.current.body;
|
|
65
|
+
/** 空正文不进缓存:归档条目的正文可能冷存,读取时要能回冷存兜底。 */
|
|
66
|
+
if (typeof body === 'string' && body !== '') map.set(entry.id, body);
|
|
67
|
+
}
|
|
58
68
|
bodyCache.set(scope.dir, map);
|
|
59
69
|
}
|
|
60
70
|
|
|
@@ -77,6 +87,8 @@ function toFlat(entry) {
|
|
|
77
87
|
id: entry.id,
|
|
78
88
|
title: current.title || '',
|
|
79
89
|
summary: current.summary || '',
|
|
90
|
+
/** 超长摘要的完整原句单独托管,不进正文;平铺出来供保存路径与回执核对。 */
|
|
91
|
+
summaryFull: typeof maintenance.summaryFull === 'string' ? maintenance.summaryFull : '',
|
|
80
92
|
when: current.when || '',
|
|
81
93
|
body: typeof current.body === 'string' ? current.body : '',
|
|
82
94
|
kind: current.kind || 'note',
|
|
@@ -91,6 +103,10 @@ function toFlat(entry) {
|
|
|
91
103
|
reviewState: maintenance.reviewState || 'active',
|
|
92
104
|
created: entry.createdAt || '',
|
|
93
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 : '',
|
|
94
110
|
lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
|
|
95
111
|
revision: Number(entry.revision) || 1,
|
|
96
112
|
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
@@ -110,7 +126,7 @@ function toFlat(entry) {
|
|
|
110
126
|
|
|
111
127
|
function fromFlat(record, status) {
|
|
112
128
|
const at = record.updated || record.created || new Date().toISOString();
|
|
113
|
-
|
|
129
|
+
const entry = {
|
|
114
130
|
id: record.id,
|
|
115
131
|
revision: Number(record.revision) || 1,
|
|
116
132
|
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
@@ -118,6 +134,10 @@ function fromFlat(record, status) {
|
|
|
118
134
|
ownerProjectId: record.ownerProjectId || '',
|
|
119
135
|
createdAt: record.created || at,
|
|
120
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 : '',
|
|
121
141
|
current: {
|
|
122
142
|
title: record.title || '',
|
|
123
143
|
summary: record.summary || '',
|
|
@@ -148,6 +168,13 @@ function fromFlat(record, status) {
|
|
|
148
168
|
lastUsed: record.lastUsed || at,
|
|
149
169
|
},
|
|
150
170
|
};
|
|
171
|
+
/** 完整摘要是维护区字段:显式给了就覆盖,给了空串就清掉,不给则沿用旧值。 */
|
|
172
|
+
if (Object.prototype.hasOwnProperty.call(record, 'summaryFull')) {
|
|
173
|
+
const full = typeof record.summaryFull === 'string' ? record.summaryFull : '';
|
|
174
|
+
if (full) entry.maintenance.summaryFull = full;
|
|
175
|
+
else delete entry.maintenance.summaryFull;
|
|
176
|
+
}
|
|
177
|
+
return entry;
|
|
151
178
|
}
|
|
152
179
|
|
|
153
180
|
/**
|
|
@@ -161,7 +188,16 @@ export function loadIndex(scope, options = {}) {
|
|
|
161
188
|
}
|
|
162
189
|
const loaded = state.load(scope, options);
|
|
163
190
|
const entries = loaded.state.entries.filter((entry) => entry.status === 'active').map(toFlat);
|
|
164
|
-
|
|
191
|
+
/**
|
|
192
|
+
* 归档条目只加载元数据,正文留在冷存里按需读:列表、图谱、宫殿、snapshot 都
|
|
193
|
+
* 不该为了显示标题去读归档正文,更不能因为某个旧归档缺冷存就把整层读失败。
|
|
194
|
+
*/
|
|
195
|
+
const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map((entry) => {
|
|
196
|
+
const record = toFlat(entry);
|
|
197
|
+
record.cold = Boolean(entry.maintenance && entry.maintenance[COLD_MARK]);
|
|
198
|
+
record.coldBody = '';
|
|
199
|
+
return record;
|
|
200
|
+
});
|
|
165
201
|
cacheBodies(scope, loaded.state.entries);
|
|
166
202
|
const index = {
|
|
167
203
|
version: 2,
|
|
@@ -175,10 +211,12 @@ export function loadIndex(scope, options = {}) {
|
|
|
175
211
|
receipts: JSON.parse(JSON.stringify(loaded.state.receipts || [])),
|
|
176
212
|
lastSuggestedAt: loaded.state.lastSuggestedAt || '',
|
|
177
213
|
suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
|
|
214
|
+
conflicts: JSON.parse(JSON.stringify(loaded.state.conflicts || [])),
|
|
178
215
|
__evolution: state.evolutionOf(loaded.state),
|
|
179
216
|
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
180
217
|
__control: state.controlOf(loaded.state),
|
|
181
218
|
__exists: loaded.exists === true,
|
|
219
|
+
__recovery: loaded.recovery || null,
|
|
182
220
|
};
|
|
183
221
|
index.__meta = {
|
|
184
222
|
exists: loaded.exists,
|
|
@@ -190,14 +228,16 @@ export function loadIndex(scope, options = {}) {
|
|
|
190
228
|
}
|
|
191
229
|
|
|
192
230
|
/** 扁平索引 → v2 状态:整层重写,条目、候选、回执一次换完。 */
|
|
193
|
-
function applyFlat(draft, index) {
|
|
231
|
+
function applyFlat(scope, draft, index) {
|
|
194
232
|
const next = [];
|
|
195
233
|
for (const record of index.entries) next.push(fromFlat(record));
|
|
196
|
-
|
|
234
|
+
/** 归档正文不写回热状态:先落冷存再摘空,冷存写失败就整次保存失败,不丢正文。 */
|
|
235
|
+
for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
|
|
197
236
|
draft.entries = next;
|
|
198
237
|
draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
|
|
199
238
|
draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
|
|
200
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)) : [];
|
|
201
241
|
draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
|
|
202
242
|
draft.scopeKey = draft.scopeKey || index.scope;
|
|
203
243
|
}
|
|
@@ -210,9 +250,10 @@ export function saveIndex(scope, index) {
|
|
|
210
250
|
const meta = index.__meta || { exists: false };
|
|
211
251
|
const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
|
|
212
252
|
const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
253
|
+
const stamp = () => JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.conflicts, draft.lastSuggestedAt]);
|
|
254
|
+
const before = stamp();
|
|
255
|
+
applyFlat(scope, draft, index);
|
|
256
|
+
const after = stamp();
|
|
216
257
|
return { changed: before !== after };
|
|
217
258
|
});
|
|
218
259
|
const published = result.state;
|
|
@@ -291,6 +332,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
291
332
|
const has = (key) => Object.prototype.hasOwnProperty.call(draft, key) && draft[key] !== undefined;
|
|
292
333
|
const id = typeof draft.id === 'string' && /^mem_[a-z0-9]+$/.test(draft.id) ? draft.id : '';
|
|
293
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');
|
|
294
336
|
const when = has('when') ? textField(draft.when, LIMITS.whenChars, '前置条件') : '';
|
|
295
337
|
const title = textField(cleanTitle(draft.title), LIMITS.titleChars, '标题');
|
|
296
338
|
/** 带 id 是更新:省略的字段保持原值,标题也一样。 */
|
|
@@ -311,17 +353,18 @@ export function normalizeDraft(source, options = {}) {
|
|
|
311
353
|
const mayOmitSummary = options.requireSummary === false || Boolean(id);
|
|
312
354
|
if (!summary && !mayOmitSummary) throw new Error('摘要为空');
|
|
313
355
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
356
|
+
* 正文只属于调用方显式给出的内容:摘要超长时不再往正文里塞托管尾巴,
|
|
357
|
+
* 完整原句单独进维护区(maintenance.summaryFull),正文保持原样。
|
|
316
358
|
*/
|
|
317
359
|
const bodyExplicit = has('body');
|
|
318
360
|
const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
361
|
+
const summaryFull = summaryShortened ? rawSummary : '';
|
|
362
|
+
const bodyBytes = Buffer.byteLength(body);
|
|
363
|
+
/** 预算按真正会存进去的正文 + 完整摘要算:超限就报错,不截断后谎报成功。 */
|
|
364
|
+
const storedBytes = bodyBytes + Buffer.byteLength(summaryFull);
|
|
365
|
+
if (storedBytes > LIMITS.entryBytes) {
|
|
366
|
+
throw new Error(summaryFull
|
|
367
|
+
? `正文 ${bodyBytes} 字节加完整摘要 ${Buffer.byteLength(summaryFull)} 字节,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
|
|
325
368
|
: `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
326
369
|
}
|
|
327
370
|
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
@@ -337,8 +380,9 @@ export function normalizeDraft(source, options = {}) {
|
|
|
337
380
|
const links = view.normalizeLinks(draft.links, id);
|
|
338
381
|
const applicability = view.normalizeApplicability(draft.applicability);
|
|
339
382
|
const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
|
|
340
|
-
|
|
341
|
-
|
|
383
|
+
/** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
|
|
384
|
+
const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
|
|
385
|
+
if (secret) throw new state.StateError('unsafe', `疑似凭据(${secret}),只记位置,不记值:正文里写「去哪取、怎么取」,不抄值`);
|
|
342
386
|
return {
|
|
343
387
|
id,
|
|
344
388
|
title,
|
|
@@ -347,7 +391,10 @@ export function normalizeDraft(source, options = {}) {
|
|
|
347
391
|
body,
|
|
348
392
|
summaryShortened,
|
|
349
393
|
summaryRaw: rawSummary,
|
|
350
|
-
|
|
394
|
+
summaryFull,
|
|
395
|
+
/** 回执字段:完整原句已另存维护区(不是塞进正文)。 */
|
|
396
|
+
fullTextSaved: Boolean(summaryFull),
|
|
397
|
+
/** 兼容旧回执:正文里从来没有托管尾巴,这个字段恒为 false。 */
|
|
351
398
|
fullTextInBody: false,
|
|
352
399
|
kind: kind || 'note',
|
|
353
400
|
tags,
|
|
@@ -357,6 +404,8 @@ export function normalizeDraft(source, options = {}) {
|
|
|
357
404
|
links,
|
|
358
405
|
applicability,
|
|
359
406
|
topicKey,
|
|
407
|
+
/** 显式取代:新记录写好后再给旧记录打 supersededBy/validUntil。 */
|
|
408
|
+
supersedes: has('supersedes') ? textField(draft.supersedes, 60, '取代目标') : '',
|
|
360
409
|
has: {
|
|
361
410
|
title: has('title') && Boolean(title),
|
|
362
411
|
summary: has('summary'),
|
|
@@ -370,6 +419,7 @@ export function normalizeDraft(source, options = {}) {
|
|
|
370
419
|
links: links !== undefined,
|
|
371
420
|
applicability: applicability !== undefined,
|
|
372
421
|
topicKey: topicKey !== undefined,
|
|
422
|
+
supersedes: has('supersedes'),
|
|
373
423
|
},
|
|
374
424
|
};
|
|
375
425
|
}
|
|
@@ -381,9 +431,27 @@ function newId() {
|
|
|
381
431
|
/** 正文就在状态里;缓存只是省一次读盘,得手时一律回状态读。 */
|
|
382
432
|
function readBody(scope, id) {
|
|
383
433
|
const cached = cachedBody(scope, id);
|
|
384
|
-
if (typeof cached === 'string') return cached;
|
|
434
|
+
if (typeof cached === 'string' && cached !== '') return cached;
|
|
385
435
|
const found = state.readEntryBody(scope, id);
|
|
386
436
|
if (!found) throw new Error(`记忆正文缺失:${id} 在索引里,但正文文件不在了`);
|
|
437
|
+
/**
|
|
438
|
+
* 归档正文不在热状态里:只有真要读这一条正文时才回冷存取,
|
|
439
|
+
* 缺文件/坏文件都报明确错误,不拿空正文冒充「这条没有正文」。
|
|
440
|
+
*/
|
|
441
|
+
if (!found.body && found.entry && found.entry.status === 'archived') {
|
|
442
|
+
let payload = null;
|
|
443
|
+
try {
|
|
444
|
+
payload = readCold(scope, id, { required: true });
|
|
445
|
+
} catch (error) {
|
|
446
|
+
/** 旧版归档在正文为空时可能没写过冷存:这类老记录按空正文读回,不改写也不再报错。 */
|
|
447
|
+
const legacy = !(found.entry.maintenance && found.entry.maintenance.coldSaved === true);
|
|
448
|
+
if (!(legacy && error && error.code === 'cold_missing')) throw error;
|
|
449
|
+
}
|
|
450
|
+
if (payload && typeof payload.body === 'string' && payload.body) {
|
|
451
|
+
writeBody(scope, id, payload.body);
|
|
452
|
+
return payload.body;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
387
455
|
return found.body;
|
|
388
456
|
}
|
|
389
457
|
|
|
@@ -398,6 +466,192 @@ function removeBody(scope, id) {
|
|
|
398
466
|
if (map) map.delete(id);
|
|
399
467
|
}
|
|
400
468
|
|
|
469
|
+
/**
|
|
470
|
+
* 归档正文冷存:热状态只留元数据,正文和旧版本正文放 scope.dir/cold/<id>.json。
|
|
471
|
+
* 五百条归档 ≈ 2 MiB 时,只有真看过的那几条会回到内存,保存不再被整层预算卡死。
|
|
472
|
+
*/
|
|
473
|
+
const COLD_DIR = 'cold';
|
|
474
|
+
const COLD_MARK = 'coldBody';
|
|
475
|
+
|
|
476
|
+
function coldDirOf(scope) {
|
|
477
|
+
return path.join(scope.dir, COLD_DIR);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function coldFileOf(scope, id) {
|
|
481
|
+
return path.join(coldDirOf(scope), `${id}.json`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function coldPayload(scope, record) {
|
|
485
|
+
return {
|
|
486
|
+
/** 冷存文件自带身份:读回来先核对是不是这一条、这一层,不靠文件名猜。 */
|
|
487
|
+
id: record.id,
|
|
488
|
+
scope: scope.key,
|
|
489
|
+
revision: Number(record.revision) || 1,
|
|
490
|
+
at: new Date().toISOString(),
|
|
491
|
+
body: typeof record.body === 'string' ? record.body : '',
|
|
492
|
+
versions: (Array.isArray(record.versions) ? record.versions : [])
|
|
493
|
+
.map((version) => ({ at: String((version && version.at) || ''), body: typeof (version && version.body) === 'string' ? version.body : '' }))
|
|
494
|
+
.filter((version) => version.body),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 冷存写失败就报错:宁可整次保存失败,也不能把正文悄悄丢掉。
|
|
500
|
+
* 落盘序列与 state.json 一致(fsync 文件 → rename → fsync 目录):归档正文只在这里,
|
|
501
|
+
* 少一步 fsync,崩溃后就是「热状态说有归档、冷存文件是空的」。
|
|
502
|
+
*/
|
|
503
|
+
function writeCold(scope, record) {
|
|
504
|
+
const file = coldFileOf(scope, record.id);
|
|
505
|
+
try {
|
|
506
|
+
state.writeDurable(file, `${JSON.stringify(coldPayload(scope, record))}\n`);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
throw new state.StateError('unavailable', `归档正文没写进冷存(${path.basename(file)}):${(error && error.message) || 'IO 错误'}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 读冷存:文件缺失时按 required 决定返回 null 还是报错;JSON 损坏、身份不符
|
|
514
|
+
* 一律抛明确错误,绝不静默返回空正文冒充「这条没有正文」。
|
|
515
|
+
*/
|
|
516
|
+
function readCold(scope, id, options = {}) {
|
|
517
|
+
const required = options.required === true;
|
|
518
|
+
const file = coldFileOf(scope, id);
|
|
519
|
+
let raw;
|
|
520
|
+
try {
|
|
521
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
522
|
+
} catch (error) {
|
|
523
|
+
if (error && error.code === 'ENOENT') {
|
|
524
|
+
if (!required) return null;
|
|
525
|
+
throw new state.StateError('cold_missing', `冷存里没有这条的正文备份(${file});先修复冷存再读,不拿空正文顶上`);
|
|
526
|
+
}
|
|
527
|
+
throw error;
|
|
528
|
+
}
|
|
529
|
+
let payload;
|
|
530
|
+
try {
|
|
531
|
+
payload = JSON.parse(raw);
|
|
532
|
+
} catch {
|
|
533
|
+
throw new state.StateError('cold_corrupt', `冷存文件不是合法 JSON(${file});先修复再读,不拿空正文顶上`);
|
|
534
|
+
}
|
|
535
|
+
if (!payload || typeof payload !== 'object' || payload.id !== id || payload.scope !== scope.key || typeof payload.body !== 'string') {
|
|
536
|
+
throw new state.StateError('cold_corrupt', `冷存文件记录的条目不匹配这一条或这一层(${file});先修复再读,不拿空正文顶上`);
|
|
537
|
+
}
|
|
538
|
+
return payload;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** 写回状态前把归档正文摘出去:只有正文/旧版本真变了才重写冷存文件。 */
|
|
542
|
+
function coldStored(scope, record) {
|
|
543
|
+
if (record.status !== 'archived') return record;
|
|
544
|
+
const versions = Array.isArray(record.versions) ? record.versions : [];
|
|
545
|
+
const hasVersionBodies = versions.some((version) => typeof (version && version.body) === 'string' && version.body);
|
|
546
|
+
/** 内存记录的正文可能已经是空串(占位/摘过):先按 coldBody、冷存文件、热状态顺序找回。 */
|
|
547
|
+
let body = typeof record.body === 'string' ? record.body : '';
|
|
548
|
+
const known = typeof record.coldBody === 'string' ? record.coldBody : null;
|
|
549
|
+
if (body || known !== null) {
|
|
550
|
+
body = body || known;
|
|
551
|
+
} else {
|
|
552
|
+
/** 冷存是唯一权威副本;它坏了先退回热状态里的正文(保存时会重写成好文件),两处都没有才报错。 */
|
|
553
|
+
let coldError = null;
|
|
554
|
+
try {
|
|
555
|
+
const payload = readCold(scope, record.id);
|
|
556
|
+
if (payload && typeof payload.body === 'string' && payload.body) body = payload.body;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
coldError = error;
|
|
559
|
+
}
|
|
560
|
+
if (!body) {
|
|
561
|
+
const found = state.readEntryBody(scope, record.id);
|
|
562
|
+
if (found && typeof found.body === 'string') body = found.body;
|
|
563
|
+
}
|
|
564
|
+
if (coldError && !body) throw coldError;
|
|
565
|
+
}
|
|
566
|
+
/** 正文和冷存里的一致就不重写:五百条归档每次保存只动真变过的那几条。 */
|
|
567
|
+
const cached = record.cold === true && known !== null && known === body;
|
|
568
|
+
/** 空正文也要落一份冷存:只要标了 coldBody,就必须有自证身份的文件可读回。 */
|
|
569
|
+
if (!cached) writeCold(scope, { ...record, body });
|
|
570
|
+
return {
|
|
571
|
+
...record,
|
|
572
|
+
body: '',
|
|
573
|
+
cold: true,
|
|
574
|
+
coldBody: body,
|
|
575
|
+
versions: versions.map((version) => ({ ...(version || {}), body: '' })),
|
|
576
|
+
maintenance: { ...(record.maintenance || {}), [COLD_MARK]: true, coldSaved: true },
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 彻底删除是一个事务:先把要删的冷存文件按 id 精确移到层外的隔离区
|
|
582
|
+
* (state.purge 只扫 scope.dir,所以隔离区必须在层外),state.purge 成功后才丢弃隔离副本;
|
|
583
|
+
* 失败按错误类型恢复或留待重放收尾。绝不按标题/正文片段扫描别人的冷存文件。
|
|
584
|
+
*/
|
|
585
|
+
const PURGE_TRASH_DIR = '.purge-trash';
|
|
586
|
+
|
|
587
|
+
function trashDirOf(scope) {
|
|
588
|
+
const safe = String(scope.key || '').replace(/[^A-Za-z0-9._-]/g, '_') || '_';
|
|
589
|
+
return path.join(memoryRoot(), PURGE_TRASH_DIR, safe);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 只搬走 ids 对应的 <id>.json;别的条目一个字节都不动。 */
|
|
593
|
+
function isolateCold(scope, ids, operationId) {
|
|
594
|
+
const token = String(operationId || '').replace(/[^A-Za-z0-9._-]/g, '_') || crypto.randomBytes(8).toString('hex');
|
|
595
|
+
const dir = path.join(trashDirOf(scope), token);
|
|
596
|
+
const moved = [];
|
|
597
|
+
for (const id of ids || []) {
|
|
598
|
+
const from = coldFileOf(scope, id);
|
|
599
|
+
if (!fs.existsSync(from)) continue;
|
|
600
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
601
|
+
const to = path.join(dir, `${id}.json`);
|
|
602
|
+
fs.renameSync(from, to);
|
|
603
|
+
moved.push({ from, to });
|
|
604
|
+
}
|
|
605
|
+
return { dir, moved };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function restoreCold(isolated) {
|
|
609
|
+
let restored = true;
|
|
610
|
+
for (const row of isolated.moved) {
|
|
611
|
+
try {
|
|
612
|
+
fs.renameSync(row.to, row.from);
|
|
613
|
+
} catch {
|
|
614
|
+
/** 恢复失败就保留整个隔离区:副本还在,重放/人工都能取回,不能顺手删掉。 */
|
|
615
|
+
restored = false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (restored) dropColdQuarantine(isolated);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function dropColdQuarantine(isolated) {
|
|
622
|
+
try {
|
|
623
|
+
fs.rmSync(isolated.dir, { recursive: true, force: true });
|
|
624
|
+
} catch {
|
|
625
|
+
/** 删不掉隔离目录不影响删除结果,下次收尾再清。 */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** 删除确认后的收尾:清掉这一层隔离区里残留的副本(上一次做到一半留下的)。 */
|
|
630
|
+
function cleanColdQuarantine(scope) {
|
|
631
|
+
try {
|
|
632
|
+
fs.rmSync(trashDirOf(scope), { recursive: true, force: true });
|
|
633
|
+
} catch {
|
|
634
|
+
/** 收尾清不掉不影响数据正确性。 */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 删除与冷存的一致顺序:隔离 → 发布删除 → 丢弃隔离副本。
|
|
640
|
+
* 发布失败(例如 purge_pending)时保留隔离副本,留给重放路径收尾。
|
|
641
|
+
*/
|
|
642
|
+
function purgeWithCold(scope, ids, run) {
|
|
643
|
+
const isolated = isolateCold(scope, ids);
|
|
644
|
+
try {
|
|
645
|
+
const result = run();
|
|
646
|
+
dropColdQuarantine(isolated);
|
|
647
|
+
return result;
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (error && error.code === 'purge_pending') throw error;
|
|
650
|
+
restoreCold(isolated);
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
401
655
|
function entrySize(record, body) {
|
|
402
656
|
const meta = [record.title || '', record.summary || '', record.when || '', (record.tags || []).join(',')].join('\n');
|
|
403
657
|
return Buffer.byteLength(meta) + Buffer.byteLength(body || '') + Buffer.byteLength(state.fieldsBytesOf(record));
|
|
@@ -456,9 +710,9 @@ const STOP_GRAMS = new Set([
|
|
|
456
710
|
'的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
|
|
457
711
|
]);
|
|
458
712
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const wordIn =
|
|
713
|
+
/** 英文整词规则只有一份,放在 view:自动召回和手动搜索必须同一个判断。 */
|
|
714
|
+
const asciiWordsOf = view.asciiWordsOf;
|
|
715
|
+
const wordIn = view.wordIn;
|
|
462
716
|
|
|
463
717
|
/** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
|
|
464
718
|
function queryRuns(query) {
|
|
@@ -573,7 +827,16 @@ export function similarEntries(entries, title, summary, limit = 2) {
|
|
|
573
827
|
const titleScore = Math.max(0, ...names.map((name) => similarity(name, title)));
|
|
574
828
|
const summaryScore = similarity(item.summary, summary);
|
|
575
829
|
const score = Math.max(titleScore, summaryScore);
|
|
576
|
-
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
|
+
});
|
|
577
840
|
}
|
|
578
841
|
rows.sort((a, b) => b.score - a.score);
|
|
579
842
|
return rows.slice(0, limit);
|
|
@@ -607,6 +870,7 @@ function snapshot(record, body) {
|
|
|
607
870
|
at: record.updated || record.created || new Date().toISOString(),
|
|
608
871
|
title: record.title,
|
|
609
872
|
summary: record.summary,
|
|
873
|
+
summaryFull: typeof record.summaryFull === 'string' ? record.summaryFull : '',
|
|
610
874
|
when: record.when || '',
|
|
611
875
|
kind: record.kind || 'note',
|
|
612
876
|
tags: [...(record.tags || [])],
|
|
@@ -651,12 +915,12 @@ function rulesOf(draft, body, index, foreign) {
|
|
|
651
915
|
return rules;
|
|
652
916
|
}
|
|
653
917
|
|
|
654
|
-
/**
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
return
|
|
918
|
+
/**
|
|
919
|
+
* 旧正文里可能留着「摘要原文:」尾巴:只识别、做标记,绝不改写。
|
|
920
|
+
* 猜不出来哪一段是托管内容,删错就是把用户原话删了。
|
|
921
|
+
*/
|
|
922
|
+
function hasLegacySummaryTail(text) {
|
|
923
|
+
return typeof text === 'string' && text.includes(LEGACY_SUMMARY_TAIL);
|
|
660
924
|
}
|
|
661
925
|
|
|
662
926
|
function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
|
|
@@ -672,9 +936,8 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
672
936
|
selection: draft.has.selection ? draft.selection : null,
|
|
673
937
|
links: draft.has.links ? draft.links : [],
|
|
674
938
|
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
675
|
-
body:
|
|
939
|
+
body: String(draft.body || ''),
|
|
676
940
|
};
|
|
677
|
-
if (next.body !== draft.body) draft.fullTextInBody = true;
|
|
678
941
|
next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
679
942
|
next.conditions, next.selection, next.when);
|
|
680
943
|
assertLinksVisible(next.links, index, foreign);
|
|
@@ -707,36 +970,74 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
707
970
|
return next;
|
|
708
971
|
}
|
|
709
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
|
+
|
|
710
993
|
/**
|
|
711
994
|
* 一次写入的纯业务部分:校验、身份、字节预算、版本。不改盘(正文写入由调用方在同一把锁里做)。
|
|
712
995
|
*/
|
|
713
996
|
function applyDraft(scope, index, source, now, options = {}) {
|
|
714
997
|
const draft = normalizeDraft(source);
|
|
998
|
+
scanUnsafe(draft);
|
|
715
999
|
const foreign = options.foreignEntries || [];
|
|
716
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
|
+
};
|
|
717
1010
|
let record = draft.id ? index.entries.find((item) => item.id === draft.id) : null;
|
|
718
1011
|
if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
|
|
719
1012
|
let mergedFrom = null;
|
|
720
1013
|
let similarList = [];
|
|
721
1014
|
if (!record) {
|
|
722
1015
|
/** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
|
|
723
|
-
const hit = distinct ? null : resolveTarget(
|
|
1016
|
+
const hit = distinct ? null : resolveTarget(live, draft);
|
|
724
1017
|
if (hit) {
|
|
725
1018
|
record = hit.target;
|
|
726
1019
|
mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
|
|
727
1020
|
} else if (draft.has.topicKey && draft.topicKey && !distinct) {
|
|
728
1021
|
/** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
|
|
729
|
-
const slot =
|
|
1022
|
+
const slot = live.find((item) => (item.topicKey || '') === draft.topicKey
|
|
730
1023
|
&& view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
|
|
731
1024
|
if (slot) {
|
|
732
|
-
const row = {
|
|
1025
|
+
const row = {
|
|
1026
|
+
id: slot.id,
|
|
1027
|
+
title: slot.title,
|
|
1028
|
+
summary: slot.summary || '',
|
|
1029
|
+
when: slot.when || '',
|
|
1030
|
+
updated: slot.updated || slot.created || '',
|
|
1031
|
+
reason: 'same_topic',
|
|
1032
|
+
revision: slot.revision || 1,
|
|
1033
|
+
};
|
|
733
1034
|
return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
734
1035
|
}
|
|
735
|
-
similarList = similarEntries(
|
|
1036
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
736
1037
|
} else {
|
|
737
|
-
similarList = similarEntries(
|
|
1038
|
+
similarList = similarEntries(live, draft.title, draft.summary, 2);
|
|
738
1039
|
/** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
|
|
739
|
-
const sameName =
|
|
1040
|
+
const sameName = live.some((item) => [item.title, ...(item.aliases || [])]
|
|
740
1041
|
.some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
|
|
741
1042
|
if (similarList.length > 0 && !distinct && !sameName) {
|
|
742
1043
|
return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
@@ -750,11 +1051,16 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
750
1051
|
}
|
|
751
1052
|
}
|
|
752
1053
|
if (record) {
|
|
1054
|
+
const target = supersedeTarget(record.id);
|
|
753
1055
|
const oldBody = readBody(scope, record.id);
|
|
754
1056
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1057
|
+
/** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
|
|
1058
|
+
draft.fullTextInBody = false;
|
|
1059
|
+
/** 摘要这次被改过就按本次结果落 summaryFull:改短即清空,没改则保持原值。 */
|
|
1060
|
+
const summaryFull = draft.has.summary
|
|
1061
|
+
? (draft.summaryShortened ? String(draft.summaryRaw || '') : '')
|
|
1062
|
+
: (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
1063
|
+
draft.fullTextSaved = Boolean(summaryFull);
|
|
758
1064
|
const noop = next.title === record.title && next.summary === record.summary
|
|
759
1065
|
&& next.when === (record.when || '') && next.kind === (record.kind || 'note')
|
|
760
1066
|
&& JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
|
|
@@ -764,8 +1070,9 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
764
1070
|
&& JSON.stringify(next.links) === JSON.stringify(record.links || [])
|
|
765
1071
|
&& (next.topicKey || '') === (record.topicKey || '')
|
|
766
1072
|
&& next.applicability === (record.applicability || 'all_in_scope')
|
|
767
|
-
&& next.body === oldBody
|
|
768
|
-
|
|
1073
|
+
&& next.body === oldBody
|
|
1074
|
+
&& summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
1075
|
+
if (noop && !target) {
|
|
769
1076
|
return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
|
|
770
1077
|
}
|
|
771
1078
|
const versions = snapshot(record, oldBody);
|
|
@@ -773,6 +1080,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
773
1080
|
const nextRecord = {
|
|
774
1081
|
...record,
|
|
775
1082
|
...next,
|
|
1083
|
+
summaryFull,
|
|
776
1084
|
aliases: titles,
|
|
777
1085
|
versions,
|
|
778
1086
|
revision: (record.revision || 1) + 1,
|
|
@@ -782,22 +1090,36 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
782
1090
|
status: 'active',
|
|
783
1091
|
source: record.source || (source && source.source) || 'user_explicit',
|
|
784
1092
|
};
|
|
1093
|
+
nextRecord.maintenance = { ...(record.maintenance || {}) };
|
|
1094
|
+
if (summaryFull) nextRecord.maintenance.summaryFull = summaryFull;
|
|
1095
|
+
else delete nextRecord.maintenance.summaryFull;
|
|
1096
|
+
/** 旧正文里的托管尾巴只标记待整理,绝不在这里改写用户正文。 */
|
|
1097
|
+
if (hasLegacySummaryTail(next.body)) nextRecord.maintenance.legacySummaryTail = true;
|
|
785
1098
|
nextRecord.bytes = entrySize(nextRecord, next.body);
|
|
786
1099
|
const archived = assertFits(scope, index, nextRecord);
|
|
787
1100
|
writeBody(scope, record.id, next.body);
|
|
788
1101
|
Object.assign(record, nextRecord);
|
|
789
|
-
|
|
1102
|
+
/** 更新让这条重新成为现行版本:旧的取代链标记要摘掉。 */
|
|
1103
|
+
delete record.supersededBy;
|
|
1104
|
+
record.validUntil = '';
|
|
1105
|
+
if (target) {
|
|
1106
|
+
const chain = stampSupersede(scope, target, record.id, now);
|
|
1107
|
+
record.versions = [...chain, ...(record.versions || [])].slice(0, 2);
|
|
1108
|
+
}
|
|
1109
|
+
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: Boolean(noop), previousBody: oldBody, archived };
|
|
790
1110
|
}
|
|
791
1111
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
const createdBody =
|
|
795
|
-
|
|
1112
|
+
/** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
|
|
1113
|
+
const target = supersedeTarget('');
|
|
1114
|
+
const createdBody = draft.body;
|
|
1115
|
+
draft.fullTextInBody = false;
|
|
1116
|
+
draft.fullTextSaved = Boolean(draft.summaryFull);
|
|
796
1117
|
const rules = rulesOf(draft, createdBody, index, foreign);
|
|
797
1118
|
const created = {
|
|
798
1119
|
id: newId(),
|
|
799
1120
|
title: draft.title,
|
|
800
1121
|
summary: draft.summary,
|
|
1122
|
+
summaryFull: draft.summaryFull,
|
|
801
1123
|
when: draft.when,
|
|
802
1124
|
kind: draft.kind,
|
|
803
1125
|
tags: draft.tags,
|
|
@@ -811,6 +1133,10 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
811
1133
|
aliases: [],
|
|
812
1134
|
created: now,
|
|
813
1135
|
updated: now,
|
|
1136
|
+
validFrom: now,
|
|
1137
|
+
validUntil: '',
|
|
1138
|
+
supersededBy: '',
|
|
1139
|
+
supersedeReason: '',
|
|
814
1140
|
lastUsed: now,
|
|
815
1141
|
revision: 1,
|
|
816
1142
|
status: 'active',
|
|
@@ -818,15 +1144,52 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
818
1144
|
hits: 0,
|
|
819
1145
|
merges: 0,
|
|
820
1146
|
versions: [],
|
|
1147
|
+
maintenance: {},
|
|
821
1148
|
bytes: 0,
|
|
822
1149
|
};
|
|
1150
|
+
if (draft.summaryFull) created.maintenance.summaryFull = draft.summaryFull;
|
|
1151
|
+
/** 旧正文里的托管尾巴只标记待整理,绝不改写用户正文。 */
|
|
1152
|
+
if (hasLegacySummaryTail(createdBody)) created.maintenance.legacySummaryTail = true;
|
|
823
1153
|
created.bytes = entrySize(created, created.body);
|
|
824
1154
|
const archived = assertFits(scope, index, created);
|
|
825
1155
|
writeBody(scope, created.id, created.body);
|
|
826
1156
|
index.entries.push(created);
|
|
1157
|
+
if (target) created.versions = stampSupersede(scope, target, created.id, now);
|
|
827
1158
|
return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
|
|
828
1159
|
}
|
|
829
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* 写入前兜底扫描:记忆会被 memory_read 原样喂回模型,所以只拦形状明确的三类——
|
|
1163
|
+
* 提示注入指令、凭据原文、不可见 Unicode;提到「凭据 / 指令」的正文不算。
|
|
1164
|
+
*/
|
|
1165
|
+
const UNSAFE_TEXT = [
|
|
1166
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /ignore\s+(?:all\s+)?(?:previous|prior|above)\s+(?:instructions?|rules?|prompts?)/i },
|
|
1167
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /忽略(?:上面|以上|此前|之前)?(?:的)?(?:所有)?(?:指令|指示|要求|规则)/ },
|
|
1168
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /<\/?(?:system|instructions?|prompt|tool_call)[^>]*>/i },
|
|
1169
|
+
{ code: 'injection', why: '像提示注入的指令', fix: '把「指令」改成对规则的描述', test: /(?:^|\n)\s*(?:system|assistant)\s*[::]/i },
|
|
1170
|
+
];
|
|
1171
|
+
const INVISIBLE = /[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
|
|
1172
|
+
const SCAN_FIELD_NAMES = { title: '标题', summary: '摘要', when: '适用场景', body: '正文', tags: '标签', triggers: '触发词', conditions: '条件', selection: '适用范围' };
|
|
1173
|
+
|
|
1174
|
+
function scanUnsafe(draft) {
|
|
1175
|
+
const fields = [
|
|
1176
|
+
['title', draft.title], ['summary', draft.summary], ['when', draft.when], ['body', draft.body],
|
|
1177
|
+
['tags', (draft.tags || []).join('\n')], ['triggers', (draft.triggers || []).join('\n')],
|
|
1178
|
+
['conditions', JSON.stringify(draft.conditions || [])], ['selection', JSON.stringify(draft.selection || null)],
|
|
1179
|
+
];
|
|
1180
|
+
for (const [field, raw] of fields) {
|
|
1181
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
1182
|
+
if (!text) continue;
|
|
1183
|
+
const name = SCAN_FIELD_NAMES[field] || field;
|
|
1184
|
+
if (INVISIBLE.test(text)) {
|
|
1185
|
+
throw new state.StateError('unsafe', `${name}里有看不见的字符(零宽或方向控制);它们会跟着记忆原样回到模型,删掉再存`);
|
|
1186
|
+
}
|
|
1187
|
+
for (const rule of UNSAFE_TEXT) {
|
|
1188
|
+
if (rule.test.test(text)) throw new state.StateError('unsafe', `${name}${rule.why};${rule.fix}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
830
1193
|
/**
|
|
831
1194
|
* 单条与记录序列化两档预算:新增与更新走同一套。
|
|
832
1195
|
* 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
|
|
@@ -1055,8 +1418,41 @@ export function setTaskDefault(scope, defaults, options = {}) {
|
|
|
1055
1418
|
* 返回的记录带着「本次检索之前」的 lastUsed,给 renderIndex 判断陈旧用;
|
|
1056
1419
|
* 命中计数在返回前写入,所以同一次调用既改盘又不污染展示。
|
|
1057
1420
|
*/
|
|
1421
|
+
/** 「用过」的判定门槛:lastUsed 超过这么久才回写,检索不至于每次都抢锁落盘。 */
|
|
1422
|
+
const USED_TOUCH_MS = 10 * 60 * 1000;
|
|
1423
|
+
|
|
1424
|
+
function usedStale(record, now) {
|
|
1425
|
+
const at = Date.parse(String((record && (record.lastUsed || record.updated)) || ''));
|
|
1426
|
+
return !Number.isFinite(at) || now - at >= USED_TOUCH_MS;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
|
|
1431
|
+
* 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
|
|
1432
|
+
*/
|
|
1433
|
+
function touchUsed(scope, ids) {
|
|
1434
|
+
const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
|
|
1435
|
+
if (wanted.size === 0) return;
|
|
1436
|
+
try {
|
|
1437
|
+
withLock(scope, () => {
|
|
1438
|
+
const index = loadIndex(scope);
|
|
1439
|
+
const now = Date.now();
|
|
1440
|
+
const stamp = new Date(now).toISOString();
|
|
1441
|
+
let changed = false;
|
|
1442
|
+
for (const record of index.entries) {
|
|
1443
|
+
if (!wanted.has(record.id) || !usedStale(record, now)) continue;
|
|
1444
|
+
record.lastUsed = stamp;
|
|
1445
|
+
changed = true;
|
|
1446
|
+
}
|
|
1447
|
+
if (changed) saveIndex(scope, index);
|
|
1448
|
+
});
|
|
1449
|
+
} catch {
|
|
1450
|
+
/* lastUsed 只是提示:失败不影响这次检索/阅读的结果。 */
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1058
1454
|
export function search(scope, query, limit = 4, options = {}) {
|
|
1059
|
-
|
|
1455
|
+
const found = withLocks([scope, globalScope()], () => {
|
|
1060
1456
|
const grams = bigrams(query);
|
|
1061
1457
|
if (grams.size === 0) return [];
|
|
1062
1458
|
const runs = queryRuns(query);
|
|
@@ -1071,6 +1467,8 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1071
1467
|
const collect = (pool, archived) => {
|
|
1072
1468
|
for (const item of pool) {
|
|
1073
1469
|
if (!readableRecord(item, options, index)) continue;
|
|
1470
|
+
/** 被取代/过期待记录默认不出现;history:true 才列出,供模型看演化。 */
|
|
1471
|
+
if (options.history !== true && staleRecord(item, options.now || Date.now())) continue;
|
|
1074
1472
|
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1075
1473
|
if (options.allowExperimental === true && options.history !== true
|
|
1076
1474
|
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
@@ -1098,14 +1496,23 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1098
1496
|
|| Number(a.archived) - Number(b.archived)
|
|
1099
1497
|
|| String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
|
|
1100
1498
|
const chosen = visible.slice(0, limit);
|
|
1101
|
-
/**
|
|
1499
|
+
/** hits 不在检索里记,lastUsed 也只在锁外补写;这里先把层信息和结果一起带出去。 */
|
|
1102
1500
|
return chosen.map((row) => ({
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1501
|
+
record: {
|
|
1502
|
+
...row.item,
|
|
1503
|
+
scope: row.source.key === 'global' ? 'global' : 'project',
|
|
1504
|
+
status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
|
|
1505
|
+
archived: Boolean(row.archived),
|
|
1506
|
+
},
|
|
1507
|
+
source: row.source,
|
|
1106
1508
|
archived: Boolean(row.archived),
|
|
1107
1509
|
}));
|
|
1108
1510
|
});
|
|
1511
|
+
/** 锁外回写:在层锁里再抢同一层的锁会死锁。 */
|
|
1512
|
+
for (const row of found) {
|
|
1513
|
+
if (!row.archived) touchUsed(row.source, [row.record.id]);
|
|
1514
|
+
}
|
|
1515
|
+
return found.map((row) => row.record);
|
|
1109
1516
|
}
|
|
1110
1517
|
|
|
1111
1518
|
/**
|
|
@@ -1146,7 +1553,7 @@ export function peek(scope, query, limit = 3, options = {}) {
|
|
|
1146
1553
|
}
|
|
1147
1554
|
|
|
1148
1555
|
export function readEntry(scope, id, options = {}) {
|
|
1149
|
-
|
|
1556
|
+
const found = withLocks([scope, globalScope()], () => {
|
|
1150
1557
|
for (const source of [scope, globalScope()]) {
|
|
1151
1558
|
const index = loadIndex(source);
|
|
1152
1559
|
const record = index.entries.find((item) => item.id === id);
|
|
@@ -1155,14 +1562,22 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1155
1562
|
if (!readableRecord(record || archived, options, index)) continue;
|
|
1156
1563
|
const body = readBody(source, id);
|
|
1157
1564
|
return {
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1565
|
+
source,
|
|
1566
|
+
archived: Boolean(archived),
|
|
1567
|
+
record: {
|
|
1568
|
+
...(record || archived),
|
|
1569
|
+
scope: source.key === 'global' ? 'global' : 'project',
|
|
1570
|
+
body,
|
|
1571
|
+
status: archived ? 'archived' : view.statusOf(record, options.context || {}),
|
|
1572
|
+
},
|
|
1162
1573
|
};
|
|
1163
1574
|
}
|
|
1164
1575
|
return null;
|
|
1165
1576
|
});
|
|
1577
|
+
if (!found) return null;
|
|
1578
|
+
/** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
|
|
1579
|
+
if (!found.archived) touchUsed(found.source, [id]);
|
|
1580
|
+
return found.record;
|
|
1166
1581
|
}
|
|
1167
1582
|
|
|
1168
1583
|
/**
|
|
@@ -1179,7 +1594,9 @@ export function forget(scope, id, options = {}) {
|
|
|
1179
1594
|
for (const source of [scope, globalScope()]) {
|
|
1180
1595
|
const pending = state.purgeIntent(source);
|
|
1181
1596
|
if (!pending) continue;
|
|
1182
|
-
|
|
1597
|
+
/** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
|
|
1598
|
+
const result = purgeWithCold(source, pending.ids, () => state.purge(source, { resume: true }));
|
|
1599
|
+
cleanColdQuarantine(source);
|
|
1183
1600
|
removeBody(source, id);
|
|
1184
1601
|
if (pending.ids.includes(id)) {
|
|
1185
1602
|
replayed = {
|
|
@@ -1217,7 +1634,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1217
1634
|
}
|
|
1218
1635
|
if (archived) {
|
|
1219
1636
|
/** 归档条目也能彻底删除:state.purge 本来就按 id 清整份状态,只是以前没走到这里。 */
|
|
1220
|
-
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1637
|
+
const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
|
|
1638
|
+
cleanColdQuarantine(source);
|
|
1221
1639
|
removeBody(source, id);
|
|
1222
1640
|
return {
|
|
1223
1641
|
id,
|
|
@@ -1228,7 +1646,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1228
1646
|
scopeKey: source.key,
|
|
1229
1647
|
};
|
|
1230
1648
|
}
|
|
1231
|
-
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1649
|
+
const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
|
|
1650
|
+
cleanColdQuarantine(source);
|
|
1232
1651
|
removeBody(source, id);
|
|
1233
1652
|
return {
|
|
1234
1653
|
id,
|
|
@@ -1275,8 +1694,9 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1275
1694
|
const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
|
|
1276
1695
|
if (!record) continue;
|
|
1277
1696
|
if (!readableRecord(record, options, index)) continue;
|
|
1278
|
-
/**
|
|
1279
|
-
|
|
1697
|
+
/** 关联目标与来源走同一资格门槛:项目归属、学习状态、条件适用缺一不可。 */
|
|
1698
|
+
const gate = view.eligibility(record, options.runtime || {}, 'read', options.now || Date.now());
|
|
1699
|
+
if (!gate.ok || gate.status !== 'ok') continue;
|
|
1280
1700
|
if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
|
|
1281
1701
|
return {
|
|
1282
1702
|
id: record.id,
|
|
@@ -1284,6 +1704,8 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1284
1704
|
summary: record.summary || '',
|
|
1285
1705
|
scope: source.key === 'global' ? 'global' : 'project',
|
|
1286
1706
|
revision: record.revision || 1,
|
|
1707
|
+
validUntil: record.validUntil || '',
|
|
1708
|
+
supersededBy: record.supersededBy || '',
|
|
1287
1709
|
status: 'ok',
|
|
1288
1710
|
};
|
|
1289
1711
|
}
|
|
@@ -1314,6 +1736,7 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1314
1736
|
...record,
|
|
1315
1737
|
title: last.title || record.title,
|
|
1316
1738
|
summary: last.summary || record.summary,
|
|
1739
|
+
summaryFull: typeof last.summaryFull === 'string' ? last.summaryFull : '',
|
|
1317
1740
|
when: last.when || '',
|
|
1318
1741
|
kind: last.kind || record.kind,
|
|
1319
1742
|
tags: Array.isArray(last.tags) ? last.tags : record.tags,
|
|
@@ -1330,6 +1753,9 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1330
1753
|
};
|
|
1331
1754
|
writeBody(source, record.id, last.body);
|
|
1332
1755
|
Object.assign(record, restored);
|
|
1756
|
+
record.maintenance = { ...(record.maintenance || {}) };
|
|
1757
|
+
if (record.summaryFull) record.maintenance.summaryFull = record.summaryFull;
|
|
1758
|
+
else delete record.maintenance.summaryFull;
|
|
1333
1759
|
record.bytes = entrySize(record, last.body);
|
|
1334
1760
|
try {
|
|
1335
1761
|
saveIndex(source, index);
|
|
@@ -1893,6 +2319,27 @@ const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
|
|
|
1893
2319
|
* 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
|
|
1894
2320
|
* 判不出来就返回 needsScope,让调用方问一句,而不是默默写进某一层。
|
|
1895
2321
|
*/
|
|
2322
|
+
/** 本机痕迹:路径、回环地址、端口。带这些字样的「通用」规则换个项目就用不了。 */
|
|
2323
|
+
const MACHINE_BOUND_PATTERNS = [
|
|
2324
|
+
/(?:^|[\s(('"`])(?:\/tmp|\/Users|\/home|\/opt|\/private|\/var|\/etc)(?:[\/]|$)/m,
|
|
2325
|
+
/(?:^|[\s(('"`])~\//m,
|
|
2326
|
+
/(?:^|[^A-Za-z0-9])[A-Za-z]:\\/,
|
|
2327
|
+
/(?:^|[^0-9])127\.0\.0\.1(?::\d+)?/,
|
|
2328
|
+
/(?:^|[^A-Za-z0-9.])localhost(?::\d+)?/,
|
|
2329
|
+
/\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
|
|
2330
|
+
];
|
|
2331
|
+
|
|
2332
|
+
function machineBoundText(source) {
|
|
2333
|
+
const text = [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
|
|
2334
|
+
.filter((value) => typeof value === 'string' && value)
|
|
2335
|
+
.join('\n');
|
|
2336
|
+
return MACHINE_BOUND_PATTERNS.some((re) => re.test(text));
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* 适用范围判定:内容/依据的普适性优先,模型声明的 scope 只是提案。
|
|
2341
|
+
* 模型说 global、但正文写死了本机路径或依据是本机文件时,先留在项目层(或问一次条件)。
|
|
2342
|
+
*/
|
|
1896
2343
|
export function routeScope(draft, options = {}) {
|
|
1897
2344
|
const source = draft || {};
|
|
1898
2345
|
const explicit = source.scope === 'global' || source.scope === 'project' ? source.scope : '';
|
|
@@ -1900,9 +2347,30 @@ export function routeScope(draft, options = {}) {
|
|
|
1900
2347
|
const projectReady = Boolean(options.project) && options.project.confirmed !== false;
|
|
1901
2348
|
const cls = typeof source.memoryClass === 'string' ? source.memoryClass : '';
|
|
1902
2349
|
const durability = typeof source.durability === 'string' ? source.durability : 'durable';
|
|
2350
|
+
const evidence = typeof source.evidenceRef === 'string' ? source.evidenceRef.trim() : '';
|
|
2351
|
+
/** 依据本身是本机文件/路径的,不能算通用流程;正文里带本机痕迹同理。 */
|
|
2352
|
+
const machineBound = machineBoundText(source) || (evidence ? machineBoundText({ title: evidence }) : false);
|
|
1903
2353
|
const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
|
|
2354
|
+
/** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
|
|
2355
|
+
const fallbackToProject = (reasonCode) => {
|
|
2356
|
+
if (projectReady) return { ...base, chosenScope: 'project', reasonCode };
|
|
2357
|
+
return {
|
|
2358
|
+
...base,
|
|
2359
|
+
needsScope: true,
|
|
2360
|
+
reasonCode: 'needs_environment',
|
|
2361
|
+
requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
|
|
2362
|
+
};
|
|
2363
|
+
};
|
|
2364
|
+
/** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
|
|
2365
|
+
const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
|
|
1904
2366
|
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
1905
2367
|
if (explicit) {
|
|
2368
|
+
/** 模型自己填的 global 只是提案:本机痕迹、项目事实、无依据的通用流程都按提案降级。 */
|
|
2369
|
+
if (explicit === 'global' && !humanConfirmed) {
|
|
2370
|
+
if (machineBound) return fallbackToProject('machine_bound_scope');
|
|
2371
|
+
if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
|
|
2372
|
+
if (cls === 'portable_procedure' && !evidence) return fallbackToProject('unbound_evidence');
|
|
2373
|
+
}
|
|
1906
2374
|
if (explicit === 'project' && !projectReady) {
|
|
1907
2375
|
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
1908
2376
|
return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
|
|
@@ -1923,8 +2391,17 @@ export function routeScope(draft, options = {}) {
|
|
|
1923
2391
|
}
|
|
1924
2392
|
return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
|
|
1925
2393
|
}
|
|
1926
|
-
if (cls === 'personal_preference')
|
|
1927
|
-
|
|
2394
|
+
if (cls === 'personal_preference') {
|
|
2395
|
+
/** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
|
|
2396
|
+
if (machineBound) return fallbackToProject('machine_bound_preference');
|
|
2397
|
+
return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
|
|
2398
|
+
}
|
|
2399
|
+
if (cls === 'portable_procedure') {
|
|
2400
|
+
if (machineBound) return fallbackToProject('machine_bound_procedure');
|
|
2401
|
+
/** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
|
|
2402
|
+
if (!evidence) return fallbackToProject('unbound_evidence');
|
|
2403
|
+
return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
|
|
2404
|
+
}
|
|
1928
2405
|
const conditions = view.normalizeConditions(source.conditions) || [];
|
|
1929
2406
|
const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
|
|
1930
2407
|
if (!scoped) {
|
|
@@ -1988,6 +2465,9 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
|
|
|
1988
2465
|
id: item.id,
|
|
1989
2466
|
title: item.title,
|
|
1990
2467
|
summary: item.summary || '',
|
|
2468
|
+
when: item.when || '',
|
|
2469
|
+
updated: item.updated || '',
|
|
2470
|
+
reason: item.reason || 'similar_title',
|
|
1991
2471
|
revision: item.revision || 1,
|
|
1992
2472
|
scope: other.key === 'global' ? 'global' : 'project',
|
|
1993
2473
|
}));
|
|
@@ -2004,6 +2484,49 @@ function bindSameTopic(projectScope, ref) {
|
|
|
2004
2484
|
return { topicKey: view.topicSlot(record) };
|
|
2005
2485
|
}
|
|
2006
2486
|
|
|
2487
|
+
|
|
2488
|
+
/** 冲突队列:只记待办,不预写记忆;最多 20 条。 */
|
|
2489
|
+
const CONFLICT_LIMIT = 20;
|
|
2490
|
+
|
|
2491
|
+
function queueConflictLocked(scope, draft, candidates) {
|
|
2492
|
+
const index = loadIndex(scope);
|
|
2493
|
+
const title = normalizeText(draft && draft.title, 120);
|
|
2494
|
+
if (!title) return Array.isArray(index.conflicts) ? index.conflicts.length : 0;
|
|
2495
|
+
const scopeKey = scope.key === 'global' ? 'global' : 'project';
|
|
2496
|
+
const now = new Date().toISOString();
|
|
2497
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2498
|
+
const view = candidates.slice(0, 2).map((item) => ({
|
|
2499
|
+
id: item.id, title: item.title || '', when: item.when || '', reason: item.reason || 'similar_title',
|
|
2500
|
+
}));
|
|
2501
|
+
const hit = rows.find((row) => row.title === title && row.scope === scopeKey);
|
|
2502
|
+
if (hit) {
|
|
2503
|
+
hit.candidates = view;
|
|
2504
|
+
hit.updatedAt = now;
|
|
2505
|
+
} else {
|
|
2506
|
+
rows.push({ id: `cf_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`, title, scope: scopeKey, candidates: view, createdAt: now, updatedAt: now });
|
|
2507
|
+
}
|
|
2508
|
+
index.conflicts = rows.slice(-CONFLICT_LIMIT);
|
|
2509
|
+
saveIndex(scope, index);
|
|
2510
|
+
return index.conflicts.length;
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function clearConflictLocked(scope, title) {
|
|
2514
|
+
const index = loadIndex(scope);
|
|
2515
|
+
const rows = Array.isArray(index.conflicts) ? index.conflicts : [];
|
|
2516
|
+
const norm = normalizeText(title, 120);
|
|
2517
|
+
const next = rows.filter((row) => !(row.title === norm && row.scope === (scope.key === 'global' ? 'global' : 'project')));
|
|
2518
|
+
if (next.length === rows.length) return rows.length;
|
|
2519
|
+
index.conflicts = next;
|
|
2520
|
+
saveIndex(scope, index);
|
|
2521
|
+
return next.length;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
/** 当前层还有几条没处理的冲突。 */
|
|
2525
|
+
export function pendingConflictsOf(scope) {
|
|
2526
|
+
const rows = loadIndex(scope).conflicts;
|
|
2527
|
+
return Array.isArray(rows) ? rows.length : 0;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2007
2530
|
/**
|
|
2008
2531
|
* 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
|
|
2009
2532
|
* 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
|
|
@@ -2047,6 +2570,7 @@ export function saveEntry(projectScope, source) {
|
|
|
2047
2570
|
}
|
|
2048
2571
|
const near = crossLayerSimilar(projectScope, targetScope, draft);
|
|
2049
2572
|
if (near.length) {
|
|
2573
|
+
const pendingConflicts = queueConflictLocked(targetScope, draft, near);
|
|
2050
2574
|
return {
|
|
2051
2575
|
needsResolution: true,
|
|
2052
2576
|
crossLayer: true,
|
|
@@ -2054,12 +2578,19 @@ export function saveEntry(projectScope, source) {
|
|
|
2054
2578
|
similar: near[0],
|
|
2055
2579
|
draft,
|
|
2056
2580
|
needsScope: false,
|
|
2581
|
+
pendingConflicts,
|
|
2057
2582
|
chosenScope: targetScope.key === 'global' ? 'global' : 'project',
|
|
2058
2583
|
};
|
|
2059
2584
|
}
|
|
2060
2585
|
}
|
|
2061
2586
|
const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
|
|
2062
2587
|
const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
|
|
2588
|
+
if (!result.needsResolution) clearConflictLocked(targetScope, draft ? draft.title : next.title);
|
|
2589
|
+
if (result.needsResolution) {
|
|
2590
|
+
const rows = Array.isArray(result.similarList) ? result.similarList : [];
|
|
2591
|
+
const pendingConflicts = queueConflictLocked(targetScope, result.draft || draft, rows);
|
|
2592
|
+
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode, pendingConflicts };
|
|
2593
|
+
}
|
|
2063
2594
|
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
|
|
2064
2595
|
});
|
|
2065
2596
|
}
|
|
@@ -2456,9 +2987,15 @@ function economyOf(ledger) {
|
|
|
2456
2987
|
}
|
|
2457
2988
|
|
|
2458
2989
|
/** 给整理器的候选规则:只读、有限,不跨项目。 */
|
|
2990
|
+
/** 整理器看到的规则顺序与扣尝试时一致:按状态/最近使用排序,不靠插入顺序。 */
|
|
2991
|
+
function orderedLearningRules(index) {
|
|
2992
|
+
const visible = learningRulesOf(index).filter(visibleLearningRule);
|
|
2993
|
+
return learning.orderOrganizerRules(visible, { limit: learning.LIMITS.organizerRules });
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2459
2996
|
export function learningRulesForOrganizer(scope) {
|
|
2460
2997
|
const index = loadIndex(scope);
|
|
2461
|
-
return
|
|
2998
|
+
return orderedLearningRules(index).map(learningRuleView);
|
|
2462
2999
|
}
|
|
2463
3000
|
|
|
2464
3001
|
/**
|
|
@@ -2467,16 +3004,17 @@ export function learningRulesForOrganizer(scope) {
|
|
|
2467
3004
|
*/
|
|
2468
3005
|
export function beginLearningAttempt(scope, options = {}) {
|
|
2469
3006
|
const episodeId = String(options.episodeId || '');
|
|
2470
|
-
|
|
3007
|
+
/** 项目层候选没有作品绑定:按项目身份记一次尝试,限额照样生效,不编造作品 id。 */
|
|
3008
|
+
const attemptKey = episodeId || `project:${scope.key}`;
|
|
2471
3009
|
const now = options.now || Date.now();
|
|
2472
3010
|
return withLock(scope, () => {
|
|
2473
3011
|
const index = loadIndex(scope);
|
|
2474
3012
|
const evolution = index.__evolution;
|
|
2475
3013
|
if (!evolution.enabled || evolution.compatible === false) return { ok: false, reason: '实验没有开启' };
|
|
2476
3014
|
if (evolution.policyVersion !== learning.POLICY_VERSION) return { ok: false, reason: '实验策略版本不认' };
|
|
2477
|
-
const check = learning.quota(evolution.learning, { episodeId, now });
|
|
3015
|
+
const check = learning.quota(evolution.learning, { episodeId: attemptKey, now });
|
|
2478
3016
|
if (!check.ok) return { ...check, learning: evolution.learning };
|
|
2479
|
-
const next = learning.recordAttempt(evolution.learning, { episodeId, now });
|
|
3017
|
+
const next = learning.recordAttempt(evolution.learning, { episodeId: attemptKey, now });
|
|
2480
3018
|
try {
|
|
2481
3019
|
const result = state.transact(scope, {
|
|
2482
3020
|
operationId: options.operationId || '',
|
|
@@ -2493,7 +3031,7 @@ export function beginLearningAttempt(scope, options = {}) {
|
|
|
2493
3031
|
generation: index.generation,
|
|
2494
3032
|
episodeId,
|
|
2495
3033
|
learning: next,
|
|
2496
|
-
rules:
|
|
3034
|
+
rules: orderedLearningRules(index).map(learningRuleView),
|
|
2497
3035
|
replayed: result.replayed === true,
|
|
2498
3036
|
};
|
|
2499
3037
|
} catch (error) {
|
|
@@ -2527,7 +3065,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2527
3065
|
const operationId = String(batch.operationId || '');
|
|
2528
3066
|
const now = batch.now || Date.now();
|
|
2529
3067
|
const at = new Date(now).toISOString();
|
|
2530
|
-
|
|
3068
|
+
/**
|
|
3069
|
+
* 没绑作品的候选(learning.candidateFrom 在项目层给的空 episodeId)按项目身份提交:
|
|
3070
|
+
* 不编造作品 id,但必须由本项目发起;规则版本、epoch 与目标版本照旧要对得上。
|
|
3071
|
+
*/
|
|
3072
|
+
if (!episodeId && String(batch.projectId || '') !== scope.key) return { ok: false, reason: '缺少作品标识' };
|
|
2531
3073
|
if (!operationId) return { ok: false, reason: '缺少提交标识' };
|
|
2532
3074
|
if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
2533
3075
|
return withLock(scope, () => {
|
|
@@ -2542,13 +3084,13 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2542
3084
|
if (!evolution || evolution.enabled !== true) throw new Error('实验没有开启,这次整理作废');
|
|
2543
3085
|
if (evolution.policyVersion !== learning.POLICY_VERSION) throw new Error('实验策略版本不认');
|
|
2544
3086
|
if (batch.epoch && evolution.epoch !== batch.epoch) throw new Error('实验已经重新开启过,这次整理作废');
|
|
2545
|
-
const episode = episodeOfEvolution(evolution, episodeId);
|
|
2546
|
-
if (!episode) throw new Error('作品已经失效,这次整理作废');
|
|
2547
|
-
if (batch.sessionId) {
|
|
3087
|
+
const episode = episodeId ? episodeOfEvolution(evolution, episodeId) : null;
|
|
3088
|
+
if (episodeId && !episode) throw new Error('作品已经失效,这次整理作废');
|
|
3089
|
+
if (episodeId && batch.sessionId) {
|
|
2548
3090
|
const focus = evolution.tasks?.sessions?.[batch.sessionId];
|
|
2549
3091
|
if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这轮结果没采用');
|
|
2550
3092
|
}
|
|
2551
|
-
if (batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
|
|
3093
|
+
if (episode && batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
|
|
2552
3094
|
const prepared = new Map((Array.isArray(batch.preparedTargets) ? batch.preparedTargets : []).map((row) => [row.id, row]));
|
|
2553
3095
|
let touched = 0;
|
|
2554
3096
|
const currentRules = draft.entries.filter((entry) => entry.status === 'active' && isExperimentalRecord(toFlat(entry)));
|
|
@@ -2591,6 +3133,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2591
3133
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2592
3134
|
at,
|
|
2593
3135
|
};
|
|
3136
|
+
/** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
|
|
3137
|
+
const excerpt = typeof change.sourceExcerpt === 'string'
|
|
3138
|
+
? change.sourceExcerpt
|
|
3139
|
+
: (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
|
|
3140
|
+
if (excerpt) evidence.sourceExcerpt = excerpt;
|
|
2594
3141
|
merged.learning = {
|
|
2595
3142
|
...learning.nextLearningState({
|
|
2596
3143
|
existing: flat,
|
|
@@ -2624,6 +3171,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2624
3171
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2625
3172
|
at,
|
|
2626
3173
|
};
|
|
3174
|
+
/** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
|
|
3175
|
+
const excerpt = typeof change.sourceExcerpt === 'string'
|
|
3176
|
+
? change.sourceExcerpt
|
|
3177
|
+
: (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
|
|
3178
|
+
if (excerpt) evidence.sourceExcerpt = excerpt;
|
|
2627
3179
|
const merged = learning.mergeChange(null, change, { now });
|
|
2628
3180
|
/** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
|
|
2629
3181
|
const signature = (list) => (Array.isArray(list) ? list : [])
|