dsh-lite-memory 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/TRIGGERS.md +14 -13
- package/anchor.js +259 -24
- package/client.js +1211 -206
- package/docs/CONVENTIONS.md +143 -0
- package/docs/RELEASE-0.17.0.md +8 -0
- package/index.js +337 -95
- package/learning.js +456 -48
- package/package.json +5 -2
- package/state.js +110 -5
- package/store.js +447 -61
- package/test/anchor.test.mjs +240 -0
- package/test/client.test.mjs +176 -6
- package/test/contract.test.mjs +9 -1
- package/test/learning.test.mjs +484 -2
- package/test/palace.test.mjs +102 -0
- package/test/runtime-context.test.mjs +186 -0
- package/test/state.test.mjs +121 -2
- package/test/store.test.mjs +454 -15
- package/view.js +60 -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',
|
|
@@ -110,7 +122,7 @@ function toFlat(entry) {
|
|
|
110
122
|
|
|
111
123
|
function fromFlat(record, status) {
|
|
112
124
|
const at = record.updated || record.created || new Date().toISOString();
|
|
113
|
-
|
|
125
|
+
const entry = {
|
|
114
126
|
id: record.id,
|
|
115
127
|
revision: Number(record.revision) || 1,
|
|
116
128
|
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
@@ -148,6 +160,13 @@ function fromFlat(record, status) {
|
|
|
148
160
|
lastUsed: record.lastUsed || at,
|
|
149
161
|
},
|
|
150
162
|
};
|
|
163
|
+
/** 完整摘要是维护区字段:显式给了就覆盖,给了空串就清掉,不给则沿用旧值。 */
|
|
164
|
+
if (Object.prototype.hasOwnProperty.call(record, 'summaryFull')) {
|
|
165
|
+
const full = typeof record.summaryFull === 'string' ? record.summaryFull : '';
|
|
166
|
+
if (full) entry.maintenance.summaryFull = full;
|
|
167
|
+
else delete entry.maintenance.summaryFull;
|
|
168
|
+
}
|
|
169
|
+
return entry;
|
|
151
170
|
}
|
|
152
171
|
|
|
153
172
|
/**
|
|
@@ -161,7 +180,16 @@ export function loadIndex(scope, options = {}) {
|
|
|
161
180
|
}
|
|
162
181
|
const loaded = state.load(scope, options);
|
|
163
182
|
const entries = loaded.state.entries.filter((entry) => entry.status === 'active').map(toFlat);
|
|
164
|
-
|
|
183
|
+
/**
|
|
184
|
+
* 归档条目只加载元数据,正文留在冷存里按需读:列表、图谱、宫殿、snapshot 都
|
|
185
|
+
* 不该为了显示标题去读归档正文,更不能因为某个旧归档缺冷存就把整层读失败。
|
|
186
|
+
*/
|
|
187
|
+
const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map((entry) => {
|
|
188
|
+
const record = toFlat(entry);
|
|
189
|
+
record.cold = Boolean(entry.maintenance && entry.maintenance[COLD_MARK]);
|
|
190
|
+
record.coldBody = '';
|
|
191
|
+
return record;
|
|
192
|
+
});
|
|
165
193
|
cacheBodies(scope, loaded.state.entries);
|
|
166
194
|
const index = {
|
|
167
195
|
version: 2,
|
|
@@ -179,6 +207,7 @@ export function loadIndex(scope, options = {}) {
|
|
|
179
207
|
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
180
208
|
__control: state.controlOf(loaded.state),
|
|
181
209
|
__exists: loaded.exists === true,
|
|
210
|
+
__recovery: loaded.recovery || null,
|
|
182
211
|
};
|
|
183
212
|
index.__meta = {
|
|
184
213
|
exists: loaded.exists,
|
|
@@ -190,10 +219,11 @@ export function loadIndex(scope, options = {}) {
|
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
/** 扁平索引 → v2 状态:整层重写,条目、候选、回执一次换完。 */
|
|
193
|
-
function applyFlat(draft, index) {
|
|
222
|
+
function applyFlat(scope, draft, index) {
|
|
194
223
|
const next = [];
|
|
195
224
|
for (const record of index.entries) next.push(fromFlat(record));
|
|
196
|
-
|
|
225
|
+
/** 归档正文不写回热状态:先落冷存再摘空,冷存写失败就整次保存失败,不丢正文。 */
|
|
226
|
+
for (const record of index.archived || []) next.push(fromFlat(coldStored(scope, record), 'archived'));
|
|
197
227
|
draft.entries = next;
|
|
198
228
|
draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
|
|
199
229
|
draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
|
|
@@ -211,7 +241,7 @@ export function saveIndex(scope, index) {
|
|
|
211
241
|
const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
|
|
212
242
|
const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
|
|
213
243
|
const before = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
|
|
214
|
-
applyFlat(draft, index);
|
|
244
|
+
applyFlat(scope, draft, index);
|
|
215
245
|
const after = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
|
|
216
246
|
return { changed: before !== after };
|
|
217
247
|
});
|
|
@@ -311,17 +341,18 @@ export function normalizeDraft(source, options = {}) {
|
|
|
311
341
|
const mayOmitSummary = options.requireSummary === false || Boolean(id);
|
|
312
342
|
if (!summary && !mayOmitSummary) throw new Error('摘要为空');
|
|
313
343
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
344
|
+
* 正文只属于调用方显式给出的内容:摘要超长时不再往正文里塞托管尾巴,
|
|
345
|
+
* 完整原句单独进维护区(maintenance.summaryFull),正文保持原样。
|
|
316
346
|
*/
|
|
317
347
|
const bodyExplicit = has('body');
|
|
318
348
|
const body = bodyExplicit ? String(draft.body ?? '').trim() : '';
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
349
|
+
const summaryFull = summaryShortened ? rawSummary : '';
|
|
350
|
+
const bodyBytes = Buffer.byteLength(body);
|
|
351
|
+
/** 预算按真正会存进去的正文 + 完整摘要算:超限就报错,不截断后谎报成功。 */
|
|
352
|
+
const storedBytes = bodyBytes + Buffer.byteLength(summaryFull);
|
|
353
|
+
if (storedBytes > LIMITS.entryBytes) {
|
|
354
|
+
throw new Error(summaryFull
|
|
355
|
+
? `正文 ${bodyBytes} 字节加完整摘要 ${Buffer.byteLength(summaryFull)} 字节,超过单条 ${LIMITS.entryBytes} 字节;先拆成两条写,别丢后半段`
|
|
325
356
|
: `正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
326
357
|
}
|
|
327
358
|
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
@@ -337,7 +368,8 @@ export function normalizeDraft(source, options = {}) {
|
|
|
337
368
|
const links = view.normalizeLinks(draft.links, id);
|
|
338
369
|
const applicability = view.normalizeApplicability(draft.applicability);
|
|
339
370
|
const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
|
|
340
|
-
|
|
371
|
+
/** 凭据扫描必须覆盖完整原文:截短后的摘要看不出凭据,原文里有也不行。 */
|
|
372
|
+
const secret = detectSecret([title, summary, rawSummary, when, body, tags.join(' '), topicKey || ''].join('\n'));
|
|
341
373
|
if (secret) throw new Error(`疑似凭据(${secret}),只记位置,不记值`);
|
|
342
374
|
return {
|
|
343
375
|
id,
|
|
@@ -347,7 +379,10 @@ export function normalizeDraft(source, options = {}) {
|
|
|
347
379
|
body,
|
|
348
380
|
summaryShortened,
|
|
349
381
|
summaryRaw: rawSummary,
|
|
350
|
-
|
|
382
|
+
summaryFull,
|
|
383
|
+
/** 回执字段:完整原句已另存维护区(不是塞进正文)。 */
|
|
384
|
+
fullTextSaved: Boolean(summaryFull),
|
|
385
|
+
/** 兼容旧回执:正文里从来没有托管尾巴,这个字段恒为 false。 */
|
|
351
386
|
fullTextInBody: false,
|
|
352
387
|
kind: kind || 'note',
|
|
353
388
|
tags,
|
|
@@ -381,9 +416,27 @@ function newId() {
|
|
|
381
416
|
/** 正文就在状态里;缓存只是省一次读盘,得手时一律回状态读。 */
|
|
382
417
|
function readBody(scope, id) {
|
|
383
418
|
const cached = cachedBody(scope, id);
|
|
384
|
-
if (typeof cached === 'string') return cached;
|
|
419
|
+
if (typeof cached === 'string' && cached !== '') return cached;
|
|
385
420
|
const found = state.readEntryBody(scope, id);
|
|
386
421
|
if (!found) throw new Error(`记忆正文缺失:${id} 在索引里,但正文文件不在了`);
|
|
422
|
+
/**
|
|
423
|
+
* 归档正文不在热状态里:只有真要读这一条正文时才回冷存取,
|
|
424
|
+
* 缺文件/坏文件都报明确错误,不拿空正文冒充「这条没有正文」。
|
|
425
|
+
*/
|
|
426
|
+
if (!found.body && found.entry && found.entry.status === 'archived') {
|
|
427
|
+
let payload = null;
|
|
428
|
+
try {
|
|
429
|
+
payload = readCold(scope, id, { required: true });
|
|
430
|
+
} catch (error) {
|
|
431
|
+
/** 旧版归档在正文为空时可能没写过冷存:这类老记录按空正文读回,不改写也不再报错。 */
|
|
432
|
+
const legacy = !(found.entry.maintenance && found.entry.maintenance.coldSaved === true);
|
|
433
|
+
if (!(legacy && error && error.code === 'cold_missing')) throw error;
|
|
434
|
+
}
|
|
435
|
+
if (payload && typeof payload.body === 'string' && payload.body) {
|
|
436
|
+
writeBody(scope, id, payload.body);
|
|
437
|
+
return payload.body;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
387
440
|
return found.body;
|
|
388
441
|
}
|
|
389
442
|
|
|
@@ -398,6 +451,192 @@ function removeBody(scope, id) {
|
|
|
398
451
|
if (map) map.delete(id);
|
|
399
452
|
}
|
|
400
453
|
|
|
454
|
+
/**
|
|
455
|
+
* 归档正文冷存:热状态只留元数据,正文和旧版本正文放 scope.dir/cold/<id>.json。
|
|
456
|
+
* 五百条归档 ≈ 2 MiB 时,只有真看过的那几条会回到内存,保存不再被整层预算卡死。
|
|
457
|
+
*/
|
|
458
|
+
const COLD_DIR = 'cold';
|
|
459
|
+
const COLD_MARK = 'coldBody';
|
|
460
|
+
|
|
461
|
+
function coldDirOf(scope) {
|
|
462
|
+
return path.join(scope.dir, COLD_DIR);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function coldFileOf(scope, id) {
|
|
466
|
+
return path.join(coldDirOf(scope), `${id}.json`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function coldPayload(scope, record) {
|
|
470
|
+
return {
|
|
471
|
+
/** 冷存文件自带身份:读回来先核对是不是这一条、这一层,不靠文件名猜。 */
|
|
472
|
+
id: record.id,
|
|
473
|
+
scope: scope.key,
|
|
474
|
+
revision: Number(record.revision) || 1,
|
|
475
|
+
at: new Date().toISOString(),
|
|
476
|
+
body: typeof record.body === 'string' ? record.body : '',
|
|
477
|
+
versions: (Array.isArray(record.versions) ? record.versions : [])
|
|
478
|
+
.map((version) => ({ at: String((version && version.at) || ''), body: typeof (version && version.body) === 'string' ? version.body : '' }))
|
|
479
|
+
.filter((version) => version.body),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* 冷存写失败就报错:宁可整次保存失败,也不能把正文悄悄丢掉。
|
|
485
|
+
* 落盘序列与 state.json 一致(fsync 文件 → rename → fsync 目录):归档正文只在这里,
|
|
486
|
+
* 少一步 fsync,崩溃后就是「热状态说有归档、冷存文件是空的」。
|
|
487
|
+
*/
|
|
488
|
+
function writeCold(scope, record) {
|
|
489
|
+
const file = coldFileOf(scope, record.id);
|
|
490
|
+
try {
|
|
491
|
+
state.writeDurable(file, `${JSON.stringify(coldPayload(scope, record))}\n`);
|
|
492
|
+
} catch (error) {
|
|
493
|
+
throw new state.StateError('unavailable', `归档正文没写进冷存(${path.basename(file)}):${(error && error.message) || 'IO 错误'}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* 读冷存:文件缺失时按 required 决定返回 null 还是报错;JSON 损坏、身份不符
|
|
499
|
+
* 一律抛明确错误,绝不静默返回空正文冒充「这条没有正文」。
|
|
500
|
+
*/
|
|
501
|
+
function readCold(scope, id, options = {}) {
|
|
502
|
+
const required = options.required === true;
|
|
503
|
+
const file = coldFileOf(scope, id);
|
|
504
|
+
let raw;
|
|
505
|
+
try {
|
|
506
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error && error.code === 'ENOENT') {
|
|
509
|
+
if (!required) return null;
|
|
510
|
+
throw new state.StateError('cold_missing', `冷存里没有这条的正文备份(${file});先修复冷存再读,不拿空正文顶上`);
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
let payload;
|
|
515
|
+
try {
|
|
516
|
+
payload = JSON.parse(raw);
|
|
517
|
+
} catch {
|
|
518
|
+
throw new state.StateError('cold_corrupt', `冷存文件不是合法 JSON(${file});先修复再读,不拿空正文顶上`);
|
|
519
|
+
}
|
|
520
|
+
if (!payload || typeof payload !== 'object' || payload.id !== id || payload.scope !== scope.key || typeof payload.body !== 'string') {
|
|
521
|
+
throw new state.StateError('cold_corrupt', `冷存文件记录的条目不匹配这一条或这一层(${file});先修复再读,不拿空正文顶上`);
|
|
522
|
+
}
|
|
523
|
+
return payload;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** 写回状态前把归档正文摘出去:只有正文/旧版本真变了才重写冷存文件。 */
|
|
527
|
+
function coldStored(scope, record) {
|
|
528
|
+
if (record.status !== 'archived') return record;
|
|
529
|
+
const versions = Array.isArray(record.versions) ? record.versions : [];
|
|
530
|
+
const hasVersionBodies = versions.some((version) => typeof (version && version.body) === 'string' && version.body);
|
|
531
|
+
/** 内存记录的正文可能已经是空串(占位/摘过):先按 coldBody、冷存文件、热状态顺序找回。 */
|
|
532
|
+
let body = typeof record.body === 'string' ? record.body : '';
|
|
533
|
+
const known = typeof record.coldBody === 'string' ? record.coldBody : null;
|
|
534
|
+
if (body || known !== null) {
|
|
535
|
+
body = body || known;
|
|
536
|
+
} else {
|
|
537
|
+
/** 冷存是唯一权威副本;它坏了先退回热状态里的正文(保存时会重写成好文件),两处都没有才报错。 */
|
|
538
|
+
let coldError = null;
|
|
539
|
+
try {
|
|
540
|
+
const payload = readCold(scope, record.id);
|
|
541
|
+
if (payload && typeof payload.body === 'string' && payload.body) body = payload.body;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
coldError = error;
|
|
544
|
+
}
|
|
545
|
+
if (!body) {
|
|
546
|
+
const found = state.readEntryBody(scope, record.id);
|
|
547
|
+
if (found && typeof found.body === 'string') body = found.body;
|
|
548
|
+
}
|
|
549
|
+
if (coldError && !body) throw coldError;
|
|
550
|
+
}
|
|
551
|
+
/** 正文和冷存里的一致就不重写:五百条归档每次保存只动真变过的那几条。 */
|
|
552
|
+
const cached = record.cold === true && known !== null && known === body;
|
|
553
|
+
/** 空正文也要落一份冷存:只要标了 coldBody,就必须有自证身份的文件可读回。 */
|
|
554
|
+
if (!cached) writeCold(scope, { ...record, body });
|
|
555
|
+
return {
|
|
556
|
+
...record,
|
|
557
|
+
body: '',
|
|
558
|
+
cold: true,
|
|
559
|
+
coldBody: body,
|
|
560
|
+
versions: versions.map((version) => ({ ...(version || {}), body: '' })),
|
|
561
|
+
maintenance: { ...(record.maintenance || {}), [COLD_MARK]: true, coldSaved: true },
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 彻底删除是一个事务:先把要删的冷存文件按 id 精确移到层外的隔离区
|
|
567
|
+
* (state.purge 只扫 scope.dir,所以隔离区必须在层外),state.purge 成功后才丢弃隔离副本;
|
|
568
|
+
* 失败按错误类型恢复或留待重放收尾。绝不按标题/正文片段扫描别人的冷存文件。
|
|
569
|
+
*/
|
|
570
|
+
const PURGE_TRASH_DIR = '.purge-trash';
|
|
571
|
+
|
|
572
|
+
function trashDirOf(scope) {
|
|
573
|
+
const safe = String(scope.key || '').replace(/[^A-Za-z0-9._-]/g, '_') || '_';
|
|
574
|
+
return path.join(memoryRoot(), PURGE_TRASH_DIR, safe);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 只搬走 ids 对应的 <id>.json;别的条目一个字节都不动。 */
|
|
578
|
+
function isolateCold(scope, ids, operationId) {
|
|
579
|
+
const token = String(operationId || '').replace(/[^A-Za-z0-9._-]/g, '_') || crypto.randomBytes(8).toString('hex');
|
|
580
|
+
const dir = path.join(trashDirOf(scope), token);
|
|
581
|
+
const moved = [];
|
|
582
|
+
for (const id of ids || []) {
|
|
583
|
+
const from = coldFileOf(scope, id);
|
|
584
|
+
if (!fs.existsSync(from)) continue;
|
|
585
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
586
|
+
const to = path.join(dir, `${id}.json`);
|
|
587
|
+
fs.renameSync(from, to);
|
|
588
|
+
moved.push({ from, to });
|
|
589
|
+
}
|
|
590
|
+
return { dir, moved };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function restoreCold(isolated) {
|
|
594
|
+
let restored = true;
|
|
595
|
+
for (const row of isolated.moved) {
|
|
596
|
+
try {
|
|
597
|
+
fs.renameSync(row.to, row.from);
|
|
598
|
+
} catch {
|
|
599
|
+
/** 恢复失败就保留整个隔离区:副本还在,重放/人工都能取回,不能顺手删掉。 */
|
|
600
|
+
restored = false;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (restored) dropColdQuarantine(isolated);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function dropColdQuarantine(isolated) {
|
|
607
|
+
try {
|
|
608
|
+
fs.rmSync(isolated.dir, { recursive: true, force: true });
|
|
609
|
+
} catch {
|
|
610
|
+
/** 删不掉隔离目录不影响删除结果,下次收尾再清。 */
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 删除确认后的收尾:清掉这一层隔离区里残留的副本(上一次做到一半留下的)。 */
|
|
615
|
+
function cleanColdQuarantine(scope) {
|
|
616
|
+
try {
|
|
617
|
+
fs.rmSync(trashDirOf(scope), { recursive: true, force: true });
|
|
618
|
+
} catch {
|
|
619
|
+
/** 收尾清不掉不影响数据正确性。 */
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* 删除与冷存的一致顺序:隔离 → 发布删除 → 丢弃隔离副本。
|
|
625
|
+
* 发布失败(例如 purge_pending)时保留隔离副本,留给重放路径收尾。
|
|
626
|
+
*/
|
|
627
|
+
function purgeWithCold(scope, ids, run) {
|
|
628
|
+
const isolated = isolateCold(scope, ids);
|
|
629
|
+
try {
|
|
630
|
+
const result = run();
|
|
631
|
+
dropColdQuarantine(isolated);
|
|
632
|
+
return result;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (error && error.code === 'purge_pending') throw error;
|
|
635
|
+
restoreCold(isolated);
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
401
640
|
function entrySize(record, body) {
|
|
402
641
|
const meta = [record.title || '', record.summary || '', record.when || '', (record.tags || []).join(',')].join('\n');
|
|
403
642
|
return Buffer.byteLength(meta) + Buffer.byteLength(body || '') + Buffer.byteLength(state.fieldsBytesOf(record));
|
|
@@ -607,6 +846,7 @@ function snapshot(record, body) {
|
|
|
607
846
|
at: record.updated || record.created || new Date().toISOString(),
|
|
608
847
|
title: record.title,
|
|
609
848
|
summary: record.summary,
|
|
849
|
+
summaryFull: typeof record.summaryFull === 'string' ? record.summaryFull : '',
|
|
610
850
|
when: record.when || '',
|
|
611
851
|
kind: record.kind || 'note',
|
|
612
852
|
tags: [...(record.tags || [])],
|
|
@@ -651,12 +891,12 @@ function rulesOf(draft, body, index, foreign) {
|
|
|
651
891
|
return rules;
|
|
652
892
|
}
|
|
653
893
|
|
|
654
|
-
/**
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
return
|
|
894
|
+
/**
|
|
895
|
+
* 旧正文里可能留着「摘要原文:」尾巴:只识别、做标记,绝不改写。
|
|
896
|
+
* 猜不出来哪一段是托管内容,删错就是把用户原话删了。
|
|
897
|
+
*/
|
|
898
|
+
function hasLegacySummaryTail(text) {
|
|
899
|
+
return typeof text === 'string' && text.includes(LEGACY_SUMMARY_TAIL);
|
|
660
900
|
}
|
|
661
901
|
|
|
662
902
|
function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
|
|
@@ -672,9 +912,8 @@ function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreig
|
|
|
672
912
|
selection: draft.has.selection ? draft.selection : null,
|
|
673
913
|
links: draft.has.links ? draft.links : [],
|
|
674
914
|
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
675
|
-
body:
|
|
915
|
+
body: String(draft.body || ''),
|
|
676
916
|
};
|
|
677
|
-
if (next.body !== draft.body) draft.fullTextInBody = true;
|
|
678
917
|
next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
679
918
|
next.conditions, next.selection, next.when);
|
|
680
919
|
assertLinksVisible(next.links, index, foreign);
|
|
@@ -752,9 +991,13 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
752
991
|
if (record) {
|
|
753
992
|
const oldBody = readBody(scope, record.id);
|
|
754
993
|
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
994
|
+
/** 正文原样保存:摘要再长也不往正文里补尾巴;完整原句进维护区。 */
|
|
995
|
+
draft.fullTextInBody = false;
|
|
996
|
+
/** 摘要这次被改过就按本次结果落 summaryFull:改短即清空,没改则保持原值。 */
|
|
997
|
+
const summaryFull = draft.has.summary
|
|
998
|
+
? (draft.summaryShortened ? String(draft.summaryRaw || '') : '')
|
|
999
|
+
: (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
1000
|
+
draft.fullTextSaved = Boolean(summaryFull);
|
|
758
1001
|
const noop = next.title === record.title && next.summary === record.summary
|
|
759
1002
|
&& next.when === (record.when || '') && next.kind === (record.kind || 'note')
|
|
760
1003
|
&& JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
|
|
@@ -764,7 +1007,8 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
764
1007
|
&& JSON.stringify(next.links) === JSON.stringify(record.links || [])
|
|
765
1008
|
&& (next.topicKey || '') === (record.topicKey || '')
|
|
766
1009
|
&& next.applicability === (record.applicability || 'all_in_scope')
|
|
767
|
-
&& next.body === oldBody
|
|
1010
|
+
&& next.body === oldBody
|
|
1011
|
+
&& summaryFull === (typeof record.summaryFull === 'string' ? record.summaryFull : '');
|
|
768
1012
|
if (noop) {
|
|
769
1013
|
return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
|
|
770
1014
|
}
|
|
@@ -773,6 +1017,7 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
773
1017
|
const nextRecord = {
|
|
774
1018
|
...record,
|
|
775
1019
|
...next,
|
|
1020
|
+
summaryFull,
|
|
776
1021
|
aliases: titles,
|
|
777
1022
|
versions,
|
|
778
1023
|
revision: (record.revision || 1) + 1,
|
|
@@ -782,6 +1027,11 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
782
1027
|
status: 'active',
|
|
783
1028
|
source: record.source || (source && source.source) || 'user_explicit',
|
|
784
1029
|
};
|
|
1030
|
+
nextRecord.maintenance = { ...(record.maintenance || {}) };
|
|
1031
|
+
if (summaryFull) nextRecord.maintenance.summaryFull = summaryFull;
|
|
1032
|
+
else delete nextRecord.maintenance.summaryFull;
|
|
1033
|
+
/** 旧正文里的托管尾巴只标记待整理,绝不在这里改写用户正文。 */
|
|
1034
|
+
if (hasLegacySummaryTail(next.body)) nextRecord.maintenance.legacySummaryTail = true;
|
|
785
1035
|
nextRecord.bytes = entrySize(nextRecord, next.body);
|
|
786
1036
|
const archived = assertFits(scope, index, nextRecord);
|
|
787
1037
|
writeBody(scope, record.id, next.body);
|
|
@@ -789,15 +1039,16 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
789
1039
|
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
|
|
790
1040
|
}
|
|
791
1041
|
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
792
|
-
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
1042
|
+
/** 新建也只用调用方给的正文:超长摘要的完整原句进维护区,不混进正文。 */
|
|
1043
|
+
const createdBody = draft.body;
|
|
1044
|
+
draft.fullTextInBody = false;
|
|
1045
|
+
draft.fullTextSaved = Boolean(draft.summaryFull);
|
|
796
1046
|
const rules = rulesOf(draft, createdBody, index, foreign);
|
|
797
1047
|
const created = {
|
|
798
1048
|
id: newId(),
|
|
799
1049
|
title: draft.title,
|
|
800
1050
|
summary: draft.summary,
|
|
1051
|
+
summaryFull: draft.summaryFull,
|
|
801
1052
|
when: draft.when,
|
|
802
1053
|
kind: draft.kind,
|
|
803
1054
|
tags: draft.tags,
|
|
@@ -818,8 +1069,12 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
818
1069
|
hits: 0,
|
|
819
1070
|
merges: 0,
|
|
820
1071
|
versions: [],
|
|
1072
|
+
maintenance: {},
|
|
821
1073
|
bytes: 0,
|
|
822
1074
|
};
|
|
1075
|
+
if (draft.summaryFull) created.maintenance.summaryFull = draft.summaryFull;
|
|
1076
|
+
/** 旧正文里的托管尾巴只标记待整理,绝不改写用户正文。 */
|
|
1077
|
+
if (hasLegacySummaryTail(createdBody)) created.maintenance.legacySummaryTail = true;
|
|
823
1078
|
created.bytes = entrySize(created, created.body);
|
|
824
1079
|
const archived = assertFits(scope, index, created);
|
|
825
1080
|
writeBody(scope, created.id, created.body);
|
|
@@ -1055,8 +1310,41 @@ export function setTaskDefault(scope, defaults, options = {}) {
|
|
|
1055
1310
|
* 返回的记录带着「本次检索之前」的 lastUsed,给 renderIndex 判断陈旧用;
|
|
1056
1311
|
* 命中计数在返回前写入,所以同一次调用既改盘又不污染展示。
|
|
1057
1312
|
*/
|
|
1313
|
+
/** 「用过」的判定门槛:lastUsed 超过这么久才回写,检索不至于每次都抢锁落盘。 */
|
|
1314
|
+
const USED_TOUCH_MS = 10 * 60 * 1000;
|
|
1315
|
+
|
|
1316
|
+
function usedStale(record, now) {
|
|
1317
|
+
const at = Date.parse(String((record && (record.lastUsed || record.updated)) || ''));
|
|
1318
|
+
return !Number.isFinite(at) || now - at >= USED_TOUCH_MS;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* 真正被搜到/读过才算用过:只回写 lastUsed,不动 hits,也不改正文。
|
|
1323
|
+
* 抢不到锁或并发冲突都吞掉——这个是提示字段,不该让检索/阅读失败。
|
|
1324
|
+
*/
|
|
1325
|
+
function touchUsed(scope, ids) {
|
|
1326
|
+
const wanted = new Set((ids || []).filter((id) => typeof id === 'string' && id));
|
|
1327
|
+
if (wanted.size === 0) return;
|
|
1328
|
+
try {
|
|
1329
|
+
withLock(scope, () => {
|
|
1330
|
+
const index = loadIndex(scope);
|
|
1331
|
+
const now = Date.now();
|
|
1332
|
+
const stamp = new Date(now).toISOString();
|
|
1333
|
+
let changed = false;
|
|
1334
|
+
for (const record of index.entries) {
|
|
1335
|
+
if (!wanted.has(record.id) || !usedStale(record, now)) continue;
|
|
1336
|
+
record.lastUsed = stamp;
|
|
1337
|
+
changed = true;
|
|
1338
|
+
}
|
|
1339
|
+
if (changed) saveIndex(scope, index);
|
|
1340
|
+
});
|
|
1341
|
+
} catch {
|
|
1342
|
+
/* lastUsed 只是提示:失败不影响这次检索/阅读的结果。 */
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1058
1346
|
export function search(scope, query, limit = 4, options = {}) {
|
|
1059
|
-
|
|
1347
|
+
const found = withLocks([scope, globalScope()], () => {
|
|
1060
1348
|
const grams = bigrams(query);
|
|
1061
1349
|
if (grams.size === 0) return [];
|
|
1062
1350
|
const runs = queryRuns(query);
|
|
@@ -1098,14 +1386,23 @@ export function search(scope, query, limit = 4, options = {}) {
|
|
|
1098
1386
|
|| Number(a.archived) - Number(b.archived)
|
|
1099
1387
|
|| String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
|
|
1100
1388
|
const chosen = visible.slice(0, limit);
|
|
1101
|
-
/**
|
|
1389
|
+
/** hits 不在检索里记,lastUsed 也只在锁外补写;这里先把层信息和结果一起带出去。 */
|
|
1102
1390
|
return chosen.map((row) => ({
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1391
|
+
record: {
|
|
1392
|
+
...row.item,
|
|
1393
|
+
scope: row.source.key === 'global' ? 'global' : 'project',
|
|
1394
|
+
status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
|
|
1395
|
+
archived: Boolean(row.archived),
|
|
1396
|
+
},
|
|
1397
|
+
source: row.source,
|
|
1106
1398
|
archived: Boolean(row.archived),
|
|
1107
1399
|
}));
|
|
1108
1400
|
});
|
|
1401
|
+
/** 锁外回写:在层锁里再抢同一层的锁会死锁。 */
|
|
1402
|
+
for (const row of found) {
|
|
1403
|
+
if (!row.archived) touchUsed(row.source, [row.record.id]);
|
|
1404
|
+
}
|
|
1405
|
+
return found.map((row) => row.record);
|
|
1109
1406
|
}
|
|
1110
1407
|
|
|
1111
1408
|
/**
|
|
@@ -1146,7 +1443,7 @@ export function peek(scope, query, limit = 3, options = {}) {
|
|
|
1146
1443
|
}
|
|
1147
1444
|
|
|
1148
1445
|
export function readEntry(scope, id, options = {}) {
|
|
1149
|
-
|
|
1446
|
+
const found = withLocks([scope, globalScope()], () => {
|
|
1150
1447
|
for (const source of [scope, globalScope()]) {
|
|
1151
1448
|
const index = loadIndex(source);
|
|
1152
1449
|
const record = index.entries.find((item) => item.id === id);
|
|
@@ -1155,14 +1452,22 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1155
1452
|
if (!readableRecord(record || archived, options, index)) continue;
|
|
1156
1453
|
const body = readBody(source, id);
|
|
1157
1454
|
return {
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1455
|
+
source,
|
|
1456
|
+
archived: Boolean(archived),
|
|
1457
|
+
record: {
|
|
1458
|
+
...(record || archived),
|
|
1459
|
+
scope: source.key === 'global' ? 'global' : 'project',
|
|
1460
|
+
body,
|
|
1461
|
+
status: archived ? 'archived' : view.statusOf(record, options.context || {}),
|
|
1462
|
+
},
|
|
1162
1463
|
};
|
|
1163
1464
|
}
|
|
1164
1465
|
return null;
|
|
1165
1466
|
});
|
|
1467
|
+
if (!found) return null;
|
|
1468
|
+
/** 读过才算用过;归档的不回写(看病史不等于「在用」)。 */
|
|
1469
|
+
if (!found.archived) touchUsed(found.source, [id]);
|
|
1470
|
+
return found.record;
|
|
1166
1471
|
}
|
|
1167
1472
|
|
|
1168
1473
|
/**
|
|
@@ -1179,7 +1484,9 @@ export function forget(scope, id, options = {}) {
|
|
|
1179
1484
|
for (const source of [scope, globalScope()]) {
|
|
1180
1485
|
const pending = state.purgeIntent(source);
|
|
1181
1486
|
if (!pending) continue;
|
|
1182
|
-
|
|
1487
|
+
/** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
|
|
1488
|
+
const result = purgeWithCold(source, pending.ids, () => state.purge(source, { resume: true }));
|
|
1489
|
+
cleanColdQuarantine(source);
|
|
1183
1490
|
removeBody(source, id);
|
|
1184
1491
|
if (pending.ids.includes(id)) {
|
|
1185
1492
|
replayed = {
|
|
@@ -1217,7 +1524,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1217
1524
|
}
|
|
1218
1525
|
if (archived) {
|
|
1219
1526
|
/** 归档条目也能彻底删除:state.purge 本来就按 id 清整份状态,只是以前没走到这里。 */
|
|
1220
|
-
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1527
|
+
const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
|
|
1528
|
+
cleanColdQuarantine(source);
|
|
1221
1529
|
removeBody(source, id);
|
|
1222
1530
|
return {
|
|
1223
1531
|
id,
|
|
@@ -1228,7 +1536,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1228
1536
|
scopeKey: source.key,
|
|
1229
1537
|
};
|
|
1230
1538
|
}
|
|
1231
|
-
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1539
|
+
const result = purgeWithCold(source, [id], () => state.purge(source, { ids: [id], operationId: options.operationId }));
|
|
1540
|
+
cleanColdQuarantine(source);
|
|
1232
1541
|
removeBody(source, id);
|
|
1233
1542
|
return {
|
|
1234
1543
|
id,
|
|
@@ -1275,8 +1584,9 @@ function findCurrent(id, scope, options = {}) {
|
|
|
1275
1584
|
const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
|
|
1276
1585
|
if (!record) continue;
|
|
1277
1586
|
if (!readableRecord(record, options, index)) continue;
|
|
1278
|
-
/**
|
|
1279
|
-
|
|
1587
|
+
/** 关联目标与来源走同一资格门槛:项目归属、学习状态、条件适用缺一不可。 */
|
|
1588
|
+
const gate = view.eligibility(record, options.runtime || {}, 'read', options.now || Date.now());
|
|
1589
|
+
if (!gate.ok || gate.status !== 'ok') continue;
|
|
1280
1590
|
if (isExperimentalRecord(record) && !view.experimentalUsable(record, options.now)) continue;
|
|
1281
1591
|
return {
|
|
1282
1592
|
id: record.id,
|
|
@@ -1314,6 +1624,7 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1314
1624
|
...record,
|
|
1315
1625
|
title: last.title || record.title,
|
|
1316
1626
|
summary: last.summary || record.summary,
|
|
1627
|
+
summaryFull: typeof last.summaryFull === 'string' ? last.summaryFull : '',
|
|
1317
1628
|
when: last.when || '',
|
|
1318
1629
|
kind: last.kind || record.kind,
|
|
1319
1630
|
tags: Array.isArray(last.tags) ? last.tags : record.tags,
|
|
@@ -1330,6 +1641,9 @@ export function undoChange(scope, id, options = {}) {
|
|
|
1330
1641
|
};
|
|
1331
1642
|
writeBody(source, record.id, last.body);
|
|
1332
1643
|
Object.assign(record, restored);
|
|
1644
|
+
record.maintenance = { ...(record.maintenance || {}) };
|
|
1645
|
+
if (record.summaryFull) record.maintenance.summaryFull = record.summaryFull;
|
|
1646
|
+
else delete record.maintenance.summaryFull;
|
|
1333
1647
|
record.bytes = entrySize(record, last.body);
|
|
1334
1648
|
try {
|
|
1335
1649
|
saveIndex(source, index);
|
|
@@ -1893,6 +2207,27 @@ const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
|
|
|
1893
2207
|
* 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
|
|
1894
2208
|
* 判不出来就返回 needsScope,让调用方问一句,而不是默默写进某一层。
|
|
1895
2209
|
*/
|
|
2210
|
+
/** 本机痕迹:路径、回环地址、端口。带这些字样的「通用」规则换个项目就用不了。 */
|
|
2211
|
+
const MACHINE_BOUND_PATTERNS = [
|
|
2212
|
+
/(?:^|[\s(('"`])(?:\/tmp|\/Users|\/home|\/opt|\/private|\/var|\/etc)(?:[\/]|$)/m,
|
|
2213
|
+
/(?:^|[\s(('"`])~\//m,
|
|
2214
|
+
/(?:^|[^A-Za-z0-9])[A-Za-z]:\\/,
|
|
2215
|
+
/(?:^|[^0-9])127\.0\.0\.1(?::\d+)?/,
|
|
2216
|
+
/(?:^|[^A-Za-z0-9.])localhost(?::\d+)?/,
|
|
2217
|
+
/\b\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}\b/,
|
|
2218
|
+
];
|
|
2219
|
+
|
|
2220
|
+
function machineBoundText(source) {
|
|
2221
|
+
const text = [source?.title, source?.summary, source?.when, source?.body, source?.topicKey]
|
|
2222
|
+
.filter((value) => typeof value === 'string' && value)
|
|
2223
|
+
.join('\n');
|
|
2224
|
+
return MACHINE_BOUND_PATTERNS.some((re) => re.test(text));
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
/**
|
|
2228
|
+
* 适用范围判定:内容/依据的普适性优先,模型声明的 scope 只是提案。
|
|
2229
|
+
* 模型说 global、但正文写死了本机路径或依据是本机文件时,先留在项目层(或问一次条件)。
|
|
2230
|
+
*/
|
|
1896
2231
|
export function routeScope(draft, options = {}) {
|
|
1897
2232
|
const source = draft || {};
|
|
1898
2233
|
const explicit = source.scope === 'global' || source.scope === 'project' ? source.scope : '';
|
|
@@ -1900,9 +2235,30 @@ export function routeScope(draft, options = {}) {
|
|
|
1900
2235
|
const projectReady = Boolean(options.project) && options.project.confirmed !== false;
|
|
1901
2236
|
const cls = typeof source.memoryClass === 'string' ? source.memoryClass : '';
|
|
1902
2237
|
const durability = typeof source.durability === 'string' ? source.durability : 'durable';
|
|
2238
|
+
const evidence = typeof source.evidenceRef === 'string' ? source.evidenceRef.trim() : '';
|
|
2239
|
+
/** 依据本身是本机文件/路径的,不能算通用流程;正文里带本机痕迹同理。 */
|
|
2240
|
+
const machineBound = machineBoundText(source) || (evidence ? machineBoundText({ title: evidence }) : false);
|
|
1903
2241
|
const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
|
|
2242
|
+
/** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
|
|
2243
|
+
const fallbackToProject = (reasonCode) => {
|
|
2244
|
+
if (projectReady) return { ...base, chosenScope: 'project', reasonCode };
|
|
2245
|
+
return {
|
|
2246
|
+
...base,
|
|
2247
|
+
needsScope: true,
|
|
2248
|
+
reasonCode: 'needs_environment',
|
|
2249
|
+
requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
|
|
2250
|
+
};
|
|
2251
|
+
};
|
|
2252
|
+
/** 人明确点过「就存全局」才算数;模型自己提的 global 不抵消本机痕迹。 */
|
|
2253
|
+
const humanConfirmed = options.humanScope === true || source.scopeConfirmed === true;
|
|
1904
2254
|
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
1905
2255
|
if (explicit) {
|
|
2256
|
+
/** 模型自己填的 global 只是提案:本机痕迹、项目事实、无依据的通用流程都按提案降级。 */
|
|
2257
|
+
if (explicit === 'global' && !humanConfirmed) {
|
|
2258
|
+
if (machineBound) return fallbackToProject('machine_bound_scope');
|
|
2259
|
+
if (cls === 'project_fact') return fallbackToProject('model_global_project_fact');
|
|
2260
|
+
if (cls === 'portable_procedure' && !evidence) return fallbackToProject('unbound_evidence');
|
|
2261
|
+
}
|
|
1906
2262
|
if (explicit === 'project' && !projectReady) {
|
|
1907
2263
|
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
1908
2264
|
return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
|
|
@@ -1923,8 +2279,17 @@ export function routeScope(draft, options = {}) {
|
|
|
1923
2279
|
}
|
|
1924
2280
|
return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
|
|
1925
2281
|
}
|
|
1926
|
-
if (cls === 'personal_preference')
|
|
1927
|
-
|
|
2282
|
+
if (cls === 'personal_preference') {
|
|
2283
|
+
/** 偏好也分「我的习惯」和「这台机器上的习惯」:后者绑机器,不进通用层。 */
|
|
2284
|
+
if (machineBound) return fallbackToProject('machine_bound_preference');
|
|
2285
|
+
return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
|
|
2286
|
+
}
|
|
2287
|
+
if (cls === 'portable_procedure') {
|
|
2288
|
+
if (machineBound) return fallbackToProject('machine_bound_procedure');
|
|
2289
|
+
/** 说能搬去别的项目,就要有能搬的依据;没有就先留在本项目。 */
|
|
2290
|
+
if (!evidence) return fallbackToProject('unbound_evidence');
|
|
2291
|
+
return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
|
|
2292
|
+
}
|
|
1928
2293
|
const conditions = view.normalizeConditions(source.conditions) || [];
|
|
1929
2294
|
const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
|
|
1930
2295
|
if (!scoped) {
|
|
@@ -2456,9 +2821,15 @@ function economyOf(ledger) {
|
|
|
2456
2821
|
}
|
|
2457
2822
|
|
|
2458
2823
|
/** 给整理器的候选规则:只读、有限,不跨项目。 */
|
|
2824
|
+
/** 整理器看到的规则顺序与扣尝试时一致:按状态/最近使用排序,不靠插入顺序。 */
|
|
2825
|
+
function orderedLearningRules(index) {
|
|
2826
|
+
const visible = learningRulesOf(index).filter(visibleLearningRule);
|
|
2827
|
+
return learning.orderOrganizerRules(visible, { limit: learning.LIMITS.organizerRules });
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2459
2830
|
export function learningRulesForOrganizer(scope) {
|
|
2460
2831
|
const index = loadIndex(scope);
|
|
2461
|
-
return
|
|
2832
|
+
return orderedLearningRules(index).map(learningRuleView);
|
|
2462
2833
|
}
|
|
2463
2834
|
|
|
2464
2835
|
/**
|
|
@@ -2467,16 +2838,17 @@ export function learningRulesForOrganizer(scope) {
|
|
|
2467
2838
|
*/
|
|
2468
2839
|
export function beginLearningAttempt(scope, options = {}) {
|
|
2469
2840
|
const episodeId = String(options.episodeId || '');
|
|
2470
|
-
|
|
2841
|
+
/** 项目层候选没有作品绑定:按项目身份记一次尝试,限额照样生效,不编造作品 id。 */
|
|
2842
|
+
const attemptKey = episodeId || `project:${scope.key}`;
|
|
2471
2843
|
const now = options.now || Date.now();
|
|
2472
2844
|
return withLock(scope, () => {
|
|
2473
2845
|
const index = loadIndex(scope);
|
|
2474
2846
|
const evolution = index.__evolution;
|
|
2475
2847
|
if (!evolution.enabled || evolution.compatible === false) return { ok: false, reason: '实验没有开启' };
|
|
2476
2848
|
if (evolution.policyVersion !== learning.POLICY_VERSION) return { ok: false, reason: '实验策略版本不认' };
|
|
2477
|
-
const check = learning.quota(evolution.learning, { episodeId, now });
|
|
2849
|
+
const check = learning.quota(evolution.learning, { episodeId: attemptKey, now });
|
|
2478
2850
|
if (!check.ok) return { ...check, learning: evolution.learning };
|
|
2479
|
-
const next = learning.recordAttempt(evolution.learning, { episodeId, now });
|
|
2851
|
+
const next = learning.recordAttempt(evolution.learning, { episodeId: attemptKey, now });
|
|
2480
2852
|
try {
|
|
2481
2853
|
const result = state.transact(scope, {
|
|
2482
2854
|
operationId: options.operationId || '',
|
|
@@ -2493,7 +2865,7 @@ export function beginLearningAttempt(scope, options = {}) {
|
|
|
2493
2865
|
generation: index.generation,
|
|
2494
2866
|
episodeId,
|
|
2495
2867
|
learning: next,
|
|
2496
|
-
rules:
|
|
2868
|
+
rules: orderedLearningRules(index).map(learningRuleView),
|
|
2497
2869
|
replayed: result.replayed === true,
|
|
2498
2870
|
};
|
|
2499
2871
|
} catch (error) {
|
|
@@ -2527,7 +2899,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2527
2899
|
const operationId = String(batch.operationId || '');
|
|
2528
2900
|
const now = batch.now || Date.now();
|
|
2529
2901
|
const at = new Date(now).toISOString();
|
|
2530
|
-
|
|
2902
|
+
/**
|
|
2903
|
+
* 没绑作品的候选(learning.candidateFrom 在项目层给的空 episodeId)按项目身份提交:
|
|
2904
|
+
* 不编造作品 id,但必须由本项目发起;规则版本、epoch 与目标版本照旧要对得上。
|
|
2905
|
+
*/
|
|
2906
|
+
if (!episodeId && String(batch.projectId || '') !== scope.key) return { ok: false, reason: '缺少作品标识' };
|
|
2531
2907
|
if (!operationId) return { ok: false, reason: '缺少提交标识' };
|
|
2532
2908
|
if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
2533
2909
|
return withLock(scope, () => {
|
|
@@ -2542,13 +2918,13 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2542
2918
|
if (!evolution || evolution.enabled !== true) throw new Error('实验没有开启,这次整理作废');
|
|
2543
2919
|
if (evolution.policyVersion !== learning.POLICY_VERSION) throw new Error('实验策略版本不认');
|
|
2544
2920
|
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) {
|
|
2921
|
+
const episode = episodeId ? episodeOfEvolution(evolution, episodeId) : null;
|
|
2922
|
+
if (episodeId && !episode) throw new Error('作品已经失效,这次整理作废');
|
|
2923
|
+
if (episodeId && batch.sessionId) {
|
|
2548
2924
|
const focus = evolution.tasks?.sessions?.[batch.sessionId];
|
|
2549
|
-
if (!focus || focus.episodeId !== episodeId) throw new Error('
|
|
2925
|
+
if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这轮结果没采用');
|
|
2550
2926
|
}
|
|
2551
|
-
if (batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
|
|
2927
|
+
if (episode && batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
|
|
2552
2928
|
const prepared = new Map((Array.isArray(batch.preparedTargets) ? batch.preparedTargets : []).map((row) => [row.id, row]));
|
|
2553
2929
|
let touched = 0;
|
|
2554
2930
|
const currentRules = draft.entries.filter((entry) => entry.status === 'active' && isExperimentalRecord(toFlat(entry)));
|
|
@@ -2591,6 +2967,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2591
2967
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2592
2968
|
at,
|
|
2593
2969
|
};
|
|
2970
|
+
/** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
|
|
2971
|
+
const excerpt = typeof change.sourceExcerpt === 'string'
|
|
2972
|
+
? change.sourceExcerpt
|
|
2973
|
+
: (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
|
|
2974
|
+
if (excerpt) evidence.sourceExcerpt = excerpt;
|
|
2594
2975
|
merged.learning = {
|
|
2595
2976
|
...learning.nextLearningState({
|
|
2596
2977
|
existing: flat,
|
|
@@ -2624,6 +3005,11 @@ export function commitLearning(scope, batch = {}) {
|
|
|
2624
3005
|
messageIds: (batch.messageIds || []).slice(-3),
|
|
2625
3006
|
at,
|
|
2626
3007
|
};
|
|
3008
|
+
/** 用户原句优先取本次改动带的摘录,其次取整批的:四处回执都能看到原话。 */
|
|
3009
|
+
const excerpt = typeof change.sourceExcerpt === 'string'
|
|
3010
|
+
? change.sourceExcerpt
|
|
3011
|
+
: (typeof batch.sourceExcerpt === 'string' ? batch.sourceExcerpt : '');
|
|
3012
|
+
if (excerpt) evidence.sourceExcerpt = excerpt;
|
|
2627
3013
|
const merged = learning.mergeChange(null, change, { now });
|
|
2628
3014
|
/** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
|
|
2629
3015
|
const signature = (list) => (Array.isArray(list) ? list : [])
|