dsh-lite-memory 0.3.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -17
- package/TRIGGERS.md +18 -14
- package/anchor.js +518 -0
- package/bench/bench.mjs +145 -0
- package/bench/evolution.mjs +123 -0
- package/client.js +1519 -33
- package/docs/BENCH.md +74 -0
- package/docs/RELEASE-0.11.0.md +27 -0
- package/docs/RELEASE-0.11.1.md +11 -0
- package/docs/RELEASE-0.12.0.md +15 -0
- package/docs/RELEASE-0.12.1.md +17 -0
- package/docs/RELEASE-0.12.2.md +17 -0
- package/docs/RELEASE-0.13.0.md +7 -0
- package/docs/RELEASE-0.14.0.md +7 -0
- package/docs/RELEASE-0.15.0.md +8 -0
- package/index.js +2080 -76
- package/learning.js +585 -0
- package/package.json +10 -2
- package/state.js +1476 -0
- package/store.js +2967 -244
- package/test/anchor.test.mjs +436 -0
- package/test/client.test.mjs +114 -0
- package/test/contract.test.mjs +880 -0
- package/test/control.test.mjs +278 -0
- package/test/crossing.test.mjs +372 -0
- package/test/evolution-read.test.mjs +448 -0
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
- package/test/fixtures/legacy-v1/index.json +63 -0
- package/test/learning.test.mjs +772 -0
- package/test/lexical.test.mjs +33 -0
- package/test/recall.test.mjs +449 -0
- package/test/scenario.test.mjs +184 -0
- package/test/state.test.mjs +485 -0
- package/test/store.test.mjs +816 -115
- package/test/view.test.mjs +257 -0
- package/view.js +616 -0
package/store.js
CHANGED
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import * as state from './state.js';
|
|
5
|
+
import * as view from './view.js';
|
|
6
|
+
import * as anchor from './anchor.js';
|
|
7
|
+
import * as learning from './learning.js';
|
|
8
|
+
import { LIMITS, memoryRoot, scopeOf, globalScope } from './state.js';
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
11
|
+
* 落盘格式与预算归 state.js 管;这里只保留条目的业务规则。
|
|
12
|
+
* 条数 200 是提示线,硬上限 1000 条或整层正文 512 KB;到顶把最久没读的移进归档,
|
|
13
|
+
* 不拒绝新记忆(保存结果里写明动了哪条,正文保留)。单条超预算才报错。
|
|
9
14
|
*/
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
entryBytes: 4096,
|
|
14
|
-
totalBytes: 512 * 1024,
|
|
15
|
-
titleChars: 40,
|
|
16
|
-
whenChars: 60,
|
|
17
|
-
summaryChars: 60,
|
|
18
|
-
suggestionWindowMs: 48 * 60 * 60 * 1000,
|
|
19
|
-
};
|
|
15
|
+
export { LIMITS, memoryRoot, scopeOf, globalScope };
|
|
16
|
+
/** Record types the model may pick. Anything else is a schema error, not a guess. */
|
|
17
|
+
export const KINDS = ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'];
|
|
20
18
|
|
|
21
19
|
/** Strings that mean "this is a credential", never a memory. */
|
|
22
20
|
const SECRET_PATTERNS = [
|
|
@@ -31,47 +29,224 @@ const SECRET_PATTERNS = [
|
|
|
31
29
|
/** A summary is a noun phrase. An imperative turns a note into an instruction. */
|
|
32
30
|
const IMPERATIVE = /^(?:你要|你得|你必须|请|务必|必须|记得|记住|别忘了|do not|don't|please|you (?:must|should|need))/i;
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const SUGGESTION_ID = /^sug_[a-z0-9]{6,}$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 实验来源的条目默认不属于普通记忆读取面:开关关闭、暂停或身份未知时,
|
|
36
|
+
* 老 ID、病史查询和关联跳转都不能把正文带出来。管理面显式传 allowExperimental。
|
|
37
|
+
*/
|
|
38
|
+
export const EXPERIMENTAL_ORIGIN = 'project_evolution';
|
|
39
|
+
|
|
40
|
+
export function isExperimentalRecord(record) {
|
|
41
|
+
if (!record) return false;
|
|
42
|
+
return record.origin === EXPERIMENTAL_ORIGIN
|
|
43
|
+
|| record.source === EXPERIMENTAL_ORIGIN
|
|
44
|
+
|| Boolean(record.learning);
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const label = path.basename(abs).replace(/[^\w.-]+/g, '-').slice(0, 32) || 'root';
|
|
44
|
-
return { key: `${label}-${hash}`, dir: path.join(memoryRoot(), `${label}-${hash}`), cwd: abs };
|
|
47
|
+
function readableRecord(record, options, index) {
|
|
48
|
+
if (!isExperimentalRecord(record)) return true;
|
|
49
|
+
/** 实验来源只在带本请求上下文时放行;开关打开不等于全局可见。 */
|
|
50
|
+
return !!(options && options.allowExperimental === true);
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
/** 正文缓存:一次读到的正文先放这里,saveIndex 落盘后清掉。 */
|
|
54
|
+
const bodyCache = new Map();
|
|
55
|
+
|
|
56
|
+
function cacheBodies(scope, entries) {
|
|
57
|
+
const map = new Map();
|
|
58
|
+
for (const entry of entries) map.set(entry.id, entry.current && entry.current.body);
|
|
59
|
+
bodyCache.set(scope.dir, map);
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
function cachedBody(scope, id) {
|
|
63
|
+
const map = bodyCache.get(scope.dir);
|
|
64
|
+
if (!map || !map.has(id)) return undefined;
|
|
65
|
+
return map.get(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 深拷贝 JSON 形状,避免扁平记录和权威条目共享引用。 */
|
|
69
|
+
function cloneJson(value) {
|
|
70
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** v2 条目 → 旧扁平记录:业务层只认这一种形状。 */
|
|
74
|
+
function toFlat(entry) {
|
|
75
|
+
const current = entry.current || {};
|
|
76
|
+
const maintenance = entry.maintenance || {};
|
|
77
|
+
return {
|
|
78
|
+
id: entry.id,
|
|
79
|
+
title: current.title || '',
|
|
80
|
+
summary: current.summary || '',
|
|
81
|
+
when: current.when || '',
|
|
82
|
+
body: typeof current.body === 'string' ? current.body : '',
|
|
83
|
+
kind: current.kind || 'note',
|
|
84
|
+
tags: Array.isArray(current.keywords) ? [...current.keywords] : [],
|
|
85
|
+
topicKey: current.topicKey || '',
|
|
86
|
+
aliases: Array.isArray(maintenance.retiredNames) ? [...maintenance.retiredNames] : [],
|
|
87
|
+
triggers: Array.isArray(current.triggers) ? [...current.triggers] : [],
|
|
88
|
+
applicability: current.applicability || 'all_in_scope',
|
|
89
|
+
conditions: Array.isArray(current.conditions) ? JSON.parse(JSON.stringify(current.conditions)) : [],
|
|
90
|
+
selection: current.selection ? JSON.parse(JSON.stringify(current.selection)) : null,
|
|
91
|
+
links: Array.isArray(current.links) ? current.links.map((link) => ({ type: link.type || 'related', id: link.id })) : [],
|
|
92
|
+
reviewState: maintenance.reviewState || 'active',
|
|
93
|
+
created: entry.createdAt || '',
|
|
94
|
+
updated: entry.updatedAt || '',
|
|
95
|
+
lastUsed: maintenance.lastUsed || entry.updatedAt || entry.createdAt || '',
|
|
96
|
+
revision: Number(entry.revision) || 1,
|
|
97
|
+
status: entry.status === 'archived' ? 'archived' : 'active',
|
|
98
|
+
origin: entry.origin || 'user_explicit',
|
|
99
|
+
ownerProjectId: typeof entry.ownerProjectId === 'string' ? entry.ownerProjectId : '',
|
|
100
|
+
hits: Number(maintenance.hits) || 0,
|
|
101
|
+
merges: Number(maintenance.merges) || 0,
|
|
102
|
+
versions: Array.isArray(maintenance.versions) ? cloneJson(maintenance.versions) : [],
|
|
103
|
+
sourceRefs: Array.isArray(maintenance.sourceRefs) ? cloneJson(maintenance.sourceRefs) : [],
|
|
104
|
+
checks: Array.isArray(maintenance.checks) ? cloneJson(maintenance.checks) : [],
|
|
105
|
+
learning: cloneJson(maintenance.learning) || null,
|
|
106
|
+
/** 权威 maintenance 的完整副本:整层写回时不能只按白名单重建。 */
|
|
107
|
+
maintenance: cloneJson(maintenance) || {},
|
|
108
|
+
bytes: entrySize({ title: current.title, summary: current.summary, when: current.when, tags: current.keywords, triggers: current.triggers, topicKey: current.topicKey, applicability: current.applicability, conditions: current.conditions, selection: current.selection, links: current.links }, current.body || ''),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fromFlat(record, status) {
|
|
113
|
+
const at = record.updated || record.created || new Date().toISOString();
|
|
114
|
+
return {
|
|
115
|
+
id: record.id,
|
|
116
|
+
revision: Number(record.revision) || 1,
|
|
117
|
+
status: status || (record.status === 'archived' ? 'archived' : 'active'),
|
|
118
|
+
origin: typeof record.origin === 'string' && record.origin ? record.origin : 'user_explicit',
|
|
119
|
+
ownerProjectId: record.ownerProjectId || '',
|
|
120
|
+
createdAt: record.created || at,
|
|
121
|
+
updatedAt: at,
|
|
122
|
+
current: {
|
|
123
|
+
title: record.title || '',
|
|
124
|
+
summary: record.summary || '',
|
|
125
|
+
body: typeof record.body === 'string' ? record.body : '',
|
|
126
|
+
kind: record.kind || 'note',
|
|
127
|
+
when: record.when || '',
|
|
128
|
+
keywords: Array.isArray(record.tags) ? [...record.tags] : [],
|
|
129
|
+
topicKey: record.topicKey || '',
|
|
130
|
+
triggers: Array.isArray(record.triggers) ? [...record.triggers] : [],
|
|
131
|
+
applicability: record.applicability || 'all_in_scope',
|
|
132
|
+
conditions: Array.isArray(record.conditions) ? JSON.parse(JSON.stringify(record.conditions)) : [],
|
|
133
|
+
selection: record.selection ? JSON.parse(JSON.stringify(record.selection)) : null,
|
|
134
|
+
links: Array.isArray(record.links) ? record.links.map((link) => ({ type: link.type || 'related', id: link.id })) : [],
|
|
135
|
+
aliases: [],
|
|
136
|
+
},
|
|
137
|
+
maintenance: {
|
|
138
|
+
...(cloneJson(record.maintenance) || {}),
|
|
139
|
+
reviewState: record.reviewState === 'legacy_unreviewed' ? 'legacy_unreviewed' : 'active',
|
|
140
|
+
sourceRefs: Array.isArray(record.sourceRefs) ? cloneJson(record.sourceRefs)
|
|
141
|
+
: Array.isArray(record.maintenance?.sourceRefs) ? cloneJson(record.maintenance.sourceRefs) : [],
|
|
142
|
+
retiredNames: Array.isArray(record.aliases) ? [...record.aliases] : [],
|
|
143
|
+
versions: Array.isArray(record.versions) ? cloneJson(record.versions) : [],
|
|
144
|
+
checks: Array.isArray(record.checks) ? cloneJson(record.checks)
|
|
145
|
+
: Array.isArray(record.maintenance?.checks) ? cloneJson(record.maintenance.checks) : [],
|
|
146
|
+
learning: cloneJson(record.learning) || cloneJson(record.maintenance?.learning) || undefined,
|
|
147
|
+
hits: Number(record.hits) || 0,
|
|
148
|
+
merges: Number(record.merges) || 0,
|
|
149
|
+
lastUsed: record.lastUsed || at,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 读一层:v2 状态是唯一权威。旧 index.json 只在迁移时读,读不到正文的旧条目
|
|
156
|
+
* 由 state.js 拦成报告,这里不猜、不补空正文。
|
|
157
|
+
*/
|
|
158
|
+
export function loadIndex(scope, options = {}) {
|
|
159
|
+
if (!options.readOnly && !fs.existsSync(path.join(scope.dir, 'state.json'))) {
|
|
160
|
+
const report = state.migrationReport(scope);
|
|
161
|
+
if (report.required || report.adoptable.length) state.migrateLegacy(scope);
|
|
58
162
|
}
|
|
59
|
-
|
|
163
|
+
const loaded = state.load(scope, options);
|
|
164
|
+
const entries = loaded.state.entries.filter((entry) => entry.status === 'active').map(toFlat);
|
|
165
|
+
const archived = loaded.state.entries.filter((entry) => entry.status === 'archived').map(toFlat);
|
|
166
|
+
cacheBodies(scope, loaded.state.entries);
|
|
167
|
+
const index = {
|
|
168
|
+
version: 2,
|
|
169
|
+
scope: loaded.state.scopeKey,
|
|
170
|
+
cwd: typeof loaded.state.cwd === 'string' ? loaded.state.cwd : '',
|
|
171
|
+
generation: loaded.state.generation,
|
|
172
|
+
epoch: loaded.state.epoch,
|
|
173
|
+
entries,
|
|
174
|
+
archived,
|
|
175
|
+
suggestions: JSON.parse(JSON.stringify(loaded.state.candidates || [])),
|
|
176
|
+
receipts: JSON.parse(JSON.stringify(loaded.state.receipts || [])),
|
|
177
|
+
lastSuggestedAt: loaded.state.lastSuggestedAt || '',
|
|
178
|
+
suppressions: JSON.parse(JSON.stringify(loaded.state.suppressions || [])),
|
|
179
|
+
__evolution: state.evolutionOf(loaded.state),
|
|
180
|
+
__tasks: anchor.normalizeTasks(state.evolutionOf(loaded.state).tasks),
|
|
181
|
+
__control: state.controlOf(loaded.state),
|
|
182
|
+
__exists: loaded.exists === true,
|
|
183
|
+
};
|
|
184
|
+
index.__meta = {
|
|
185
|
+
exists: loaded.exists,
|
|
186
|
+
generation: loaded.state.generation,
|
|
187
|
+
epoch: loaded.state.epoch,
|
|
188
|
+
storeId: loaded.state.storeId || '',
|
|
189
|
+
};
|
|
190
|
+
return index;
|
|
60
191
|
}
|
|
61
192
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
193
|
+
/** 扁平索引 → v2 状态:整层重写,条目、候选、回执一次换完。 */
|
|
194
|
+
function applyFlat(draft, index) {
|
|
195
|
+
const next = [];
|
|
196
|
+
for (const record of index.entries) next.push(fromFlat(record));
|
|
197
|
+
for (const record of index.archived || []) next.push(fromFlat(record, 'archived'));
|
|
198
|
+
draft.entries = next;
|
|
199
|
+
draft.candidates = Array.isArray(index.suggestions) ? JSON.parse(JSON.stringify(index.suggestions)) : [];
|
|
200
|
+
draft.receipts = Array.isArray(index.receipts) ? JSON.parse(JSON.stringify(index.receipts)) : [];
|
|
201
|
+
draft.suppressions = Array.isArray(index.suppressions) ? JSON.parse(JSON.stringify(index.suppressions)) : [];
|
|
202
|
+
draft.lastSuggestedAt = typeof index.lastSuggestedAt === 'string' ? index.lastSuggestedAt : draft.lastSuggestedAt || '';
|
|
203
|
+
draft.scopeKey = draft.scopeKey || index.scope;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 写一层:一个事务发布整份状态。容量在事务里裁决,腾位结果回填到 index.archived,
|
|
208
|
+
* 调用方据此在结果里点名。
|
|
209
|
+
*/
|
|
210
|
+
export function saveIndex(scope, index) {
|
|
211
|
+
const meta = index.__meta || { exists: false };
|
|
212
|
+
const expected = meta.exists ? { generation: meta.generation, epoch: meta.epoch } : null;
|
|
213
|
+
const result = state.transact(scope, { expected, expectCreate: !meta.exists }, (draft) => {
|
|
214
|
+
const before = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
|
|
215
|
+
applyFlat(draft, index);
|
|
216
|
+
const after = JSON.stringify([draft.entries, draft.candidates, draft.receipts, draft.suppressions, draft.lastSuggestedAt]);
|
|
217
|
+
return { changed: before !== after };
|
|
218
|
+
});
|
|
219
|
+
const published = result.state;
|
|
220
|
+
const byId = new Map(published.entries.map((entry) => [entry.id, entry]));
|
|
221
|
+
index.entries = index.entries.filter((record) => {
|
|
222
|
+
const entry = byId.get(record.id);
|
|
223
|
+
return entry && entry.status === 'active';
|
|
224
|
+
});
|
|
225
|
+
index.archived = index.archived || [];
|
|
226
|
+
for (const evicted of result.archived) {
|
|
227
|
+
const record = index.entries.find((item) => item.id === evicted.id);
|
|
228
|
+
if (record) index.entries = index.entries.filter((item) => item.id !== record.id);
|
|
229
|
+
if (!index.archived.some((item) => item.id === evicted.id)) index.archived.push({ id: evicted.id, title: evicted.title, status: 'archived' });
|
|
67
230
|
}
|
|
231
|
+
index.generation = published.generation;
|
|
232
|
+
index.epoch = published.epoch;
|
|
233
|
+
index.__archived = result.archived;
|
|
234
|
+
index.__meta = { exists: true, generation: published.generation, epoch: published.epoch };
|
|
235
|
+
cacheBodies(scope, published.entries);
|
|
236
|
+
return index;
|
|
68
237
|
}
|
|
69
238
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
239
|
+
function withLock(scope, fn) {
|
|
240
|
+
return state.withScopeLock(scope, fn);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Locks several scopes in a stable order, so search across layers cannot deadlock. */
|
|
244
|
+
function withLocks(scopes, fn) {
|
|
245
|
+
const unique = [];
|
|
246
|
+
for (const scope of scopes) if (!unique.some((item) => item.dir === scope.dir)) unique.push(scope);
|
|
247
|
+
unique.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
248
|
+
const run = (index) => (index === unique.length ? fn() : withLock(unique[index], () => run(index + 1)));
|
|
249
|
+
return run(0);
|
|
75
250
|
}
|
|
76
251
|
|
|
77
252
|
export function detectSecret(text) {
|
|
@@ -86,66 +261,184 @@ export function normalizeText(value, max) {
|
|
|
86
261
|
.slice(0, max);
|
|
87
262
|
}
|
|
88
263
|
|
|
264
|
+
/** 超限就报错,不 slice:写的人要自己决定删哪句,存进来的东西要完整。 */
|
|
265
|
+
function textField(value, max, label) {
|
|
266
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
267
|
+
if (text.length > max) throw new Error(`${label}超过 ${max} 字,先写短些`);
|
|
268
|
+
return text;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 标题就是身份,只做无损空白规范化:不切分隔符、不剥条件、不去标点。
|
|
273
|
+
* 超长在 textField 里直接报错,不截断;标题写坏了是调用方的输入问题,不能靠改名字掩盖。
|
|
274
|
+
*/
|
|
275
|
+
function cleanTitle(value) {
|
|
276
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 正文里的实现参数(尺寸/层级/字号)不算记忆:数到三次就要求重写成原则。 */
|
|
280
|
+
const UI_SPEC_PATTERNS = [
|
|
281
|
+
/\bz-index\s*:\s*-?\d+/gi,
|
|
282
|
+
/\b(?:padding|margin|gap|font-size|line-height|border-radius|width|height|min-width|max-width|min-height|max-height|box-shadow)\s*:[^;\n]{0,32}\d/gi,
|
|
283
|
+
/\b\d+(?:\.\d+)?(?:px|rem|em)\b/gi,
|
|
284
|
+
/\bmin\(\s*\d/gi,
|
|
285
|
+
];
|
|
286
|
+
|
|
89
287
|
export function validSummary(summary) {
|
|
90
|
-
const text =
|
|
288
|
+
const text = String(summary ?? '').replace(/\s+/g, ' ').trim();
|
|
91
289
|
if (!text) return { ok: false, reason: '摘要为空' };
|
|
290
|
+
if (text.length > LIMITS.summaryChars) return { ok: false, reason: `摘要超过 ${LIMITS.summaryChars} 字,先写短些` };
|
|
92
291
|
if (IMPERATIVE.test(text)) return { ok: false, reason: '摘要必须写成名词短语,不能写成指令' };
|
|
93
292
|
return { ok: true, text };
|
|
94
293
|
}
|
|
95
294
|
|
|
96
|
-
|
|
97
|
-
|
|
295
|
+
/**
|
|
296
|
+
* 所有落盘入口共用的唯一草稿结构与校验:直接保存、提出建议、点 keep 都从这里过。
|
|
297
|
+
* 凭据检查覆盖全部文本字段;正文按 UTF-8 字节检查,不做语义截断。
|
|
298
|
+
* `has` 记录调用方到底提供了哪些字段,更新时没提供的保持原值。
|
|
299
|
+
*/
|
|
300
|
+
export function normalizeDraft(source, options = {}) {
|
|
301
|
+
const draft = typeof source === 'string' ? { title: source } : source && typeof source === 'object' ? source : {};
|
|
302
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(draft, key) && draft[key] !== undefined;
|
|
303
|
+
const id = typeof draft.id === 'string' && /^mem_[a-z0-9]+$/.test(draft.id) ? draft.id : '';
|
|
304
|
+
if (has('id') && !id) throw new Error('id 格式不对');
|
|
305
|
+
const when = has('when') ? textField(draft.when, LIMITS.whenChars, '前置条件') : '';
|
|
306
|
+
const title = textField(cleanTitle(draft.title), LIMITS.titleChars, '标题');
|
|
307
|
+
/** 带 id 是更新:省略的字段保持原值,标题也一样。 */
|
|
308
|
+
const mayOmitTitle = Boolean(id);
|
|
309
|
+
if (!title && !mayOmitTitle) throw new Error('标题为空');
|
|
310
|
+
let summary = '';
|
|
311
|
+
const rawSummary = String(draft.summary ?? '').replace(/\s+/g, ' ').trim();
|
|
312
|
+
if (rawSummary) {
|
|
313
|
+
const check = validSummary(rawSummary);
|
|
314
|
+
if (!check.ok) throw new Error(check.reason);
|
|
315
|
+
summary = check.text;
|
|
316
|
+
} else if (has('summary')) {
|
|
317
|
+
throw new Error('摘要为空');
|
|
318
|
+
}
|
|
319
|
+
/** 更新(带 id)与候选草稿允许省略摘要;省略表示保持原值,不拿标题补。 */
|
|
320
|
+
const mayOmitSummary = options.requireSummary === false || Boolean(id);
|
|
321
|
+
if (!summary && !mayOmitSummary) throw new Error('摘要为空');
|
|
322
|
+
const body = String(draft.body ?? '').trim();
|
|
323
|
+
const bodyBytes = Buffer.byteLength(body);
|
|
324
|
+
if (bodyBytes > LIMITS.entryBytes) {
|
|
325
|
+
throw new Error(`正文 ${bodyBytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;只留原则、判断规则和触发条件`);
|
|
326
|
+
}
|
|
327
|
+
if (has('body')) {
|
|
328
|
+
const concrete = UI_SPEC_PATTERNS.reduce((count, pattern) => count + (body.match(pattern) || []).length, 0);
|
|
329
|
+
if (concrete >= 3) throw new Error('正文太具体:记忆只留原则和触发条件,像素、字号、层级这类实现参数留在代码或文档里');
|
|
330
|
+
}
|
|
331
|
+
const kind = has('kind') ? textField(draft.kind, 20, '类型') : 'note';
|
|
332
|
+
if (kind && !KINDS.includes(kind)) throw new Error(`类型只能是 ${KINDS.join(' / ')}`);
|
|
333
|
+
const rawTags = has('tags') ? draft.tags : [];
|
|
334
|
+
if (!Array.isArray(rawTags)) throw new Error('标签必须是数组');
|
|
335
|
+
if (rawTags.length > LIMITS.tags) throw new Error(`标签最多 ${LIMITS.tags} 个`);
|
|
336
|
+
const tags = [...new Set(rawTags.map((tag) => textField(tag, LIMITS.tagChars, '标签')).filter(Boolean))];
|
|
337
|
+
/** 结构化字段只走白名单:模型给什么就校验什么,绝不从措辞里推断。 */
|
|
338
|
+
const triggers = view.normalizeTriggers(draft.triggers);
|
|
339
|
+
const conditions = view.normalizeConditions(draft.conditions);
|
|
340
|
+
const selection = view.normalizeSelection(draft.selection);
|
|
341
|
+
const links = view.normalizeLinks(draft.links, id);
|
|
342
|
+
const applicability = view.normalizeApplicability(draft.applicability);
|
|
343
|
+
const topicKey = has('topicKey') ? textField(draft.topicKey, LIMITS.tagChars, '主题') : undefined;
|
|
344
|
+
const secret = detectSecret([title, summary, when, body, tags.join(' '), topicKey || ''].join('\n'));
|
|
345
|
+
if (secret) throw new Error(`疑似凭据(${secret}),只记位置,不记值`);
|
|
346
|
+
return {
|
|
347
|
+
id,
|
|
348
|
+
title,
|
|
349
|
+
summary,
|
|
350
|
+
when,
|
|
351
|
+
body,
|
|
352
|
+
kind: kind || 'note',
|
|
353
|
+
tags,
|
|
354
|
+
triggers,
|
|
355
|
+
conditions,
|
|
356
|
+
selection,
|
|
357
|
+
links,
|
|
358
|
+
applicability,
|
|
359
|
+
topicKey,
|
|
360
|
+
has: {
|
|
361
|
+
title: has('title') && Boolean(title),
|
|
362
|
+
summary: has('summary'),
|
|
363
|
+
when: has('when'),
|
|
364
|
+
body: has('body'),
|
|
365
|
+
kind: has('kind'),
|
|
366
|
+
tags: has('tags'),
|
|
367
|
+
triggers: triggers !== undefined,
|
|
368
|
+
conditions: conditions !== undefined,
|
|
369
|
+
selection: selection !== undefined,
|
|
370
|
+
links: links !== undefined,
|
|
371
|
+
applicability: applicability !== undefined,
|
|
372
|
+
topicKey: topicKey !== undefined,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function newId() {
|
|
378
|
+
return `mem_${crypto.randomBytes(6).toString('hex')}`;
|
|
98
379
|
}
|
|
99
380
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
381
|
+
/** 正文就在状态里;缓存只是省一次读盘,得手时一律回状态读。 */
|
|
382
|
+
function readBody(scope, id) {
|
|
383
|
+
const cached = cachedBody(scope, id);
|
|
384
|
+
if (typeof cached === 'string') return cached;
|
|
385
|
+
const found = state.readEntryBody(scope, id);
|
|
386
|
+
if (!found) throw new Error(`记忆正文缺失:${id} 在索引里,但正文文件不在了`);
|
|
387
|
+
return found.body;
|
|
106
388
|
}
|
|
107
389
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
390
|
+
function writeBody(scope, id, body) {
|
|
391
|
+
const map = bodyCache.get(scope.dir) || new Map();
|
|
392
|
+
map.set(id, body);
|
|
393
|
+
bodyCache.set(scope.dir, map);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function removeBody(scope, id) {
|
|
397
|
+
const map = bodyCache.get(scope.dir);
|
|
398
|
+
if (map) map.delete(id);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function entrySize(record, body) {
|
|
402
|
+
const meta = [record.title || '', record.summary || '', record.when || '', (record.tags || []).join(',')].join('\n');
|
|
403
|
+
return Buffer.byteLength(meta) + Buffer.byteLength(body || '') + Buffer.byteLength(state.fieldsBytesOf(record));
|
|
115
404
|
}
|
|
116
405
|
|
|
117
|
-
|
|
406
|
+
function recordSize(record) {
|
|
407
|
+
return Buffer.byteLength(JSON.stringify({ ...record, versions: [] }));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function activeBytes(index, skipId) {
|
|
411
|
+
return index.entries.reduce((sum, item) => (item.id === skipId ? sum : sum + (item.bytes || 0)), 0);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** 归档不是删除:条目标成 archived,正文与旧版本原样留在状态里,检索与提示不再返回。 */
|
|
118
415
|
function archiveEntry(scope, index, entry) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
123
|
-
if (fs.existsSync(from)) fs.renameSync(from, to);
|
|
124
|
-
} catch {
|
|
125
|
-
/* an unreadable entry is still dropped from the index */
|
|
126
|
-
}
|
|
416
|
+
const body = readBody(scope, entry.id);
|
|
417
|
+
entry.status = 'archived';
|
|
418
|
+
entry.body = body;
|
|
127
419
|
index.entries = index.entries.filter((item) => item.id !== entry.id);
|
|
420
|
+
index.archived = [...(index.archived || []), entry];
|
|
421
|
+
writeBody(scope, entry.id, body);
|
|
128
422
|
}
|
|
129
423
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
424
|
+
/**
|
|
425
|
+
* 条数 200 是提示线,不是墙:到 1000 条或正文预算将满时才腾位置。
|
|
426
|
+
* capacityOf 同时给出字节余量,调用方能说清是条数还是正文先到顶。
|
|
427
|
+
*/
|
|
428
|
+
export function capacityOf(scope, index) {
|
|
135
429
|
const cap = scope.key === 'global' ? LIMITS.globalEntries : LIMITS.entries;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
430
|
+
const used = index.entries.length;
|
|
431
|
+
const bytes = index.entries.reduce((sum, item) => sum + (item.bytes || 0), 0);
|
|
432
|
+
return {
|
|
433
|
+
cap,
|
|
434
|
+
hard: LIMITS.hardEntries,
|
|
435
|
+
used,
|
|
436
|
+
remaining: Math.max(0, cap - used),
|
|
437
|
+
bytes,
|
|
438
|
+
bytesLeft: Math.max(0, LIMITS.totalBytes - bytes),
|
|
439
|
+
};
|
|
144
440
|
}
|
|
145
441
|
|
|
146
|
-
/** 同一主题的判定线。跨过就合并成一条,跨不过就新建。 */
|
|
147
|
-
export const MERGE = { title: 0.6, summary: 0.75 };
|
|
148
|
-
|
|
149
442
|
/** 相邻两字为一组:中文没有空格,按词切分永远对不上,按字片才认得出改写的同一件事。 */
|
|
150
443
|
function bigrams(text) {
|
|
151
444
|
const plain = normalizeText(text, 400).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
|
@@ -156,7 +449,68 @@ function bigrams(text) {
|
|
|
156
449
|
return out;
|
|
157
450
|
}
|
|
158
451
|
|
|
159
|
-
|
|
452
|
+
/** 只靠这类高频两字词重合不算相关:查询里的实词必须自己命中。 */
|
|
453
|
+
const STOP_GRAMS = new Set([
|
|
454
|
+
'这个', '那个', '怎么', '什么', '可以', '需要', '我们', '你们', '一下', '现在',
|
|
455
|
+
'然后', '还是', '就是', '已经', '没有', '不是', '要是', '如果', '因为', '所以',
|
|
456
|
+
'的话', '这里', '那里', '一个', '一些', '这样', '那样', '看看', '帮我看', '继续',
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
const asciiWordsOf = (text) => normalizeText(text, 400).toLowerCase().match(/[a-z][a-z0-9_-]{1,}/g) || [];
|
|
460
|
+
|
|
461
|
+
const wordIn = (pool, word) => new RegExp(`(^|[^a-z0-9])${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z0-9])`).test(pool);
|
|
462
|
+
|
|
463
|
+
/** 查询按空格/标点切成段:多段按关键词处理,整句仍按相关度处理。 */
|
|
464
|
+
function queryRuns(query) {
|
|
465
|
+
return normalizeText(query, 400)
|
|
466
|
+
.toLowerCase()
|
|
467
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
.slice(0, 12);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function fieldText(value) {
|
|
473
|
+
return String(value ?? '').toLowerCase().replace(/\s+/g, ' ');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* 多段查询里,条目至少要在一段上过半命中,且命中段不能只是孤零零一个两字词;
|
|
478
|
+
* 否则「插件市场」这种东西只要沾了个「插件」就会挤进结果。
|
|
479
|
+
*/
|
|
480
|
+
function matchesRun(item, runs) {
|
|
481
|
+
const pool = [
|
|
482
|
+
item.title,
|
|
483
|
+
(item.aliases || []).join(' '),
|
|
484
|
+
(item.tags || []).join(' '),
|
|
485
|
+
(item.triggers || []).join(' '),
|
|
486
|
+
item.summary,
|
|
487
|
+
item.when,
|
|
488
|
+
].map(fieldText).join('\n');
|
|
489
|
+
let qualified = 0;
|
|
490
|
+
let substantial = 0;
|
|
491
|
+
for (const run of runs) {
|
|
492
|
+
/** 英文按整词匹配:git 不能因为共享 gi/it 就命中 digital。 */
|
|
493
|
+
if (/^[a-z][a-z0-9_-]+$/.test(run)) {
|
|
494
|
+
if (!wordIn(pool, run)) continue;
|
|
495
|
+
qualified += 1;
|
|
496
|
+
substantial += 1;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const grams = bigrams(run);
|
|
500
|
+
if (grams.size === 0) continue;
|
|
501
|
+
let hit = 0;
|
|
502
|
+
for (const gram of grams) if (pool.includes(gram)) hit += 1;
|
|
503
|
+
if (hit === 0) continue;
|
|
504
|
+
const full = hit === grams.size;
|
|
505
|
+
const half = hit >= 2 && hit * 2 >= grams.size;
|
|
506
|
+
if (!full && !half) continue;
|
|
507
|
+
qualified += 1;
|
|
508
|
+
if (hit >= 2) substantial += 1;
|
|
509
|
+
}
|
|
510
|
+
return substantial >= 1 || qualified >= 2;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function similarity(a, b) {
|
|
160
514
|
const left = bigrams(a);
|
|
161
515
|
const right = bigrams(b);
|
|
162
516
|
if (left.size === 0 || right.size === 0) return 0;
|
|
@@ -165,195 +519,2564 @@ function similarity(a, b) {
|
|
|
165
519
|
return shared / (left.size + right.size - shared);
|
|
166
520
|
}
|
|
167
521
|
|
|
522
|
+
/** 身份比较只认给定的语义字段:没给的字段不参与,不拿默认值把两条规则并成一条。 */
|
|
523
|
+
const hasField = (draft, key) => (draft && draft.has && typeof draft.has === 'object'
|
|
524
|
+
? draft.has[key] === true
|
|
525
|
+
: Boolean(draft) && draft[key] !== undefined);
|
|
526
|
+
|
|
527
|
+
const selectionKeyOf = (selection) => {
|
|
528
|
+
if (!selection) return '';
|
|
529
|
+
const items = Array.isArray(selection.items) ? [...selection.items].map(String).sort() : [];
|
|
530
|
+
return JSON.stringify([selection.mode || '', selection.artifactKey || '', items]);
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const triggerKeyOf = (row) => JSON.stringify([...(row.triggers || [])].map(String).sort());
|
|
534
|
+
|
|
535
|
+
const linkKeyOf = (row) => JSON.stringify([...(row.links || [])]
|
|
536
|
+
.map((link) => [String(link.type || 'related'), String(link.id)])
|
|
537
|
+
.sort((a, b) => a[1].localeCompare(b[1])));
|
|
538
|
+
|
|
539
|
+
/** 适用范围仍是推断出来的值也算数:显式写 conditional 与靠条件推出来的是同一档。 */
|
|
540
|
+
function applicabilityOf(row) {
|
|
541
|
+
return view.validateApplicability(row.applicability, row.conditions || [], row.selection || null, row.when || '');
|
|
542
|
+
}
|
|
543
|
+
|
|
168
544
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
545
|
+
* 身份 = 标题/旧标题 + 前置条件 + 这次真正给出的适用范围字段。
|
|
546
|
+
* 不同机器、不同运行时的规则即使同名也是两条;没给的字段保持兼容,不误判成另一条。
|
|
171
547
|
*/
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
|
|
548
|
+
export function resolveTarget(entries, draft, whenArg) {
|
|
549
|
+
const source = typeof draft === 'string' ? { title: draft, when: whenArg } : draft || {};
|
|
550
|
+
const title = normalizeText(source.title, LIMITS.titleChars);
|
|
551
|
+
const when = normalizeText(source.when, LIMITS.whenChars);
|
|
552
|
+
const plain = title.toLowerCase();
|
|
175
553
|
for (const item of entries) {
|
|
176
554
|
if ((item.when || '') !== when) continue;
|
|
177
|
-
if (item.
|
|
178
|
-
|
|
555
|
+
if (hasField(source, 'conditions') && view.conditionsKey(item.conditions) !== view.conditionsKey(source.conditions)) continue;
|
|
556
|
+
if (hasField(source, 'selection') && selectionKeyOf(item.selection) !== selectionKeyOf(source.selection)) continue;
|
|
557
|
+
if (hasField(source, 'triggers') && triggerKeyOf(item) !== triggerKeyOf(source)) continue;
|
|
558
|
+
if (hasField(source, 'applicability') && applicabilityOf(item) !== applicabilityOf(source)) continue;
|
|
559
|
+
const names = [item.title, ...(item.aliases || [])].map((name) => String(name || '').toLowerCase());
|
|
560
|
+
if (names.includes(plain)) return { target: item, score: 1 };
|
|
561
|
+
}
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** 相近线只用于「提示 / 候选」,与合并无关。 */
|
|
566
|
+
export const MERGE = { near: 0.3 };
|
|
567
|
+
|
|
568
|
+
/** 最多两条最接近的条目:看得到具体 id 和标题,才有可能选对。 */
|
|
569
|
+
export function similarEntries(entries, title, summary, limit = 2) {
|
|
570
|
+
const rows = [];
|
|
571
|
+
for (const item of entries) {
|
|
572
|
+
const names = [item.title, ...(item.aliases || [])];
|
|
573
|
+
const titleScore = Math.max(0, ...names.map((name) => similarity(name, title)));
|
|
179
574
|
const summaryScore = similarity(item.summary, summary);
|
|
180
|
-
if (titleScore < MERGE.title && summaryScore < MERGE.summary) continue;
|
|
181
575
|
const score = Math.max(titleScore, summaryScore);
|
|
182
|
-
if (score
|
|
183
|
-
best = item;
|
|
184
|
-
bestScore = score;
|
|
185
|
-
}
|
|
576
|
+
if (score >= MERGE.near) rows.push({ id: item.id, title: item.title, summary: item.summary, when: item.when || '', score });
|
|
186
577
|
}
|
|
187
|
-
|
|
578
|
+
rows.sort((a, b) => b.score - a.score);
|
|
579
|
+
return rows.slice(0, limit);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 候选更新目标:标题相近还不够,应用范围和条件也必须一致,否则保留会改错条目。 */
|
|
583
|
+
export function similarTarget(entries, draft) {
|
|
584
|
+
const rows = similarEntries(entries, draft.title, draft.summary, 2);
|
|
585
|
+
const compatible = rows.filter((row) => {
|
|
586
|
+
const item = entries.find((entry) => entry.id === row.id);
|
|
587
|
+
if (!item) return false;
|
|
588
|
+
if (hasField(draft, 'conditions') && view.conditionsKey(item.conditions) !== view.conditionsKey(draft.conditions)) return false;
|
|
589
|
+
if (hasField(draft, 'selection') && selectionKeyOf(item.selection) !== selectionKeyOf(draft.selection)) return false;
|
|
590
|
+
if (hasField(draft, 'applicability') && applicabilityOf(item) !== applicabilityOf(draft)) return false;
|
|
591
|
+
if (hasField(draft, 'triggers') && triggerKeyOf(item) !== triggerKeyOf(draft)) return false;
|
|
592
|
+
if (hasField(draft, 'topicKey') && (item.topicKey || '') !== (draft.topicKey || '')) return false;
|
|
593
|
+
return true;
|
|
594
|
+
});
|
|
595
|
+
return compatible[0] || null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** 没有合并时,把最接近的那条报出来:看得到,才有可能纠正。 */
|
|
599
|
+
export function nearestEntry(entries, title, summary) {
|
|
600
|
+
return similarEntries(entries, title, summary, 1)[0] || null;
|
|
188
601
|
}
|
|
189
602
|
|
|
190
|
-
/**
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
603
|
+
/** 旧版本整段保留,字段齐全,恢复时可以连标题与条件一起还原。
|
|
604
|
+
* 空正文也是合法版本:从空正文改成有正文,撤销必须回到空,而不是当成新建去归档。 */
|
|
605
|
+
function snapshot(record, body) {
|
|
606
|
+
const version = {
|
|
607
|
+
at: record.updated || record.created || new Date().toISOString(),
|
|
608
|
+
title: record.title,
|
|
609
|
+
summary: record.summary,
|
|
610
|
+
when: record.when || '',
|
|
611
|
+
kind: record.kind || 'note',
|
|
612
|
+
tags: [...(record.tags || [])],
|
|
613
|
+
triggers: [...(record.triggers || [])],
|
|
614
|
+
applicability: record.applicability || 'all_in_scope',
|
|
615
|
+
conditions: JSON.parse(JSON.stringify(record.conditions || [])),
|
|
616
|
+
selection: record.selection ? JSON.parse(JSON.stringify(record.selection)) : null,
|
|
617
|
+
links: (record.links || []).map((link) => ({ type: link.type || 'related', id: link.id })),
|
|
618
|
+
topicKey: record.topicKey || '',
|
|
619
|
+
body,
|
|
195
620
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const line = raw.trim();
|
|
200
|
-
if (!line) continue;
|
|
201
|
-
const key = keyOf(line);
|
|
202
|
-
if (key) {
|
|
203
|
-
const at = out.findIndex((item) => keyOf(item) === key);
|
|
204
|
-
if (at >= 0) out[at] = line;
|
|
205
|
-
else out.push(line);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
if (!out.some((item) => flat(item) === flat(line))) out.push(line);
|
|
621
|
+
let kept = [...(record.versions || []), version].slice(-2);
|
|
622
|
+
while (kept.length > 1 && kept.reduce((sum, item) => sum + Buffer.byteLength(item.body), 0) > LIMITS.historyBytes) {
|
|
623
|
+
kept = kept.slice(1);
|
|
209
624
|
}
|
|
210
|
-
return
|
|
625
|
+
return kept;
|
|
211
626
|
}
|
|
212
627
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
628
|
+
/**
|
|
629
|
+
* 适用规则的合并:只认显式给过的字段,没给的保持原值;老条目的默认档位重新推断。
|
|
630
|
+
* 关联目标必须当前存在,缺了就是错,不静默丢掉一条关联。
|
|
631
|
+
*/
|
|
632
|
+
function assertLinksVisible(links, index, foreign) {
|
|
633
|
+
const visible = foreign && foreign.length ? [...index.entries, ...foreign] : index.entries;
|
|
634
|
+
for (const link of links) {
|
|
635
|
+
if (!visible.some((item) => item.id === link.id)) throw new Error(`关联目标不存在:${link.id}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
222
638
|
|
|
223
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
639
|
+
function rulesOf(draft, body, index, foreign) {
|
|
640
|
+
const rules = {
|
|
641
|
+
triggers: draft.has.triggers ? draft.triggers : [],
|
|
642
|
+
conditions: draft.has.conditions ? draft.conditions : [],
|
|
643
|
+
selection: draft.has.selection ? draft.selection : null,
|
|
644
|
+
links: draft.has.links ? draft.links : [],
|
|
645
|
+
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
646
|
+
};
|
|
647
|
+
rules.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
648
|
+
rules.conditions, rules.selection, draft.when);
|
|
649
|
+
assertLinksVisible(rules.links, index, foreign);
|
|
650
|
+
void body;
|
|
651
|
+
return rules;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function mergedFields(draft, record, oldBody, index, writeMode = 'patch', foreign) {
|
|
655
|
+
if (writeMode === 'replace_current') {
|
|
656
|
+
const next = {
|
|
657
|
+
title: draft.title,
|
|
658
|
+
summary: draft.summary,
|
|
659
|
+
when: draft.when,
|
|
660
|
+
kind: draft.kind || 'note',
|
|
661
|
+
tags: draft.tags,
|
|
662
|
+
triggers: draft.has.triggers ? draft.triggers : [],
|
|
663
|
+
conditions: draft.has.conditions ? draft.conditions : [],
|
|
664
|
+
selection: draft.has.selection ? draft.selection : null,
|
|
665
|
+
links: draft.has.links ? draft.links : [],
|
|
666
|
+
topicKey: draft.has.topicKey ? draft.topicKey : '',
|
|
667
|
+
body: draft.body,
|
|
668
|
+
};
|
|
669
|
+
next.applicability = view.validateApplicability(draft.has.applicability ? draft.applicability : undefined,
|
|
670
|
+
next.conditions, next.selection, next.when);
|
|
671
|
+
assertLinksVisible(next.links, index, foreign);
|
|
672
|
+
return next;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* 只有范围字段变了才校验或重算。只改标题/摘要/正文/标签时保留原适用范围,
|
|
676
|
+
* 否则一条 unresolved 会因无关 patch 被悄悄放宽成 all_in_scope。
|
|
677
|
+
*/
|
|
678
|
+
const scopeTouched = draft.has.applicability || draft.has.when
|
|
679
|
+
|| draft.has.conditions || draft.has.selection;
|
|
680
|
+
const requested = draft.has.applicability ? draft.applicability : undefined;
|
|
681
|
+
const next = {
|
|
682
|
+
title: draft.has.title ? draft.title : record.title,
|
|
683
|
+
summary: draft.has.summary ? draft.summary : record.summary || '',
|
|
684
|
+
when: draft.has.when ? draft.when : record.when || '',
|
|
685
|
+
kind: draft.has.kind ? draft.kind || 'note' : record.kind || 'note',
|
|
686
|
+
tags: draft.has.tags ? draft.tags : record.tags || [],
|
|
687
|
+
triggers: draft.has.triggers ? draft.triggers : (record.triggers || []),
|
|
688
|
+
conditions: draft.has.conditions ? draft.conditions : (record.conditions || []),
|
|
689
|
+
selection: draft.has.selection ? draft.selection : (record.selection || null),
|
|
690
|
+
links: draft.has.links ? draft.links : (record.links || []),
|
|
691
|
+
topicKey: draft.has.topicKey ? draft.topicKey : (record.topicKey || ''),
|
|
692
|
+
body: draft.has.body ? draft.body : oldBody,
|
|
693
|
+
};
|
|
694
|
+
next.applicability = scopeTouched
|
|
695
|
+
? view.validateApplicability(requested, next.conditions, next.selection, next.when)
|
|
696
|
+
: (record.applicability || 'all_in_scope');
|
|
697
|
+
assertLinksVisible(next.links, index, foreign);
|
|
698
|
+
return next;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 一次写入的纯业务部分:校验、身份、字节预算、版本。不改盘(正文写入由调用方在同一把锁里做)。
|
|
703
|
+
*/
|
|
704
|
+
function applyDraft(scope, index, source, now, options = {}) {
|
|
705
|
+
const draft = normalizeDraft(source);
|
|
706
|
+
const foreign = options.foreignEntries || [];
|
|
707
|
+
const distinct = source && source.resolution === 'distinct';
|
|
708
|
+
let record = draft.id ? index.entries.find((item) => item.id === draft.id) : null;
|
|
709
|
+
if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
|
|
710
|
+
let mergedFrom = null;
|
|
711
|
+
let similarList = [];
|
|
712
|
+
if (!record) {
|
|
713
|
+
/** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
|
|
714
|
+
const hit = distinct ? null : resolveTarget(index.entries, draft);
|
|
715
|
+
if (hit) {
|
|
716
|
+
record = hit.target;
|
|
717
|
+
mergedFrom = hit.target.title === draft.title ? null : hit.target.title;
|
|
718
|
+
} else if (draft.has.topicKey && draft.topicKey && !distinct) {
|
|
719
|
+
/** 同一槽位只留一条现行规范:同层同 topicKey + 同条件必须确认是更新还是另一条。 */
|
|
720
|
+
const slot = index.entries.find((item) => (item.topicKey || '') === draft.topicKey
|
|
721
|
+
&& view.conditionsKey(item.conditions) === view.conditionsKey(draft.conditions));
|
|
722
|
+
if (slot) {
|
|
723
|
+
const row = { id: slot.id, title: slot.title, summary: slot.summary || '', revision: slot.revision || 1 };
|
|
724
|
+
return { needsResolution: true, sameTopic: true, similarList: [row], similar: row, draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
725
|
+
}
|
|
726
|
+
similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
|
|
727
|
+
} else {
|
|
728
|
+
similarList = similarEntries(index.entries, draft.title, draft.summary, 2);
|
|
729
|
+
/** 同名不同前置条件是用户明确表达的另一条,不走确认;只有换个说法的相近标题才拦。 */
|
|
730
|
+
const sameName = index.entries.some((item) => [item.title, ...(item.aliases || [])]
|
|
731
|
+
.some((name) => String(name || '').toLowerCase() === draft.title.toLowerCase()));
|
|
732
|
+
if (similarList.length > 0 && !distinct && !sameName) {
|
|
733
|
+
return { needsResolution: true, similarList, similar: similarList[0], draft, record: null, merged: false, previousBody: null, archived: [] };
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
const writeMode = source && source.writeMode === 'replace_current' ? 'replace_current' : 'patch';
|
|
738
|
+
if (record && writeMode === 'replace_current') {
|
|
739
|
+
for (const key of ['title', 'summary', 'when', 'body', 'kind', 'tags']) {
|
|
740
|
+
if (!draft.has[key]) throw new Error(`整份替换要给出 ${key},空值也要显式写`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (record) {
|
|
744
|
+
const oldBody = readBody(scope, record.id);
|
|
745
|
+
const next = mergedFields(draft, record, oldBody, index, writeMode, foreign);
|
|
746
|
+
const noop = next.title === record.title && next.summary === record.summary
|
|
747
|
+
&& next.when === (record.when || '') && next.kind === (record.kind || 'note')
|
|
748
|
+
&& JSON.stringify(next.tags) === JSON.stringify(record.tags || [])
|
|
749
|
+
&& JSON.stringify(next.triggers) === JSON.stringify(record.triggers || [])
|
|
750
|
+
&& JSON.stringify(next.conditions) === JSON.stringify(record.conditions || [])
|
|
751
|
+
&& JSON.stringify(next.selection) === JSON.stringify(record.selection || null)
|
|
752
|
+
&& JSON.stringify(next.links) === JSON.stringify(record.links || [])
|
|
753
|
+
&& (next.topicKey || '') === (record.topicKey || '')
|
|
754
|
+
&& next.applicability === (record.applicability || 'all_in_scope')
|
|
755
|
+
&& next.body === oldBody;
|
|
756
|
+
if (noop) {
|
|
757
|
+
return { record, merged: true, mergedFrom: null, similar: null, similarList, draft, noop: true, previousBody: oldBody, archived: [] };
|
|
758
|
+
}
|
|
759
|
+
const versions = snapshot(record, oldBody);
|
|
760
|
+
const titles = [...new Set([...(record.aliases || []), record.title].map((name) => cleanTitle(name)).filter(Boolean))].slice(-5);
|
|
761
|
+
const nextRecord = {
|
|
762
|
+
...record,
|
|
763
|
+
...next,
|
|
764
|
+
aliases: titles,
|
|
765
|
+
versions,
|
|
766
|
+
revision: (record.revision || 1) + 1,
|
|
767
|
+
updated: now,
|
|
768
|
+
lastUsed: now,
|
|
769
|
+
merges: (record.merges || 0) + 1,
|
|
770
|
+
status: 'active',
|
|
771
|
+
source: record.source || (source && source.source) || 'user_explicit',
|
|
772
|
+
};
|
|
773
|
+
nextRecord.bytes = entrySize(nextRecord, next.body);
|
|
774
|
+
const archived = assertFits(scope, index, nextRecord);
|
|
775
|
+
writeBody(scope, record.id, next.body);
|
|
776
|
+
Object.assign(record, nextRecord);
|
|
777
|
+
return { record, merged: true, mergedFrom, similar: null, similarList, draft, noop: false, previousBody: oldBody, archived };
|
|
778
|
+
}
|
|
779
|
+
if (writeMode === 'replace_current') throw new Error('整份替换要先指明是哪条,不能用来新增');
|
|
780
|
+
const rules = rulesOf(draft, draft.body, index, foreign);
|
|
781
|
+
const created = {
|
|
782
|
+
id: newId(),
|
|
783
|
+
title: draft.title,
|
|
784
|
+
summary: draft.summary,
|
|
785
|
+
when: draft.when,
|
|
786
|
+
kind: draft.kind,
|
|
787
|
+
tags: draft.tags,
|
|
788
|
+
triggers: rules.triggers,
|
|
789
|
+
applicability: rules.applicability,
|
|
790
|
+
conditions: rules.conditions,
|
|
791
|
+
selection: rules.selection,
|
|
792
|
+
links: rules.links,
|
|
793
|
+
topicKey: rules.topicKey,
|
|
794
|
+
body: draft.body,
|
|
795
|
+
aliases: [],
|
|
796
|
+
created: now,
|
|
239
797
|
updated: now,
|
|
240
798
|
lastUsed: now,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
799
|
+
revision: 1,
|
|
800
|
+
status: 'active',
|
|
801
|
+
source: source && source.source === 'observed' ? 'observed' : 'user_explicit',
|
|
802
|
+
hits: 0,
|
|
803
|
+
merges: 0,
|
|
804
|
+
versions: [],
|
|
805
|
+
bytes: 0,
|
|
244
806
|
};
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
index.
|
|
249
|
-
|
|
250
|
-
saveIndex(scope, index);
|
|
251
|
-
return { record, merged: Boolean(existing), mergedFrom: existing && existing.title !== title ? existing.title : null };
|
|
807
|
+
created.bytes = entrySize(created, created.body);
|
|
808
|
+
const archived = assertFits(scope, index, created);
|
|
809
|
+
writeBody(scope, created.id, created.body);
|
|
810
|
+
index.entries.push(created);
|
|
811
|
+
return { record: created, merged: false, mergedFrom: null, similar: similarList[0] || null, similarList, draft, noop: false, previousBody: null, archived };
|
|
252
812
|
}
|
|
253
813
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
814
|
+
/**
|
|
815
|
+
* 单条与记录序列化两档预算:新增与更新走同一套。
|
|
816
|
+
* 整层条数/正文预算由 state.transact 在发布前裁决(腾最久没读的那条,不拒绝新记忆),
|
|
817
|
+
* 结果经 index.__archived 回填,调用方在工具结果里点名。
|
|
818
|
+
*/
|
|
819
|
+
function assertFits(scope, index, record) {
|
|
820
|
+
if (record.bytes > LIMITS.entryBytes) {
|
|
821
|
+
throw new Error(`这一条 ${record.bytes} 字节,超过单条 ${LIMITS.entryBytes} 字节;先精简正文`);
|
|
822
|
+
}
|
|
823
|
+
const size = recordSize(record);
|
|
824
|
+
if (size > LIMITS.recordBytes) {
|
|
825
|
+
throw new Error(`这一条序列化后 ${size} 字节,超过 ${LIMITS.recordBytes} 字节;减少字段或换更短的标签`);
|
|
826
|
+
}
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 保存。带 id 就是明确更新那条(没给的字段保持原值);不带 id 时只认标题 / 旧标题 + 同一前置条件。
|
|
832
|
+
* 文本相近不授权覆盖,也不先写一条再更新:返回 needsResolution,调用方决定 distinct 还是按 id 更新。
|
|
833
|
+
*/
|
|
834
|
+
export function upsert(scope, source) {
|
|
835
|
+
return withLock(scope, () => upsertLocked(scope, source));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** 已经持锁的保存:saveEntry 用它把两层去重和写入放进同一个锁窗口。 */
|
|
839
|
+
function upsertLocked(scope, source, options = {}) {
|
|
840
|
+
{
|
|
841
|
+
const index = loadIndex(scope, options.allowMove ? { allowMove: true } : {});
|
|
842
|
+
const now = new Date().toISOString();
|
|
843
|
+
const result = applyDraft(scope, index, source, now, options);
|
|
844
|
+
if (!('needsResolution' in result)) result.needsResolution = false;
|
|
845
|
+
if (result.needsResolution) return result;
|
|
846
|
+
try {
|
|
847
|
+
saveIndex(scope, index);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
if (result.record && result.previousBody !== null) {
|
|
850
|
+
try {
|
|
851
|
+
writeBody(scope, result.record.id, result.previousBody);
|
|
852
|
+
} catch {
|
|
853
|
+
/* keep the original error */
|
|
854
|
+
}
|
|
855
|
+
} else if (result.record) {
|
|
856
|
+
removeBody(scope, result.record.id);
|
|
857
|
+
}
|
|
858
|
+
throw error;
|
|
271
859
|
}
|
|
860
|
+
const evicted = index.__archived || [];
|
|
861
|
+
if (evicted.length) result.archived = [...(result.archived || []), ...evicted];
|
|
862
|
+
return result;
|
|
272
863
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const MATCH_WEIGHT = { title: 3, alias: 2, summary: 2, tags: 1.5 };
|
|
867
|
+
|
|
868
|
+
function scoreQuery(item, grams, raw) {
|
|
869
|
+
const fields = [
|
|
870
|
+
[item.title, MATCH_WEIGHT.title],
|
|
871
|
+
[(item.aliases || []).join(' '), MATCH_WEIGHT.alias],
|
|
872
|
+
[item.summary, MATCH_WEIGHT.summary],
|
|
873
|
+
[item.when, MATCH_WEIGHT.alias],
|
|
874
|
+
[(item.tags || []).join(' '), MATCH_WEIGHT.tags],
|
|
875
|
+
[(item.triggers || []).join(' '), MATCH_WEIGHT.tags],
|
|
876
|
+
];
|
|
877
|
+
let score = 0;
|
|
878
|
+
let substantive = 0;
|
|
879
|
+
for (const [text, weight] of fields) {
|
|
880
|
+
if (!text) continue;
|
|
881
|
+
const pool = bigrams(text);
|
|
882
|
+
let shared = 0;
|
|
883
|
+
for (const gram of grams) {
|
|
884
|
+
if (!pool.has(gram)) continue;
|
|
885
|
+
shared += 1;
|
|
886
|
+
if (!STOP_GRAMS.has(gram)) substantive += 1;
|
|
887
|
+
}
|
|
888
|
+
score += shared * weight;
|
|
889
|
+
}
|
|
890
|
+
const plain = normalizeText(raw, 200).toLowerCase().replace(/\s+/g, '');
|
|
891
|
+
if (plain && normalizeText(item.title, 200).toLowerCase().replace(/\s+/g, '').includes(plain)) score += 5;
|
|
892
|
+
/** 触发词命中按整词算:用户说的那句话里带着它,就该搜得到。 */
|
|
893
|
+
const triggerHit = Boolean(plain) && (item.triggers || []).some((trigger) => {
|
|
894
|
+
const text = normalizeText(trigger, 200).toLowerCase().replace(/\s+/g, '');
|
|
895
|
+
return Boolean(text) && plain.includes(text);
|
|
896
|
+
});
|
|
897
|
+
if (triggerHit) score += 4;
|
|
898
|
+
/** 纯英文查询必须按整词命中,避免 git 命中 digital 这种字面巧合;混合查询仍按中文实词判断。 */
|
|
899
|
+
const words = asciiWordsOf(raw);
|
|
900
|
+
const hasCjk = /[\u4e00-\u9fff]/.test(String(raw || ''));
|
|
901
|
+
if (words.length && !hasCjk) {
|
|
902
|
+
const pool = fields.map(([text]) => fieldText(text)).join('\n');
|
|
903
|
+
if (!words.some((word) => wordIn(pool, word))) return 0;
|
|
904
|
+
}
|
|
905
|
+
/** 只重合了「这个」「怎么」这类词,不算命中;触发词命中仍算。 */
|
|
906
|
+
if (substantive === 0 && !triggerHit && words.length === 0) return 0;
|
|
907
|
+
return score;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** 项目记忆总开关:读当前层,缺字段按开启。 */
|
|
911
|
+
export function controlStatus(scope) {
|
|
307
912
|
const index = loadIndex(scope);
|
|
308
|
-
|
|
309
|
-
if (index.entries.some((item) => item.title === text)) return { ok: false, reason: '这条已经存过' };
|
|
310
|
-
const now = Date.now();
|
|
311
|
-
const recent = index.suggestions.find((item) => now - Date.parse(item.at) < LIMITS.suggestionWindowMs);
|
|
312
|
-
if (recent) return { ok: false, reason: `48 小时内已建议过「${recent.title}」` };
|
|
313
|
-
return { ok: true };
|
|
913
|
+
return { ...(index.__control || state.controlOf(null)) };
|
|
314
914
|
}
|
|
315
915
|
|
|
316
|
-
export function
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
916
|
+
export function setMemoryEnabled(scope, enabled, options = {}) {
|
|
917
|
+
return withLock(scope, () => {
|
|
918
|
+
const before = loadIndex(scope);
|
|
919
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
920
|
+
let applied = null;
|
|
921
|
+
try {
|
|
922
|
+
state.transact(scope, {
|
|
923
|
+
operationId,
|
|
924
|
+
expectCreate: !before.__exists,
|
|
925
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
926
|
+
}, (draft) => {
|
|
927
|
+
applied = state.applyControl(draft, enabled);
|
|
928
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
929
|
+
});
|
|
930
|
+
} catch (error) {
|
|
931
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
932
|
+
return { ok: false, reason: error instanceof Error ? error.message : '记忆开关没有改动' };
|
|
933
|
+
}
|
|
934
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...controlStatus(scope) };
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* 项目实验开关:权威状态里缺字段即关闭。状态只回可核验事实,不编学习分数。
|
|
940
|
+
* 关闭时保留已完成成果;试用条目暂停,等新的有效反馈或明确管理动作再复核。
|
|
941
|
+
*/
|
|
942
|
+
export function evolutionStatus(scope) {
|
|
326
943
|
const index = loadIndex(scope);
|
|
327
|
-
index.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
944
|
+
const config = index.__evolution || { enabled: false, epoch: '', policyVersion: 0, feedbackGeneration: 0, compatible: true };
|
|
945
|
+
const counts = { active: 0, trial: 0, paused: 0 };
|
|
946
|
+
for (const record of index.entries) {
|
|
947
|
+
if (!isExperimentalRecord(record)) continue;
|
|
948
|
+
const phase = record.learning && typeof record.learning === 'object' ? record.learning.state : '';
|
|
949
|
+
if (phase === 'active' || phase === 'trial' || phase === 'paused') counts[phase] += 1;
|
|
950
|
+
}
|
|
951
|
+
const control = index.__control || state.controlOf(null);
|
|
952
|
+
return {
|
|
953
|
+
...config,
|
|
954
|
+
/** 模型可见的 enabled 是有效值:总开关关掉时,自进化一律不运行。 */
|
|
955
|
+
enabled: config.enabled === true && control.enabled === true,
|
|
956
|
+
configuredEnabled: config.enabled === true,
|
|
957
|
+
memoryEnabled: control.enabled === true,
|
|
958
|
+
ownerProject: config.ownerProject || '',
|
|
959
|
+
counts,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
export function setEvolution(scope, enabled, options = {}) {
|
|
964
|
+
return withLock(scope, () => {
|
|
965
|
+
const before = loadIndex(scope);
|
|
966
|
+
const config = before.__evolution || {};
|
|
967
|
+
/** 总开关关掉后,自进化开关也不能改:先恢复本项目记忆。 */
|
|
968
|
+
if (!(before.__control || state.controlOf(null)).enabled) {
|
|
969
|
+
return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
970
|
+
}
|
|
971
|
+
if (config.compatible === false) {
|
|
972
|
+
return { ok: false, reason: '实验配置版本不认,先不要改动开关', compatible: false };
|
|
973
|
+
}
|
|
974
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
975
|
+
let applied = null;
|
|
976
|
+
try {
|
|
977
|
+
state.transact(scope, {
|
|
978
|
+
operationId,
|
|
979
|
+
expectCreate: !before.__exists,
|
|
980
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
981
|
+
}, (draft) => {
|
|
982
|
+
applied = state.applyEvolution(draft, enabled);
|
|
983
|
+
return applied.changed ? { changed: true } : { changed: false };
|
|
984
|
+
});
|
|
985
|
+
} catch (error) {
|
|
986
|
+
const reason = error instanceof Error ? error.message : '开关没有改动';
|
|
987
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
988
|
+
return { ok: false, reason };
|
|
989
|
+
}
|
|
990
|
+
const after = evolutionStatus(scope);
|
|
991
|
+
return { ok: true, changed: Boolean(applied && applied.changed), ...after };
|
|
992
|
+
});
|
|
332
993
|
}
|
|
333
994
|
|
|
334
|
-
|
|
335
|
-
|
|
995
|
+
|
|
996
|
+
/** 任务锚点只从实验状态里进出:实验关闭时不给写,避免留下无法解释的焦点。 */
|
|
997
|
+
export function taskState(scope) {
|
|
336
998
|
const index = loadIndex(scope);
|
|
337
|
-
|
|
338
|
-
const item = (index.suggestions || []).find((entry) => entry.title === text && entry.decision === 'asked');
|
|
339
|
-
if (!item) return null;
|
|
340
|
-
return item.draft ?? { title: text };
|
|
999
|
+
return anchor.normalizeTasks(index.__tasks);
|
|
341
1000
|
}
|
|
342
1001
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
1002
|
+
export function saveTaskState(scope, tasks, options = {}) {
|
|
1003
|
+
return withLock(scope, () => {
|
|
1004
|
+
const before = loadIndex(scope);
|
|
1005
|
+
if (!(before.__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
1006
|
+
if (!before.__evolution.enabled) return { ok: false, reason: '实验还没开启' };
|
|
1007
|
+
const next = anchor.normalizeTasks(tasks);
|
|
1008
|
+
const operationId = typeof options.operationId === 'string' ? options.operationId : '';
|
|
1009
|
+
let changed = false;
|
|
1010
|
+
try {
|
|
1011
|
+
const result = state.transact(scope, {
|
|
1012
|
+
operationId,
|
|
1013
|
+
expected: before.__exists ? { generation: before.generation, epoch: before.epoch } : null,
|
|
1014
|
+
expectCreate: !before.__exists,
|
|
1015
|
+
}, (draft) => {
|
|
1016
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
1017
|
+
if (!evolution || evolution.enabled !== true) return { changed: false };
|
|
1018
|
+
changed = JSON.stringify(evolution.tasks || {}) !== JSON.stringify(next);
|
|
1019
|
+
if (changed) evolution.tasks = JSON.parse(JSON.stringify(next));
|
|
1020
|
+
return changed ? { changed: true } : { changed: false };
|
|
1021
|
+
});
|
|
1022
|
+
return { ok: true, changed, tasks: next, replayed: Boolean(result && result.replayed) };
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
1025
|
+
return { ok: false, reason: error instanceof Error ? error.message : '任务锚点没有保存' };
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
export function setTaskDefault(scope, defaults, options = {}) {
|
|
1031
|
+
const before = taskState(scope);
|
|
1032
|
+
return saveTaskState(scope, anchor.setDefault(before, { ...defaults, now: options.now }), options);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* 按需检索:项目层 + 全局层,最多 limit 条。
|
|
1037
|
+
* 空格/标点分开的查询按关键词处理:一段都没过半命中就不进结果,避免只沾了个常用词也挤进来。
|
|
1038
|
+
* 返回的记录带着「本次检索之前」的 lastUsed,给 renderIndex 判断陈旧用;
|
|
1039
|
+
* 命中计数在返回前写入,所以同一次调用既改盘又不污染展示。
|
|
1040
|
+
*/
|
|
1041
|
+
export function search(scope, query, limit = 4, options = {}) {
|
|
1042
|
+
return withLocks([scope, globalScope()], () => {
|
|
1043
|
+
const grams = bigrams(query);
|
|
1044
|
+
if (grams.size === 0) return [];
|
|
1045
|
+
const runs = queryRuns(query);
|
|
1046
|
+
const keywordMode = runs.length >= 2;
|
|
1047
|
+
const rows = [];
|
|
1048
|
+
const layers = { project: [], global: [] };
|
|
1049
|
+
for (const source of [scope, globalScope()]) {
|
|
1050
|
+
const index = loadIndex(source);
|
|
1051
|
+
const layer = source.key === 'global' ? 'global' : 'project';
|
|
1052
|
+
const owned = index.entries.filter((item) => readableRecord(item, options, index));
|
|
1053
|
+
layers[layer].push(...owned);
|
|
1054
|
+
const collect = (pool, archived) => {
|
|
1055
|
+
for (const item of pool) {
|
|
1056
|
+
if (!readableRecord(item, options, index)) continue;
|
|
1057
|
+
/** 已暂停、已过期或待确认的学习规则不进入检索结果,除非明确要病史。 */
|
|
1058
|
+
if (options.allowExperimental === true && options.history !== true
|
|
1059
|
+
&& isExperimentalRecord(item) && !view.experimentalUsable(item, options.now)) continue;
|
|
1060
|
+
const score = scoreQuery(item, grams, query);
|
|
1061
|
+
if (score <= 0) continue;
|
|
1062
|
+
if (keywordMode && !matchesRun(item, runs)) continue;
|
|
1063
|
+
rows.push({ source, index, item, score, archived });
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
collect(index.entries, false);
|
|
1067
|
+
/** 正常检索只看当前有效的;归档的旧记录只在明确要求病史时列出。 */
|
|
1068
|
+
if (options.history === true) collect(index.archived, true);
|
|
1069
|
+
}
|
|
1070
|
+
/** 被项目层压过的全局条目不再作为检索结果出现;冲突/条件未知的仍保留,便于人工处理。 */
|
|
1071
|
+
const resolved = view.resolveTopics(layers.project, layers.global, options.context || {});
|
|
1072
|
+
const shadowed = (item) => {
|
|
1073
|
+
const group = resolved.get(view.topicSlot(item));
|
|
1074
|
+
return Boolean(group && group.status === 'ok' && group.record && group.record.id !== item.id);
|
|
1075
|
+
};
|
|
1076
|
+
const visible = rows.filter((row) => row.archived || !shadowed(row.item));
|
|
1077
|
+
visible.sort((a, b) => b.score - a.score
|
|
1078
|
+
|| Number(a.archived) - Number(b.archived)
|
|
1079
|
+
|| String(b.item.lastUsed).localeCompare(String(a.item.lastUsed)));
|
|
1080
|
+
const chosen = visible.slice(0, limit);
|
|
1081
|
+
/** 检索不改盘:hits/lastUsed 只在用户真的处理过结果时才算数。 */
|
|
1082
|
+
return chosen.map((row) => ({
|
|
1083
|
+
...row.item,
|
|
1084
|
+
scope: row.source.key === 'global' ? 'global' : 'project',
|
|
1085
|
+
status: row.archived ? 'archived' : view.statusOf(row.item, options.context || {}),
|
|
1086
|
+
archived: Boolean(row.archived),
|
|
1087
|
+
}));
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* 只读命中:给自动提示用。不改 hits / lastUsed,不落盘。
|
|
1093
|
+
* 标题(含旧标题)必须真跟查询有交集,否则长句里零星重合也会冒出提示。
|
|
1094
|
+
*/
|
|
1095
|
+
export function peek(scope, query, limit = 3, options = {}) {
|
|
1096
|
+
const grams = bigrams(query);
|
|
1097
|
+
if (grams.size === 0) return [];
|
|
1098
|
+
const rows = [];
|
|
1099
|
+
for (const source of [scope, globalScope()]) {
|
|
1100
|
+
let index;
|
|
1101
|
+
try {
|
|
1102
|
+
index = loadIndex(source);
|
|
1103
|
+
} catch {
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
for (const item of index.entries) {
|
|
1107
|
+
if (!item || !item.title) continue;
|
|
1108
|
+
const pool = bigrams(`${item.title} ${(item.aliases || []).join(' ')}`);
|
|
1109
|
+
let shared = 0;
|
|
1110
|
+
for (const gram of grams) if (pool.has(gram)) shared += 1;
|
|
1111
|
+
if (shared === 0) continue;
|
|
1112
|
+
if (options.requireApplicable !== false && !view.canAutoRecall(item, options.context || {})) continue;
|
|
1113
|
+
const score = scoreQuery(item, grams, query);
|
|
1114
|
+
if (score < 6) continue;
|
|
1115
|
+
rows.push({ item, source, score });
|
|
1116
|
+
}
|
|
357
1117
|
}
|
|
358
|
-
|
|
1118
|
+
rows.sort((a, b) => b.score - a.score);
|
|
1119
|
+
return rows.slice(0, limit).map(({ item, source }) => ({
|
|
1120
|
+
id: item.id,
|
|
1121
|
+
title: item.title,
|
|
1122
|
+
summary: item.summary || '',
|
|
1123
|
+
scope: source.key === 'global' ? 'global' : 'project',
|
|
1124
|
+
status: 'ok',
|
|
1125
|
+
}));
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
export function readEntry(scope, id, options = {}) {
|
|
1129
|
+
return withLocks([scope, globalScope()], () => {
|
|
1130
|
+
for (const source of [scope, globalScope()]) {
|
|
1131
|
+
const index = loadIndex(source);
|
|
1132
|
+
const record = index.entries.find((item) => item.id === id);
|
|
1133
|
+
const archived = record ? null : index.archived.find((item) => item.id === id);
|
|
1134
|
+
if (!record && !archived) continue;
|
|
1135
|
+
if (!readableRecord(record || archived, options, index)) continue;
|
|
1136
|
+
const body = readBody(source, id);
|
|
1137
|
+
return {
|
|
1138
|
+
...(record || archived),
|
|
1139
|
+
scope: source.key === 'global' ? 'global' : 'project',
|
|
1140
|
+
body,
|
|
1141
|
+
status: archived ? 'archived' : view.statusOf(record, options.context || {}),
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
return null;
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* 忘掉一条:archive 或 purge 是两种不同承诺,没点明就不动盘,交给界面问一次。
|
|
1150
|
+
* purge 走 state 的屏障事务:锁内落 intent、旋转 epoch、清受管副本,完成才回成功。
|
|
1151
|
+
*/
|
|
1152
|
+
export function forget(scope, id, options = {}) {
|
|
1153
|
+
const mode = options.mode;
|
|
1154
|
+
if (mode !== 'archive' && mode !== 'purge') return { id, mode: 'needs_resolution' };
|
|
1155
|
+
return withLocks([scope, globalScope()], () => {
|
|
1156
|
+
/** 屏障优先:上次彻底删除没做完时,先按原层 + 原 operationId 收尾,再决定这条在哪。 */
|
|
1157
|
+
let replayed = null;
|
|
1158
|
+
if (mode === 'purge') {
|
|
1159
|
+
for (const source of [scope, globalScope()]) {
|
|
1160
|
+
const pending = state.purgeIntent(source);
|
|
1161
|
+
if (!pending) continue;
|
|
1162
|
+
const result = state.purge(source, { resume: true });
|
|
1163
|
+
removeBody(source, id);
|
|
1164
|
+
if (pending.ids.includes(id)) {
|
|
1165
|
+
replayed = {
|
|
1166
|
+
id,
|
|
1167
|
+
mode: 'purged',
|
|
1168
|
+
operationId: result.operationId,
|
|
1169
|
+
epoch: result.epoch,
|
|
1170
|
+
generation: result.generation,
|
|
1171
|
+
scopeKey: source.key,
|
|
1172
|
+
replay: true,
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (replayed) return replayed;
|
|
1177
|
+
}
|
|
1178
|
+
for (const source of [scope, globalScope()]) {
|
|
1179
|
+
const index = loadIndex(source);
|
|
1180
|
+
const active = index.entries.find((item) => item.id === id);
|
|
1181
|
+
const archived = active ? null : index.archived.find((item) => item.id === id);
|
|
1182
|
+
const record = active || archived;
|
|
1183
|
+
if (!record) continue;
|
|
1184
|
+
if (mode === 'archive') {
|
|
1185
|
+
if (archived) return { id: record.id, title: record.title, mode: 'archive', already: true, scopeKey: source.key };
|
|
1186
|
+
archiveEntry(source, index, record);
|
|
1187
|
+
/** 「别记」也要挡住以后的自动建议,不能过几天又从同一个来源冒出来。 */
|
|
1188
|
+
addSuppression(index, {
|
|
1189
|
+
fingerprint: state.fingerprintOf({ title: record.title, when: record.when }),
|
|
1190
|
+
sourceIds: [],
|
|
1191
|
+
reason: 'forgotten_source',
|
|
1192
|
+
at: new Date().toISOString(),
|
|
1193
|
+
expiresAt: null,
|
|
1194
|
+
});
|
|
1195
|
+
saveIndex(source, index);
|
|
1196
|
+
return { id: record.id, title: record.title, mode: 'archive', scopeKey: source.key };
|
|
1197
|
+
}
|
|
1198
|
+
if (archived) {
|
|
1199
|
+
/** 归档条目也能彻底删除:state.purge 本来就按 id 清整份状态,只是以前没走到这里。 */
|
|
1200
|
+
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1201
|
+
removeBody(source, id);
|
|
1202
|
+
return {
|
|
1203
|
+
id,
|
|
1204
|
+
mode: 'purged',
|
|
1205
|
+
operationId: result.operationId,
|
|
1206
|
+
epoch: result.epoch,
|
|
1207
|
+
generation: result.generation,
|
|
1208
|
+
scopeKey: source.key,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
const result = state.purge(source, { ids: [id], operationId: options.operationId });
|
|
1212
|
+
removeBody(source, id);
|
|
1213
|
+
return {
|
|
1214
|
+
id,
|
|
1215
|
+
mode: 'purged',
|
|
1216
|
+
operationId: result.operationId,
|
|
1217
|
+
epoch: result.epoch,
|
|
1218
|
+
generation: result.generation,
|
|
1219
|
+
scopeKey: source.key,
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
return null;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** 直接读邻居:只给当前投影,不缓存目标正文;目标不在了就标 invalid,不拿旧版本顶上。 */
|
|
1227
|
+
export function readLinks(scope, id, options = {}) {
|
|
1228
|
+
return withLocks([scope, globalScope()], () => {
|
|
1229
|
+
for (const source of [scope, globalScope()]) {
|
|
1230
|
+
const index = loadIndex(source);
|
|
1231
|
+
const record = index.entries.find((item) => item.id === id);
|
|
1232
|
+
if (!record) continue;
|
|
1233
|
+
if (!readableRecord(record, options, index)) continue;
|
|
1234
|
+
const links = [];
|
|
1235
|
+
for (const link of view.linksOf(record)) {
|
|
1236
|
+
const target = findCurrent(link.id, scope, options);
|
|
1237
|
+
if (target) links.push({ type: link.type || 'related', ...target });
|
|
1238
|
+
else links.push({ type: link.type || 'related', id: link.id, status: 'invalid' });
|
|
1239
|
+
}
|
|
1240
|
+
return { id, links, scope: source.key === 'global' ? 'global' : 'project', status: view.statusOf(record, options.context || {}) };
|
|
1241
|
+
}
|
|
1242
|
+
return null;
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/** 关联目标按当前投影现场解引用:先项目层再全局层,归档和删除都算不存在。 */
|
|
1247
|
+
function findCurrent(id, scope, options = {}) {
|
|
1248
|
+
for (const source of [scope, globalScope()]) {
|
|
1249
|
+
let index;
|
|
1250
|
+
try {
|
|
1251
|
+
index = loadIndex(source);
|
|
1252
|
+
} catch {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const record = index.entries.find((item) => item.id === id && item.status !== 'archived');
|
|
1256
|
+
if (!record) continue;
|
|
1257
|
+
if (!readableRecord(record, options, index)) continue;
|
|
1258
|
+
return {
|
|
1259
|
+
id: record.id,
|
|
1260
|
+
title: record.title,
|
|
1261
|
+
summary: record.summary || '',
|
|
1262
|
+
scope: source.key === 'global' ? 'global' : 'project',
|
|
1263
|
+
revision: record.revision || 1,
|
|
1264
|
+
status: view.statusOf(record, {}),
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
return null;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
/** 撤销一次修改:有旧版本就整条还原成新 revision;没有旧版本(新建的)就归档。 */
|
|
1272
|
+
export function undoChange(scope, id, options = {}) {
|
|
1273
|
+
return withLocks([scope, globalScope()], () => {
|
|
1274
|
+
for (const source of [scope, globalScope()]) {
|
|
1275
|
+
const index = loadIndex(source);
|
|
1276
|
+
const record = index.entries.find((item) => item.id === id);
|
|
1277
|
+
if (!record) continue;
|
|
1278
|
+
/** 旧回执不能撤掉别人后来的修改:版本对不上就明确拒绝。 */
|
|
1279
|
+
if (options.expectedRevision !== undefined && Number(record.revision) !== Number(options.expectedRevision)) {
|
|
1280
|
+
throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
|
|
1281
|
+
}
|
|
1282
|
+
const versions = record.versions || [];
|
|
1283
|
+
const last = versions[versions.length - 1];
|
|
1284
|
+
if (!last) {
|
|
1285
|
+
archiveEntry(source, index, record);
|
|
1286
|
+
saveIndex(source, index);
|
|
1287
|
+
return { id: record.id, title: record.title, mode: 'archived' };
|
|
1288
|
+
}
|
|
1289
|
+
const currentBody = readBody(source, record.id);
|
|
1290
|
+
const restored = {
|
|
1291
|
+
...record,
|
|
1292
|
+
title: last.title || record.title,
|
|
1293
|
+
summary: last.summary || record.summary,
|
|
1294
|
+
when: last.when || '',
|
|
1295
|
+
kind: last.kind || record.kind,
|
|
1296
|
+
tags: Array.isArray(last.tags) ? last.tags : record.tags,
|
|
1297
|
+
triggers: Array.isArray(last.triggers) ? last.triggers : record.triggers || [],
|
|
1298
|
+
applicability: last.applicability || record.applicability || 'all_in_scope',
|
|
1299
|
+
conditions: Array.isArray(last.conditions) ? last.conditions : record.conditions || [],
|
|
1300
|
+
selection: last.selection === undefined ? record.selection || null : last.selection,
|
|
1301
|
+
links: Array.isArray(last.links) ? last.links : record.links || [],
|
|
1302
|
+
topicKey: last.topicKey === undefined ? record.topicKey || '' : last.topicKey,
|
|
1303
|
+
body: last.body,
|
|
1304
|
+
versions: versions.slice(0, -1),
|
|
1305
|
+
revision: (record.revision || 1) + 1,
|
|
1306
|
+
updated: new Date().toISOString(),
|
|
1307
|
+
};
|
|
1308
|
+
writeBody(source, record.id, last.body);
|
|
1309
|
+
Object.assign(record, restored);
|
|
1310
|
+
record.bytes = entrySize(record, last.body);
|
|
1311
|
+
try {
|
|
1312
|
+
saveIndex(source, index);
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
writeBody(source, record.id, currentBody);
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
1317
|
+
return { id: record.id, title: record.title, mode: 'restored' };
|
|
1318
|
+
}
|
|
1319
|
+
return null;
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* 按 id 在所有已知层里找:界面上的引用是历史事实,当前正文可能已经不在原来那层了。
|
|
1325
|
+
* 只读,不做任何写回;找不到就返回 null,调用方据此显示「已失效」。
|
|
1326
|
+
*/
|
|
1327
|
+
export function findAnywhere(id) {
|
|
1328
|
+
if (!/^mem_[a-z0-9]{6,}$/.test(String(id || ''))) return null;
|
|
1329
|
+
let names;
|
|
1330
|
+
try {
|
|
1331
|
+
names = fs.readdirSync(memoryRoot(), { withFileTypes: true });
|
|
1332
|
+
} catch {
|
|
1333
|
+
return null;
|
|
1334
|
+
}
|
|
1335
|
+
for (const dirent of names) {
|
|
1336
|
+
if (!dirent.isDirectory()) continue;
|
|
1337
|
+
const scope = {
|
|
1338
|
+
key: dirent.name,
|
|
1339
|
+
dir: path.join(memoryRoot(), dirent.name),
|
|
1340
|
+
cwd: '',
|
|
1341
|
+
requestedCwd: '',
|
|
1342
|
+
rootSource: 'scan',
|
|
1343
|
+
confirmed: true,
|
|
1344
|
+
};
|
|
1345
|
+
if (!fs.existsSync(path.join(scope.dir, 'state.json'))) continue;
|
|
1346
|
+
let index;
|
|
1347
|
+
try {
|
|
1348
|
+
index = loadIndex(scope);
|
|
1349
|
+
} catch {
|
|
1350
|
+
continue;
|
|
1351
|
+
}
|
|
1352
|
+
const record = index.entries.find((item) => item.id === id);
|
|
1353
|
+
const archived = record ? null : index.archived.find((item) => item.id === id);
|
|
1354
|
+
if (!record && !archived) continue;
|
|
1355
|
+
let body = '';
|
|
1356
|
+
try {
|
|
1357
|
+
body = readBody(scope, id) || '';
|
|
1358
|
+
} catch {
|
|
1359
|
+
body = '';
|
|
1360
|
+
}
|
|
1361
|
+
const current = record || archived;
|
|
1362
|
+
return {
|
|
1363
|
+
id,
|
|
1364
|
+
title: current.title || '',
|
|
1365
|
+
when: current.when || '',
|
|
1366
|
+
scope: scope.key === 'global' ? 'global' : 'project',
|
|
1367
|
+
scopeKey: scope.key,
|
|
1368
|
+
scopeLabel: scope.key === 'global' ? '全局' : path.basename(index.cwd || '') || scope.key,
|
|
1369
|
+
revision: current.revision || 1,
|
|
1370
|
+
status: archived ? 'archived' : 'ok',
|
|
1371
|
+
body,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
|
|
1378
|
+
export function locate(scope, id, options = {}) {
|
|
1379
|
+
for (const source of [scope, globalScope()]) {
|
|
1380
|
+
const index = loadIndex(source, options);
|
|
1381
|
+
const active = index.entries.find((item) => item.id === id);
|
|
1382
|
+
if (active) return { scope: source, record: active, archived: false };
|
|
1383
|
+
const archived = index.archived.find((item) => item.id === id);
|
|
1384
|
+
if (archived) return { scope: source, record: archived, archived: true };
|
|
1385
|
+
}
|
|
1386
|
+
return null;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function forgottenFingerprints(index) {
|
|
1390
|
+
const out = new Set();
|
|
1391
|
+
for (const row of index.suppressions || []) {
|
|
1392
|
+
if (row && row.reason === 'forgotten_source' && typeof row.fingerprint === 'string') out.add(row.fingerprint);
|
|
1393
|
+
}
|
|
1394
|
+
return out;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** 过期候选清掉;被明确忘掉的来源自动学回来的候选也清掉,不再出现在保留队列。 */
|
|
1398
|
+
function pruneSuggestions(index) {
|
|
1399
|
+
const now = Date.now();
|
|
1400
|
+
const forgotten = forgottenFingerprints(index);
|
|
1401
|
+
index.suggestions = (index.suggestions || []).filter((item) => {
|
|
1402
|
+
const expires = Date.parse(item.expiresAt || '');
|
|
1403
|
+
if (!Number.isFinite(expires) || expires <= now) return false;
|
|
1404
|
+
const draft = item.draft || {};
|
|
1405
|
+
const fingerprint = state.fingerprintOf({ title: draft.title || item.title, when: draft.when || item.when });
|
|
1406
|
+
return !forgotten.has(fingerprint);
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** 抑制规则:忘掉的来源永久挡,候选被拒只挡一段时间。只存指纹,不存原文。 */
|
|
1411
|
+
function addSuppression(index, { fingerprint, sourceIds = [], reason, at, expiresAt }) {
|
|
1412
|
+
if (!fingerprint) return;
|
|
1413
|
+
const now = Date.now();
|
|
1414
|
+
const rows = (index.suppressions || []).filter((row) => {
|
|
1415
|
+
if (row.fingerprint === fingerprint && row.reason === reason) return false;
|
|
1416
|
+
if (!row || row.reason === 'forgotten_source') return true;
|
|
1417
|
+
const expires = Date.parse(row.expiresAt || '');
|
|
1418
|
+
return Number.isFinite(expires) && expires > now;
|
|
1419
|
+
});
|
|
1420
|
+
rows.push({ fingerprint, sourceIds: [...sourceIds].filter((id) => typeof id === 'string'), reason, at, expiresAt });
|
|
1421
|
+
index.suppressions = rows;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** 命中哪种抑制:永久忘掉 / 一段时间内不重复建议;过期的不算。 */
|
|
1425
|
+
function suppressionOf(index, fingerprint) {
|
|
1426
|
+
for (const row of index.suppressions || []) {
|
|
1427
|
+
if (!row || row.fingerprint !== fingerprint) continue;
|
|
1428
|
+
if (row.reason === 'forgotten_source') return 'forgotten_source';
|
|
1429
|
+
const expires = Date.parse(row.expiresAt || '');
|
|
1430
|
+
if (Number.isFinite(expires) && expires > Date.now()) return 'candidate_rejected';
|
|
1431
|
+
}
|
|
1432
|
+
return '';
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function pruneReceipts(index) {
|
|
1436
|
+
const now = Date.now();
|
|
1437
|
+
index.receipts = (index.receipts || []).filter((item) => {
|
|
1438
|
+
const at = Date.parse(item.at || '');
|
|
1439
|
+
return Number.isFinite(at) && now - at < LIMITS.receiptTtlMs;
|
|
1440
|
+
}).slice(-LIMITS.receipts);
|
|
1441
|
+
return index.receipts;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* 建议门限:已有同一条就不再建议;相近的另一条可以建议,但必须带着 targetId
|
|
1446
|
+
* 走候选确认,不能直接改正式条目。
|
|
1447
|
+
*/
|
|
1448
|
+
export function suggestionGate(scope, source) {
|
|
1449
|
+
if (scope && scope.confirmed === false) {
|
|
1450
|
+
return { ok: false, reason: '当前目录还不能确认属于哪个项目,先确认项目根目录再建议' };
|
|
1451
|
+
}
|
|
1452
|
+
return withLock(scope, () => {
|
|
1453
|
+
const index = loadIndex(scope);
|
|
1454
|
+
pruneSuggestions(index);
|
|
1455
|
+
const title = normalizeText(source?.title, LIMITS.titleChars);
|
|
1456
|
+
if (!title) return { ok: false, reason: '没有标题' };
|
|
1457
|
+
const summary = normalizeText(source?.summary, LIMITS.summaryChars);
|
|
1458
|
+
const when = normalizeText(source?.when, LIMITS.whenChars);
|
|
1459
|
+
const blocked = suppressionOf(index, state.fingerprintOf({ title, when }));
|
|
1460
|
+
if (blocked === 'forgotten_source') {
|
|
1461
|
+
return { ok: false, reason: '这个来源你已经明确忘掉了,不再自动建议' };
|
|
1462
|
+
}
|
|
1463
|
+
if (blocked === 'candidate_rejected') {
|
|
1464
|
+
return { ok: false, reason: '这个来源刚被拒绝过,过几天再说' };
|
|
1465
|
+
}
|
|
1466
|
+
const hit = resolveTarget(index.entries, { title, summary, when });
|
|
1467
|
+
if (hit) return { ok: false, reason: `已有「${hit.target.title}」,这次不重复建议` };
|
|
1468
|
+
const wanted = { title, summary, when };
|
|
1469
|
+
for (const key of ['triggers', 'conditions', 'selection', 'applicability']) {
|
|
1470
|
+
if (source && source[key] !== undefined) wanted[key] = source[key];
|
|
1471
|
+
if (source && source.has && typeof source.has === 'object') wanted.has = source.has;
|
|
1472
|
+
}
|
|
1473
|
+
const near = similarTarget(index.entries, wanted);
|
|
1474
|
+
if (near) return { ok: true, target: { id: near.id, title: near.title, revision: near.revision || 1 } };
|
|
1475
|
+
const last = Date.parse(index.lastSuggestedAt || '');
|
|
1476
|
+
if (Number.isFinite(last) && Date.now() - last < LIMITS.suggestionWindowMs) {
|
|
1477
|
+
return { ok: false, reason: '48 小时内已经建议过一条' };
|
|
1478
|
+
}
|
|
1479
|
+
return { ok: true };
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* 待确认草稿与正式条目走同一个校验器:凭据、大小、必填都在这里拦住。
|
|
1485
|
+
* 草稿在确认前就落盘(本地待确认区),所以撤回/过期都要能查到。
|
|
1486
|
+
*/
|
|
1487
|
+
export function noteSuggestion(scope, source, decision = 'asked') {
|
|
1488
|
+
return withLock(scope, () => {
|
|
1489
|
+
const draft = normalizeDraft(source, { requireSummary: false });
|
|
1490
|
+
const index = loadIndex(scope);
|
|
1491
|
+
pruneSuggestions(index);
|
|
1492
|
+
const at = new Date();
|
|
1493
|
+
const targetId = typeof source?.targetId === 'string' ? source.targetId : '';
|
|
1494
|
+
const target = targetId ? index.entries.find((item) => item.id === targetId) : null;
|
|
1495
|
+
if (targetId && !target) throw new Error('候选指向的条目不存在');
|
|
1496
|
+
const record = {
|
|
1497
|
+
id: `sug_${crypto.randomBytes(4).toString('hex')}`,
|
|
1498
|
+
title: draft.title,
|
|
1499
|
+
at: at.toISOString(),
|
|
1500
|
+
expiresAt: new Date(at.getTime() + LIMITS.suggestionTtlMs).toISOString(),
|
|
1501
|
+
decision,
|
|
1502
|
+
draft: {
|
|
1503
|
+
title: draft.title,
|
|
1504
|
+
summary: draft.summary,
|
|
1505
|
+
when: draft.when,
|
|
1506
|
+
body: draft.body,
|
|
1507
|
+
kind: draft.kind,
|
|
1508
|
+
tags: draft.tags,
|
|
1509
|
+
/** 语义字段整份带走:确认前后不能因为过了一道待确认区就换一套适用范围。 */
|
|
1510
|
+
...(draft.triggers !== undefined ? { triggers: draft.triggers } : {}),
|
|
1511
|
+
...(draft.conditions !== undefined ? { conditions: draft.conditions } : {}),
|
|
1512
|
+
...(draft.selection !== undefined ? { selection: draft.selection } : {}),
|
|
1513
|
+
...(draft.links !== undefined ? { links: draft.links } : {}),
|
|
1514
|
+
...(draft.applicability !== undefined ? { applicability: draft.applicability } : {}),
|
|
1515
|
+
...(draft.topicKey !== undefined ? { topicKey: draft.topicKey } : {}),
|
|
1516
|
+
/** 字段「有」和「空」不是一回事:确认时只写真正给过的字段。 */
|
|
1517
|
+
has: { ...draft.has },
|
|
1518
|
+
targetId: target ? target.id : '',
|
|
1519
|
+
targetRevision: target ? target.revision || 1 : 0,
|
|
1520
|
+
},
|
|
1521
|
+
};
|
|
1522
|
+
const next = [...(index.suggestions || []), record];
|
|
1523
|
+
const bytes = next.reduce((sum, item) => sum + Buffer.byteLength(JSON.stringify(item.draft || {})), 0);
|
|
1524
|
+
if (bytes > LIMITS.pendingBytes || next.length > LIMITS.pending) {
|
|
1525
|
+
throw new Error('待确认的候选已满,先处理旧的');
|
|
1526
|
+
}
|
|
1527
|
+
index.suggestions = next;
|
|
1528
|
+
index.lastSuggestedAt = at.toISOString();
|
|
1529
|
+
saveIndex(scope, index);
|
|
1530
|
+
return {
|
|
1531
|
+
id: record.id,
|
|
1532
|
+
title: draft.title,
|
|
1533
|
+
expiresAt: record.expiresAt,
|
|
1534
|
+
targetId: record.draft.targetId,
|
|
1535
|
+
targetTitle: target ? target.title : '',
|
|
1536
|
+
};
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** 待保留的那条建议:按 id 取,过期/已存下的不再返回。 */
|
|
1541
|
+
export function findSuggestion(scope, key) {
|
|
1542
|
+
const index = loadIndex(scope);
|
|
1543
|
+
pruneSuggestions(index);
|
|
1544
|
+
const item = (index.suggestions || []).find((entry) => entry.id === key && entry.decision === 'asked');
|
|
1545
|
+
if (!item) return null;
|
|
1546
|
+
return { ...item.draft, suggestionId: item.id };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* 旧候选或损坏候选的字段自检:has 说给了某个结构化字段,值就必须是合法形状。
|
|
1551
|
+
* 不猜、不补默认值;不合格的候选直接失效,要求重新建立。
|
|
1552
|
+
*/
|
|
1553
|
+
export function candidateProblem(draft) {
|
|
1554
|
+
const has = draft && draft.has && typeof draft.has === 'object' ? draft.has : null;
|
|
1555
|
+
if (!has) return '';
|
|
1556
|
+
if (has.conditions && !Array.isArray(draft.conditions)) return '候选的条件缺失';
|
|
1557
|
+
if (has.selection && draft.selection !== null && (typeof draft.selection !== 'object' || Array.isArray(draft.selection))) return '候选的产物范围缺失';
|
|
1558
|
+
if (has.links && !Array.isArray(draft.links)) return '候选的关联缺失';
|
|
1559
|
+
if (has.triggers && !Array.isArray(draft.triggers)) return '候选的触发词缺失';
|
|
1560
|
+
if (has.applicability && view.normalizeApplicability(draft.applicability) !== draft.applicability) return '候选的适用范围不合法';
|
|
1561
|
+
if (has.topicKey && typeof draft.topicKey !== 'string') return '候选的主题缺失';
|
|
1562
|
+
return '';
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
/**
|
|
1566
|
+
* 点「保留」:在同一个锁窗口里按 candidateId 确认、消费候选、写回执。
|
|
1567
|
+
* 已消费的候选不再写第二条;旧的 keep 重放返回原回执。
|
|
1568
|
+
*/
|
|
1569
|
+
export function acceptCandidate(scope, key, operationId = '') {
|
|
1570
|
+
return withLock(scope, () => {
|
|
1571
|
+
const index = loadIndex(scope);
|
|
1572
|
+
pruneSuggestions(index);
|
|
1573
|
+
pruneReceipts(index);
|
|
1574
|
+
const replay = (index.receipts || []).find((item) => (operationId && item.operationId === operationId)
|
|
1575
|
+
|| (!operationId && item.candidateId === key && item.status === 'accepted'));
|
|
1576
|
+
if (replay) return { ok: true, replay: true, id: replay.entryId || '', title: replay.title || '', reason: '这条候选已经处理过' };
|
|
1577
|
+
const item = (index.suggestions || []).find((entry) => entry.id === key && entry.decision === 'asked');
|
|
1578
|
+
if (!item) return { ok: false, reason: '候选不存在或已过期' };
|
|
1579
|
+
const draft = item.draft || {};
|
|
1580
|
+
const incomplete = candidateProblem(draft);
|
|
1581
|
+
if (incomplete) {
|
|
1582
|
+
index.suggestions = (index.suggestions || []).filter((entry) => entry.id !== item.id);
|
|
1583
|
+
saveIndex(scope, index);
|
|
1584
|
+
return { ok: false, invalid: true, reason: `${incomplete},重新建议一次` };
|
|
1585
|
+
}
|
|
1586
|
+
const target = draft.targetId ? index.entries.find((entry) => entry.id === draft.targetId) : null;
|
|
1587
|
+
if (draft.targetId && !target) return { ok: false, reason: '候选要更新的那条记忆已不在了' };
|
|
1588
|
+
if (target && draft.targetRevision && (target.revision || 1) !== draft.targetRevision) {
|
|
1589
|
+
return { ok: false, reason: '那条记忆在你确认前改过了,重新建议一次' };
|
|
1590
|
+
}
|
|
1591
|
+
/** 只把建议里真有的字段交给写入:省略表示保持,显式空正文清空;旧候选没有 has 时按有没有值回退。 */
|
|
1592
|
+
const source = { title: draft.title };
|
|
1593
|
+
const has = draft.has && typeof draft.has === 'object' ? draft.has : null;
|
|
1594
|
+
if (has) {
|
|
1595
|
+
if (has.summary) source.summary = draft.summary;
|
|
1596
|
+
if (has.when) source.when = draft.when;
|
|
1597
|
+
if (has.kind) source.kind = draft.kind;
|
|
1598
|
+
if (has.tags) source.tags = Array.isArray(draft.tags) ? draft.tags : [];
|
|
1599
|
+
if (has.body) source.body = draft.body;
|
|
1600
|
+
if (has.triggers) source.triggers = draft.triggers;
|
|
1601
|
+
if (has.conditions) source.conditions = draft.conditions;
|
|
1602
|
+
if (has.selection) source.selection = draft.selection;
|
|
1603
|
+
if (has.links) source.links = draft.links;
|
|
1604
|
+
if (has.applicability) source.applicability = draft.applicability;
|
|
1605
|
+
if (has.topicKey) source.topicKey = draft.topicKey;
|
|
1606
|
+
} else {
|
|
1607
|
+
if (draft.summary) source.summary = draft.summary;
|
|
1608
|
+
if (draft.when) source.when = draft.when;
|
|
1609
|
+
if (draft.kind) source.kind = draft.kind;
|
|
1610
|
+
if (Array.isArray(draft.tags)) source.tags = draft.tags;
|
|
1611
|
+
if (draft.body) source.body = draft.body;
|
|
1612
|
+
}
|
|
1613
|
+
if (target) source.id = target.id;
|
|
1614
|
+
const now = new Date().toISOString();
|
|
1615
|
+
try {
|
|
1616
|
+
const result = applyDraft(scope, index, source, now);
|
|
1617
|
+
if (result.needsResolution) return { ok: false, reason: '候选与已有条目相近,需要确认', similar: result.similarList };
|
|
1618
|
+
index.suggestions = (index.suggestions || []).filter((entry) => entry.id !== item.id);
|
|
1619
|
+
index.receipts = [...(index.receipts || []), {
|
|
1620
|
+
operationId: operationId || `op_${crypto.randomBytes(6).toString('hex')}`,
|
|
1621
|
+
candidateId: item.id,
|
|
1622
|
+
entryId: result.record.id,
|
|
1623
|
+
title: result.record.title,
|
|
1624
|
+
status: 'accepted',
|
|
1625
|
+
at: now,
|
|
1626
|
+
}].slice(-LIMITS.receipts);
|
|
1627
|
+
saveIndex(scope, index);
|
|
1628
|
+
return { ok: true, replay: false, id: result.record.id, title: result.record.title, mode: result.merged ? 'updated' : 'created' };
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** 拒绝一个候选:消费草稿,同时按指纹挡一段时间,避免同一句反复冒出来。 */
|
|
1636
|
+
export function rejectCandidate(scope, key) {
|
|
1637
|
+
return withLock(scope, () => {
|
|
1638
|
+
const index = loadIndex(scope);
|
|
1639
|
+
const item = (index.suggestions || []).find((entry) => entry.id === key);
|
|
1640
|
+
if (!item) return { ok: false, reason: '候选不存在或已过期' };
|
|
1641
|
+
const draft = item.draft || {};
|
|
1642
|
+
const at = new Date();
|
|
1643
|
+
addSuppression(index, {
|
|
1644
|
+
fingerprint: state.fingerprintOf({ title: draft.title || item.title, when: draft.when }),
|
|
1645
|
+
sourceIds: Array.isArray(draft.sourceIds) ? draft.sourceIds : [],
|
|
1646
|
+
reason: 'candidate_rejected',
|
|
1647
|
+
at: at.toISOString(),
|
|
1648
|
+
expiresAt: new Date(at.getTime() + LIMITS.suppressionTtlMs).toISOString(),
|
|
1649
|
+
});
|
|
1650
|
+
index.suggestions = (index.suggestions || []).filter((entry) => entry.id !== key);
|
|
1651
|
+
saveIndex(scope, index);
|
|
1652
|
+
return { ok: true };
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/** The compact index handed to the model: one noun-phrase line per entry, hard capped. */
|
|
1657
|
+
export function renderIndex(entries, budget = 400) {
|
|
1658
|
+
const staleMs = 90 * 24 * 60 * 60 * 1000;
|
|
1659
|
+
const now = Date.now();
|
|
1660
|
+
const lines = [];
|
|
1661
|
+
let used = 0;
|
|
1662
|
+
for (const item of entries) {
|
|
1663
|
+
const last = Date.parse(item.lastUsed || item.updated || item.created || '') || now;
|
|
1664
|
+
const stale = now - last > staleMs;
|
|
1665
|
+
const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
|
|
1666
|
+
const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
|
|
1667
|
+
const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
|
|
1668
|
+
const head = `${tag ? `${tag} ` : ''}${item.id} ${item.title}${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
|
|
1669
|
+
const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
|
|
1670
|
+
if (used + line.length > budget) break;
|
|
1671
|
+
lines.push(line);
|
|
1672
|
+
used += line.length + 1;
|
|
1673
|
+
}
|
|
1674
|
+
return { text: lines.join('\n'), chars: used };
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* 会话引用:界面「N 条记忆引用」用的派生缓存,不是记忆本体。
|
|
1679
|
+
* 每个会话一个小文件,坏了就整份丢掉重来;记忆本身不受影响。
|
|
1680
|
+
*/
|
|
1681
|
+
const SESSION_ID = /^[A-Za-z0-9._-]{1,80}$/;
|
|
1682
|
+
|
|
1683
|
+
/** 会话引用文件独立于记忆库状态,简单原子替换就够。 */
|
|
1684
|
+
function writeAtomic(file, text) {
|
|
1685
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1686
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
1687
|
+
fs.writeFileSync(tmp, text);
|
|
1688
|
+
fs.renameSync(tmp, file);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function sessionsDir() {
|
|
1692
|
+
return path.join(memoryRoot(), 'sessions');
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function citationFile(sessionId) {
|
|
1696
|
+
return path.join(sessionsDir(), `${sessionId}.json`);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function readSessionFile(sessionId) {
|
|
1700
|
+
if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return null;
|
|
1701
|
+
try {
|
|
1702
|
+
const data = JSON.parse(fs.readFileSync(citationFile(sessionId), 'utf8'));
|
|
1703
|
+
return data && Array.isArray(data.items) ? data : null;
|
|
1704
|
+
} catch {
|
|
1705
|
+
return null;
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
/** 只留最近用过的若干个会话文件,旧的按上限与时间清掉。 */
|
|
1710
|
+
function pruneSessions(keep) {
|
|
1711
|
+
const dir = sessionsDir();
|
|
1712
|
+
let names;
|
|
1713
|
+
try {
|
|
1714
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith('.json'));
|
|
1715
|
+
} catch {
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
const rows = names.map((name) => {
|
|
1719
|
+
const file = path.join(dir, name);
|
|
1720
|
+
let time = 0;
|
|
1721
|
+
try {
|
|
1722
|
+
time = fs.statSync(file).mtimeMs;
|
|
1723
|
+
} catch {
|
|
1724
|
+
/* gone already */
|
|
1725
|
+
}
|
|
1726
|
+
return { file, name, time };
|
|
1727
|
+
}).sort((a, b) => b.time - a.time);
|
|
1728
|
+
const now = Date.now();
|
|
1729
|
+
rows.forEach((row, index) => {
|
|
1730
|
+
if (row.name === `${keep}.json`) return;
|
|
1731
|
+
if (index < LIMITS.citationSessions && now - row.time <= LIMITS.citationTtlMs) return;
|
|
1732
|
+
try {
|
|
1733
|
+
fs.rmSync(row.file, { force: true });
|
|
1734
|
+
} catch {
|
|
1735
|
+
/* best effort */
|
|
1736
|
+
}
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* 记下「这些条目这次真的被读进了上下文」。同一 id 只留一条,最近的排前面。
|
|
1742
|
+
* 参数里的 scope 是读取时用的项目作用域;正文路径按条目自己的层算。
|
|
1743
|
+
*/
|
|
1744
|
+
export function recordCitations(scope, sessionId, items) {
|
|
1745
|
+
if (typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) return [];
|
|
1746
|
+
const incoming = (Array.isArray(items) ? items : []).filter((item) => item && /^mem_[a-z0-9]{6,}$/.test(String(item.id || '')));
|
|
1747
|
+
const previous = readSessionFile(sessionId);
|
|
1748
|
+
const rows = new Map();
|
|
1749
|
+
const now = new Date().toISOString();
|
|
1750
|
+
for (const item of incoming) {
|
|
1751
|
+
const key = item.scope === 'global' ? 'global' : (typeof item.scopeKey === 'string' && item.scopeKey ? item.scopeKey : scope.key);
|
|
1752
|
+
rows.set(item.id, {
|
|
1753
|
+
id: item.id,
|
|
1754
|
+
scope: key === 'global' ? 'global' : 'project',
|
|
1755
|
+
key,
|
|
1756
|
+
at: now,
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
for (const item of (previous && previous.items) || []) {
|
|
1760
|
+
if (!item || typeof item.id !== 'string' || rows.has(item.id)) continue;
|
|
1761
|
+
rows.set(item.id, item);
|
|
1762
|
+
}
|
|
1763
|
+
let next = [...rows.values()];
|
|
1764
|
+
while (next.length > 1 && Buffer.byteLength(JSON.stringify(next)) > LIMITS.citationBytes) next = next.slice(0, -1);
|
|
1765
|
+
next = next.slice(0, LIMITS.citations);
|
|
1766
|
+
const text = `${JSON.stringify({ sessionId, cwd: scope.cwd, updatedAt: now, items: next })}\n`;
|
|
1767
|
+
try {
|
|
1768
|
+
writeAtomic(citationFile(sessionId), text);
|
|
1769
|
+
} catch {
|
|
1770
|
+
return next;
|
|
1771
|
+
}
|
|
1772
|
+
pruneSessions(sessionId);
|
|
1773
|
+
return next;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const SCOPE_KEY = /^[A-Za-z0-9._-]{1,80}$/;
|
|
1777
|
+
|
|
1778
|
+
/** 引用的身份是「哪个 scope 的哪个 id」;旧文件里只有 path 时从目录名推回来。 */
|
|
1779
|
+
function citationKey(row) {
|
|
1780
|
+
if (!row || typeof row !== 'object') return '';
|
|
1781
|
+
if (typeof row.key === 'string' && SCOPE_KEY.test(row.key)) return row.key;
|
|
1782
|
+
if (typeof row.path !== 'string') return '';
|
|
1783
|
+
const scopeDir = path.dirname(path.dirname(path.resolve(row.path)));
|
|
1784
|
+
if (path.dirname(scopeDir) !== memoryRoot()) return '';
|
|
1785
|
+
const key = path.basename(scopeDir);
|
|
1786
|
+
return SCOPE_KEY.test(key) ? key : '';
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
function scopeForKey(key) {
|
|
1790
|
+
if (key === 'global') return globalScope();
|
|
1791
|
+
if (!SCOPE_KEY.test(key)) return null;
|
|
1792
|
+
const dir = path.join(memoryRoot(), key);
|
|
1793
|
+
if (!path.resolve(dir).startsWith(memoryRoot() + path.sep)) return null;
|
|
1794
|
+
return { key, dir, cwd: '' };
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* 引用只存身份,不存标题和正文:解析时一律读当前索引。
|
|
1799
|
+
* 目标不在了就整行失效,不回退旧缓存,也不按旧名字重建。
|
|
1800
|
+
*/
|
|
1801
|
+
function resolveCitation(row) {
|
|
1802
|
+
const key = citationKey(row);
|
|
1803
|
+
if (!key) return null;
|
|
1804
|
+
const scope = scopeForKey(key);
|
|
1805
|
+
if (!scope) return null;
|
|
1806
|
+
let entry = null;
|
|
1807
|
+
try {
|
|
1808
|
+
const index = loadIndex(scope);
|
|
1809
|
+
scope.cwd = index.cwd || '';
|
|
1810
|
+
entry = (index.entries || []).find((item) => item && item.id === row.id) || null;
|
|
1811
|
+
} catch {
|
|
1812
|
+
return null;
|
|
1813
|
+
}
|
|
1814
|
+
if (!entry) return null;
|
|
1815
|
+
return {
|
|
1816
|
+
key,
|
|
1817
|
+
scope,
|
|
1818
|
+
entry,
|
|
1819
|
+
row: {
|
|
1820
|
+
id: row.id,
|
|
1821
|
+
title: entry.title || '',
|
|
1822
|
+
scope: key === 'global' ? 'global' : 'project',
|
|
1823
|
+
revision: Number(entry.revision) || 1,
|
|
1824
|
+
when: entry.when || '',
|
|
1825
|
+
at: row.at,
|
|
1826
|
+
},
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/** 界面要的那几列:不返回正文,也不返回磁盘路径。 */
|
|
1831
|
+
export function listCitations(sessionId) {
|
|
1832
|
+
const data = readSessionFile(sessionId);
|
|
1833
|
+
const items = ((data && data.items) || []).map(resolveCitation).filter(Boolean).map((item) => item.row);
|
|
1834
|
+
return { items, updatedAt: (data && data.updatedAt) || '' };
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/** 点开一条时读当前正文;目标已删/已移出记忆根目录就返回空。 */
|
|
1838
|
+
export function readCitationBody(sessionId, id) {
|
|
1839
|
+
const data = readSessionFile(sessionId);
|
|
1840
|
+
const row = ((data && data.items) || []).find((item) => item && item.id === id);
|
|
1841
|
+
if (!row) return null;
|
|
1842
|
+
const found = resolveCitation(row);
|
|
1843
|
+
if (!found) return null;
|
|
1844
|
+
let body;
|
|
1845
|
+
try {
|
|
1846
|
+
const current = state.readEntryBody(found.scope, id);
|
|
1847
|
+
if (!current) return null;
|
|
1848
|
+
body = current.body;
|
|
1849
|
+
} catch {
|
|
1850
|
+
return null;
|
|
1851
|
+
}
|
|
1852
|
+
return {
|
|
1853
|
+
...found.row,
|
|
1854
|
+
scopeLabel: found.scope.key === 'global' ? '全局' : path.basename(found.scope.cwd || '') || found.scope.key,
|
|
1855
|
+
body: body.slice(0, LIMITS.entryBytes),
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
/* ------------------------------------------------------------------ *
|
|
1860
|
+
* 跨层:范围路由、两层去重、槽位、搬移。这些规则只在服务端判定,*
|
|
1861
|
+
* 模型给的是提案(memoryClass / durability / evidenceRef),不是命令。*
|
|
1862
|
+
* ------------------------------------------------------------------ */
|
|
1863
|
+
|
|
1864
|
+
export const MEMORY_CLASSES = ['personal_preference', 'project_fact', 'environment_fact', 'portable_procedure'];
|
|
1865
|
+
export const DURABILITIES = ['durable', 'task'];
|
|
1866
|
+
|
|
1867
|
+
const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
|
|
1868
|
+
|
|
1869
|
+
/**
|
|
1870
|
+
* 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
|
|
1871
|
+
* 判不出来就返回 needsScope,让调用方问一句,而不是默默写进某一层。
|
|
1872
|
+
*/
|
|
1873
|
+
export function routeScope(draft, options = {}) {
|
|
1874
|
+
const source = draft || {};
|
|
1875
|
+
const explicit = source.scope === 'global' || source.scope === 'project' ? source.scope : '';
|
|
1876
|
+
/** 根目录没确认就还没有项目层身份:不能凭 cwd 新建一个命名空间。 */
|
|
1877
|
+
const projectReady = Boolean(options.project) && options.project.confirmed !== false;
|
|
1878
|
+
const cls = typeof source.memoryClass === 'string' ? source.memoryClass : '';
|
|
1879
|
+
const durability = typeof source.durability === 'string' ? source.durability : 'durable';
|
|
1880
|
+
const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
|
|
1881
|
+
if (durability === 'task') return { ...base, needsScope: true, reasonCode: 'task_state' };
|
|
1882
|
+
if (explicit) {
|
|
1883
|
+
if (explicit === 'project' && !projectReady) {
|
|
1884
|
+
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
1885
|
+
return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
|
|
1886
|
+
}
|
|
1887
|
+
return { ...base, chosenScope: explicit, reasonCode: 'explicit' };
|
|
1888
|
+
}
|
|
1889
|
+
if (!cls) {
|
|
1890
|
+
if (!projectReady) {
|
|
1891
|
+
return { ...base, needsScope: true, reasonCode: 'unconfirmed_project', requiredConditions: [{ key: 'projectRoot', value: '' }] };
|
|
1892
|
+
}
|
|
1893
|
+
return { ...base, chosenScope: 'project', reasonCode: 'unclassified_project' };
|
|
1894
|
+
}
|
|
1895
|
+
if (!MEMORY_CLASSES.includes(cls)) return { ...base, needsScope: true, reasonCode: 'unknown_class' };
|
|
1896
|
+
if (cls === 'project_fact') {
|
|
1897
|
+
if (!projectReady) {
|
|
1898
|
+
const reasonCode = options.project && options.project.confirmed === false ? 'unconfirmed_project' : 'no_project';
|
|
1899
|
+
return { ...base, needsScope: true, reasonCode, requiredConditions: [{ key: 'projectRoot', value: '' }] };
|
|
1900
|
+
}
|
|
1901
|
+
return { ...base, chosenScope: 'project', reasonCode: 'class_project_fact' };
|
|
1902
|
+
}
|
|
1903
|
+
if (cls === 'personal_preference') return { ...base, chosenScope: 'global', reasonCode: 'class_personal_preference' };
|
|
1904
|
+
if (cls === 'portable_procedure') return { ...base, chosenScope: 'global', reasonCode: 'class_portable_procedure' };
|
|
1905
|
+
const conditions = view.normalizeConditions(source.conditions) || [];
|
|
1906
|
+
const scoped = conditions.some((item) => ENVIRONMENT_KEYS.includes(item.key));
|
|
1907
|
+
if (!scoped) {
|
|
1908
|
+
return {
|
|
1909
|
+
...base,
|
|
1910
|
+
needsScope: true,
|
|
1911
|
+
reasonCode: 'needs_environment',
|
|
1912
|
+
requiredConditions: [{ key: 'machine', value: '' }, { key: 'runtime', value: '' }],
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
return { ...base, chosenScope: 'global', reasonCode: 'class_environment_fact' };
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function locateInLayers(projectScope, id) {
|
|
1919
|
+
for (const source of [projectScope, globalScope()]) {
|
|
1920
|
+
try {
|
|
1921
|
+
const record = loadIndex(source).entries.find((item) => item.id === id);
|
|
1922
|
+
if (record) return { scope: source, record };
|
|
1923
|
+
} catch {
|
|
1924
|
+
/* 一层不可用不影响另一层 */
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
return null;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
/** 完全重复的判据:内容、适用范围、结构化条件、只对某个产物成立的规则、关联都算。 */
|
|
1931
|
+
const visibleContent = (row) => JSON.stringify([
|
|
1932
|
+
row.title || '',
|
|
1933
|
+
row.summary || '',
|
|
1934
|
+
row.when || '',
|
|
1935
|
+
row.kind || 'note',
|
|
1936
|
+
view.conditionsKey(row.conditions),
|
|
1937
|
+
row.applicability || 'all_in_scope',
|
|
1938
|
+
row.topicKey || '',
|
|
1939
|
+
Array.isArray(row.tags) ? [...row.tags].map(String).sort() : [],
|
|
1940
|
+
Array.isArray(row.triggers) ? [...row.triggers].map(String).sort() : [],
|
|
1941
|
+
row.selection ? [row.selection.mode || '', row.selection.artifactKey || '', Array.isArray(row.selection.items) ? [...row.selection.items].map(String).sort() : []] : null,
|
|
1942
|
+
Array.isArray(row.links) ? row.links.map((link) => [String(link.type || 'related'), String(link.id)]).sort((a, b) => a[1].localeCompare(b[1])) : [],
|
|
1943
|
+
row.body || '',
|
|
1944
|
+
]);
|
|
1945
|
+
|
|
1946
|
+
function exactDuplicate(projectScope, targetScope, draft) {
|
|
1947
|
+
const layers = targetScope.key === 'global' ? [globalScope()] : [projectScope, globalScope()];
|
|
1948
|
+
const wanted = visibleContent(draft);
|
|
1949
|
+
for (const source of layers) {
|
|
1950
|
+
const index = loadIndex(source);
|
|
1951
|
+
for (const item of index.entries) {
|
|
1952
|
+
if (visibleContent(item) === wanted) {
|
|
1953
|
+
return { id: item.id, title: item.title, scope: source.key === 'global' ? 'global' : 'project', revision: item.revision || 1 };
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
function crossLayerSimilar(projectScope, targetScope, draft) {
|
|
1961
|
+
const other = targetScope.key === 'global' ? projectScope : globalScope();
|
|
1962
|
+
const index = loadIndex(other);
|
|
1963
|
+
const rows = similarEntries(index.entries, draft.title, draft.summary, 2);
|
|
1964
|
+
return rows.map((item) => ({
|
|
1965
|
+
id: item.id,
|
|
1966
|
+
title: item.title,
|
|
1967
|
+
summary: item.summary || '',
|
|
1968
|
+
revision: item.revision || 1,
|
|
1969
|
+
scope: other.key === 'global' ? 'global' : 'project',
|
|
1970
|
+
}));
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function bindSameTopic(projectScope, ref) {
|
|
1974
|
+
if (!ref || typeof ref !== 'object' || typeof ref.id !== 'string') return { error: 'sameTopicAs 要写成 {scope, id, revision}' };
|
|
1975
|
+
const source = ref.scope === 'global' ? globalScope() : projectScope;
|
|
1976
|
+
const record = loadIndex(source).entries.find((item) => item.id === ref.id);
|
|
1977
|
+
if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.id}` };
|
|
1978
|
+
if (Number(ref.revision) !== Number(record.revision || 1)) {
|
|
1979
|
+
return { error: `sameTopicAs 的目标已经改过:${ref.id}` };
|
|
1980
|
+
}
|
|
1981
|
+
return { topicKey: view.topicSlot(record) };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/**
|
|
1985
|
+
* 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
|
|
1986
|
+
* 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
|
|
1987
|
+
*/
|
|
1988
|
+
export function saveEntry(projectScope, source) {
|
|
1989
|
+
return withLocks([projectScope, globalScope()], () => {
|
|
1990
|
+
const control = loadIndex(projectScope).__control || state.controlOf(null);
|
|
1991
|
+
if (!control.enabled) {
|
|
1992
|
+
return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
|
|
1993
|
+
}
|
|
1994
|
+
const routed = routeScope(source, { project: projectScope });
|
|
1995
|
+
const draft = (() => {
|
|
1996
|
+
try { return normalizeDraft({ ...source, title: source && source.title }); } catch { return null; }
|
|
1997
|
+
})();
|
|
1998
|
+
if (routed.needsScope) {
|
|
1999
|
+
return { needsScope: true, chosenScope: '', reasonCode: routed.reasonCode, requiredConditions: routed.requiredConditions, needsResolution: false, similarList: [], draft };
|
|
2000
|
+
}
|
|
2001
|
+
let next = source;
|
|
2002
|
+
if (source.sameTopicAs) {
|
|
2003
|
+
const bound = bindSameTopic(projectScope, source.sameTopicAs);
|
|
2004
|
+
if (bound.error) throw new Error(bound.error);
|
|
2005
|
+
next = { ...source, topicKey: bound.topicKey };
|
|
2006
|
+
}
|
|
2007
|
+
let targetScope = routed.chosenScope === 'global' ? globalScope() : projectScope;
|
|
2008
|
+
if (next.id) {
|
|
2009
|
+
const found = locateInLayers(projectScope, next.id);
|
|
2010
|
+
if (!found) throw new Error('没有这条记忆,改用新增');
|
|
2011
|
+
targetScope = found.scope;
|
|
2012
|
+
}
|
|
2013
|
+
/** 更新已有项目条目也必须落在确认过的根目录里。 */
|
|
2014
|
+
if (targetScope.key !== 'global' && targetScope.confirmed === false) {
|
|
2015
|
+
return {
|
|
2016
|
+
needsScope: true, chosenScope: '', reasonCode: 'unconfirmed_project',
|
|
2017
|
+
requiredConditions: [{ key: 'projectRoot', value: '' }], needsResolution: false, similarList: [], draft,
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
if (!next.id && draft && source.resolution !== 'distinct') {
|
|
2021
|
+
const duplicate = exactDuplicate(projectScope, targetScope, draft);
|
|
2022
|
+
if (duplicate) {
|
|
2023
|
+
return { existing: duplicate, needsResolution: false, needsScope: false, similarList: [], draft, changed: false, merged: false, chosenScope: duplicate.scope };
|
|
2024
|
+
}
|
|
2025
|
+
const near = crossLayerSimilar(projectScope, targetScope, draft);
|
|
2026
|
+
if (near.length) {
|
|
2027
|
+
return {
|
|
2028
|
+
needsResolution: true,
|
|
2029
|
+
crossLayer: true,
|
|
2030
|
+
similarList: near,
|
|
2031
|
+
similar: near[0],
|
|
2032
|
+
draft,
|
|
2033
|
+
needsScope: false,
|
|
2034
|
+
chosenScope: targetScope.key === 'global' ? 'global' : 'project',
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
const foreign = targetScope.key === 'global' ? [] : loadIndex(globalScope()).entries;
|
|
2039
|
+
const result = upsertLocked(targetScope, next, { foreignEntries: foreign });
|
|
2040
|
+
return { ...result, chosenScope: targetScope.key === 'global' ? 'global' : 'project', needsScope: false, reasonCode: routed.reasonCode };
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/** 两层合并后的现行规范:同槽位只给胜出的那条;冲突或条件未知的不进自动召回。 */
|
|
2045
|
+
export function visibleRules(projectScope, context = {}, options = {}) {
|
|
2046
|
+
return withLocks([projectScope, globalScope()], () => {
|
|
2047
|
+
const projectIndex = loadIndex(projectScope);
|
|
2048
|
+
const globalIndex = loadIndex(globalScope());
|
|
2049
|
+
const project = projectIndex.entries.filter((item) => readableRecord(item, options, projectIndex));
|
|
2050
|
+
const global = globalIndex.entries.filter((item) => readableRecord(item, options, globalIndex));
|
|
2051
|
+
const resolved = view.resolveTopics(project, global, context);
|
|
2052
|
+
const winners = [];
|
|
2053
|
+
for (const row of resolved.values()) {
|
|
2054
|
+
if (row.status === 'ok' && row.record) winners.push({ record: row.record, scope: row.scope, key: row.key });
|
|
2055
|
+
}
|
|
2056
|
+
return { winners, resolved };
|
|
2057
|
+
});
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/* --------------------------- 跨层搬移 --------------------------- */
|
|
2061
|
+
|
|
2062
|
+
/** 搬移两侧都从协调文件里的身份还原:不拿调用方项目顶替另一层的 scope。 */
|
|
2063
|
+
function moveProjectOf(intent) {
|
|
2064
|
+
const side = intent.source.scopeKey === globalScope().key ? intent.target : intent.source;
|
|
2065
|
+
return {
|
|
2066
|
+
key: side.scopeKey,
|
|
2067
|
+
dir: path.join(state.memoryRoot(), side.scopeKey),
|
|
2068
|
+
cwd: typeof side.cwd === 'string' ? side.cwd : '',
|
|
2069
|
+
requestedCwd: typeof side.cwd === 'string' ? side.cwd : '',
|
|
2070
|
+
rootSource: 'move-intent',
|
|
2071
|
+
confirmed: true,
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
const moveTargetOf = (intent) => (intent.target.scopeKey === globalScope().key ? globalScope() : moveProjectOf(intent));
|
|
2076
|
+
const moveSourceOf = (intent) => (intent.source.scopeKey === globalScope().key ? globalScope() : moveProjectOf(intent));
|
|
2077
|
+
|
|
2078
|
+
/** A 项目发起的搬移只能在 A 项目里续跑:换项目续跑会把它解释成另一层,必须先挡住。 */
|
|
2079
|
+
function assertMoveCaller(projectScope, intent) {
|
|
2080
|
+
const owner = intent.source.scopeKey === globalScope().key ? intent.target : intent.source;
|
|
2081
|
+
if (projectScope.key !== owner.scopeKey) {
|
|
2082
|
+
throw new state.StateError('conflict', `这笔搬移属于另一个项目(${owner.scopeKey}),不能在当前项目续跑;回到原项目再调用 resumeMove`);
|
|
2083
|
+
}
|
|
2084
|
+
if (owner.cwd && projectScope.cwd) {
|
|
2085
|
+
let left = owner.cwd;
|
|
2086
|
+
let right = projectScope.cwd;
|
|
2087
|
+
try {
|
|
2088
|
+
left = fs.realpathSync(left);
|
|
2089
|
+
} catch {
|
|
2090
|
+
/* 旧目录可能已不在,按原样比较 */
|
|
2091
|
+
}
|
|
2092
|
+
try {
|
|
2093
|
+
right = fs.realpathSync(right);
|
|
2094
|
+
} catch {
|
|
2095
|
+
/* 同上 */
|
|
2096
|
+
}
|
|
2097
|
+
if (path.resolve(left) !== path.resolve(right)) {
|
|
2098
|
+
throw new state.StateError('conflict', '这笔搬移属于另一个项目,不能在当前项目续跑;回到原项目再调用 resumeMove');
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function entryOf(scope, id) {
|
|
2104
|
+
return state.load(scope, { allowMove: true, allowPurge: true }).state.entries.find((row) => row.id === id) || null;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
function stageOf(scope) {
|
|
2108
|
+
const loaded = state.load(scope, { allowMove: true, allowPurge: true });
|
|
2109
|
+
return { state: loaded.state, exists: loaded.exists };
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
/** 目标库可能还不存在:不存在时不带 CAS 期望值;刚被别处写入就重读一次再试。 */
|
|
2113
|
+
function transactWithRetry(scope, base, work) {
|
|
2114
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2115
|
+
const loaded = state.load(scope, { allowMove: true, allowPurge: true });
|
|
2116
|
+
const expectedEpoch = base.expectedEpoch || loaded.state.epoch;
|
|
2117
|
+
const options = loaded.exists
|
|
2118
|
+
? { ...base, expected: { generation: loaded.state.generation, epoch: expectedEpoch } }
|
|
2119
|
+
: { ...base, expected: null };
|
|
2120
|
+
delete options.expectedEpoch;
|
|
2121
|
+
try {
|
|
2122
|
+
return state.transact(scope, options, work);
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
const retryable = error instanceof state.StateError && error.code === 'conflict' && /重读后再写/.test(error.message);
|
|
2125
|
+
if (attempt === 0 && retryable) continue;
|
|
2126
|
+
throw error;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
throw new state.StateError('recover_failed', '写入重试后仍冲突');
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
function commitTarget(scope, record, intent) {
|
|
2133
|
+
const toEpoch = typeof intent.target.toEpoch === 'string' ? intent.target.toEpoch : '';
|
|
2134
|
+
transactWithRetry(scope, {
|
|
2135
|
+
operationId: `${intent.operationId}:in`,
|
|
2136
|
+
payloadDigest: `${intent.source.id}->${intent.target.newId}`,
|
|
2137
|
+
allowMove: true,
|
|
2138
|
+
expectedEpoch: typeof intent.target.epoch === 'string' ? intent.target.epoch : '',
|
|
2139
|
+
nextEpoch: toEpoch,
|
|
2140
|
+
}, (draft) => {
|
|
2141
|
+
if (draft.entries.some((row) => row.id === intent.target.newId)) return { changed: false };
|
|
2142
|
+
const next = JSON.parse(JSON.stringify(record));
|
|
2143
|
+
next.id = intent.target.newId;
|
|
2144
|
+
next.status = 'active';
|
|
2145
|
+
next.revision = 1;
|
|
2146
|
+
next.updated = new Date().toISOString();
|
|
2147
|
+
next.current.topicKey = intent.topicKey || next.current.topicKey || intent.source.id;
|
|
2148
|
+
next.maintenance = next.maintenance || {};
|
|
2149
|
+
next.maintenance.lastUsed = next.updated;
|
|
2150
|
+
next.current.body = record.current.body;
|
|
2151
|
+
draft.entries.push(next);
|
|
2152
|
+
return {
|
|
2153
|
+
changed: true,
|
|
2154
|
+
kind: 'moveIn',
|
|
2155
|
+
receipt: { moveId: intent.operationId, entryId: intent.target.newId, from: intent.source.scopeKey },
|
|
2156
|
+
};
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function cleanSource(scope, intent) {
|
|
2161
|
+
const toEpoch = typeof intent.source.toEpoch === 'string' ? intent.source.toEpoch : '';
|
|
2162
|
+
transactWithRetry(scope, {
|
|
2163
|
+
operationId: `${intent.operationId}:out`,
|
|
2164
|
+
payloadDigest: intent.source.id,
|
|
2165
|
+
allowMove: true,
|
|
2166
|
+
expectedEpoch: typeof intent.source.epoch === 'string' ? intent.source.epoch : '',
|
|
2167
|
+
nextEpoch: toEpoch,
|
|
2168
|
+
}, (draft) => {
|
|
2169
|
+
const before = draft.entries.length;
|
|
2170
|
+
draft.entries = draft.entries.filter((row) => row.id !== intent.source.id);
|
|
2171
|
+
draft.candidates = draft.candidates.filter((row) => row.sourceId !== intent.source.id
|
|
2172
|
+
&& !(Array.isArray(row.sourceIds) && row.sourceIds.includes(intent.source.id)));
|
|
2173
|
+
draft.receipts = draft.receipts.filter((row) => row.moveId !== intent.operationId || row.kind === 'moveOut');
|
|
2174
|
+
return {
|
|
2175
|
+
changed: draft.entries.length !== before,
|
|
2176
|
+
kind: 'moveOut',
|
|
2177
|
+
receipt: { moveId: intent.operationId, entryId: intent.source.id, to: intent.target.scopeKey },
|
|
2178
|
+
};
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/** 完成条件只看身份与 epoch:current、prev、回执、两侧 epoch 都到位才算搬完。 */
|
|
2183
|
+
function verifyMove(sourceScope, targetScope, intent) {
|
|
2184
|
+
const problems = [];
|
|
2185
|
+
const target = stageOf(targetScope);
|
|
2186
|
+
const source = stageOf(sourceScope);
|
|
2187
|
+
if (intent.target.toEpoch && target.state.epoch !== intent.target.toEpoch) problems.push('目标层 epoch 不是这一步该到的值');
|
|
2188
|
+
if (intent.source.toEpoch && source.state.epoch !== intent.source.toEpoch) problems.push('源层 epoch 不是这一步该到的值');
|
|
2189
|
+
if (!target.state.entries.some((row) => row.id === intent.target.newId && row.status === 'active')) problems.push('目标层没有这条记忆');
|
|
2190
|
+
if (source.state.entries.some((row) => row.id === intent.source.id)) problems.push('源层还留着旧条目');
|
|
2191
|
+
if (!target.state.receipts.some((row) => row.operationId === `${intent.operationId}:in`)) problems.push('目标层缺少写入回执');
|
|
2192
|
+
if (!source.state.receipts.some((row) => row.operationId === `${intent.operationId}:out`)) problems.push('源层缺少删除回执');
|
|
2193
|
+
const prev = (scope) => {
|
|
2194
|
+
try {
|
|
2195
|
+
return JSON.parse(fs.readFileSync(state.stateFiles(scope).prev, 'utf8')).payload;
|
|
2196
|
+
} catch {
|
|
2197
|
+
return null;
|
|
2198
|
+
}
|
|
2199
|
+
};
|
|
2200
|
+
const targetPrev = prev(targetScope);
|
|
2201
|
+
if (!targetPrev) problems.push('目标层缺少可恢复的上一步');
|
|
2202
|
+
else if (!(targetPrev.entries || []).some((row) => row.id === intent.target.newId)) problems.push('目标层上一步里没有这条记忆');
|
|
2203
|
+
const sourcePrev = prev(sourceScope);
|
|
2204
|
+
if (!sourcePrev) problems.push('源层缺少可恢复的上一步');
|
|
2205
|
+
else if ((sourcePrev.entries || []).some((row) => row.id === intent.source.id)) problems.push('源层上一步里还留着旧条目');
|
|
2206
|
+
if (problems.length) {
|
|
2207
|
+
throw new state.StateError('recover_failed', `移动恢复没通过校验(${problems.join(';')});协调文件保留:${intent.operationId}`);
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function driveMove(projectScope, intent, options = {}) {
|
|
2212
|
+
assertMoveCaller(projectScope, intent);
|
|
2213
|
+
const sourceScope = moveSourceOf(intent);
|
|
2214
|
+
const targetScope = moveTargetOf(intent);
|
|
2215
|
+
const target = stageOf(targetScope);
|
|
2216
|
+
const source = stageOf(sourceScope);
|
|
2217
|
+
const targetHas = target.state.entries.find((row) => row.id === intent.target.newId) || null;
|
|
2218
|
+
if (targetHas && intent.target.toEpoch && target.state.epoch !== intent.target.toEpoch) {
|
|
2219
|
+
throw new state.StateError('conflict', `目标层在搬移期间被改过(epoch 变了),先人工确认;协调文件保留:${intent.operationId}`);
|
|
2220
|
+
}
|
|
2221
|
+
if (!targetHas) {
|
|
2222
|
+
if (target.exists && intent.target.epoch && target.state.epoch !== intent.target.epoch) {
|
|
2223
|
+
throw new state.StateError('conflict', `目标层在搬移期间被改过(epoch 变了),先人工确认;协调文件保留:${intent.operationId}`);
|
|
2224
|
+
}
|
|
2225
|
+
const record = source.state.entries.find((row) => row.id === intent.source.id) || null;
|
|
2226
|
+
if (!record) throw new state.StateError('recover_failed', `移动中断且源与目标都找不到这条记忆;协调文件保留:${intent.operationId}`);
|
|
2227
|
+
commitTarget(targetScope, record, intent);
|
|
2228
|
+
if (options.stopAfter === 'target') {
|
|
2229
|
+
return { status: 'move_pending', stage: 'target_committed', operationId: intent.operationId, id: intent.target.newId };
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
const sourceEntry = source.state.entries.find((row) => row.id === intent.source.id) || null;
|
|
2233
|
+
if (sourceEntry) {
|
|
2234
|
+
if (intent.source.epoch && source.state.epoch !== intent.source.epoch) {
|
|
2235
|
+
throw new state.StateError('conflict', `源层在搬移期间被改过(epoch 变了),先人工确认;协调文件保留:${intent.operationId}`);
|
|
2236
|
+
}
|
|
2237
|
+
cleanSource(sourceScope, intent);
|
|
2238
|
+
removeBody(sourceScope, intent.source.id);
|
|
2239
|
+
if (options.stopAfter === 'source') {
|
|
2240
|
+
return { status: 'move_pending', stage: 'source_cleaned', operationId: intent.operationId, id: intent.target.newId };
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
// 收尾也重放:先把两侧 prev 封成新状态,再逐项校验,最后才清协调文件。
|
|
2244
|
+
state.sealPrev(targetScope);
|
|
2245
|
+
state.sealPrev(sourceScope);
|
|
2246
|
+
removeBody(sourceScope, intent.source.id);
|
|
2247
|
+
state.cleanStateResidue(targetScope);
|
|
2248
|
+
state.cleanStateResidue(sourceScope);
|
|
2249
|
+
verifyMove(sourceScope, targetScope, intent);
|
|
2250
|
+
state.clearMoveIntent();
|
|
2251
|
+
return {
|
|
2252
|
+
status: 'moved',
|
|
2253
|
+
id: intent.target.newId,
|
|
2254
|
+
previousId: intent.source.id,
|
|
2255
|
+
from: sourceScope.key === 'global' ? 'global' : 'project',
|
|
2256
|
+
to: targetScope.key === 'global' ? 'global' : 'project',
|
|
2257
|
+
operationId: intent.operationId,
|
|
2258
|
+
revision: 1,
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* §13.6 五步:根锁 → 协调文件 → 目标写入(moveIn + target.prev)→ 源删除(moveOut + source.prev)→ 校验后清协调文件。
|
|
2263
|
+
* 每一步都可按 operationId 重放;协调文件在 = 屏障,两层读写都先收尾。
|
|
2264
|
+
* 协调文件里带项目身份与两侧 epoch:换项目续跑会被拒绝,续跑也不再靠猜。
|
|
2265
|
+
*/
|
|
2266
|
+
export function move(projectScope, id, options = {}) {
|
|
2267
|
+
if (!(loadIndex(projectScope).__control || state.controlOf(null)).enabled) {
|
|
2268
|
+
throw new state.StateError('conflict', '本项目记忆已关闭,先开启再整理');
|
|
2269
|
+
}
|
|
2270
|
+
return state.withRootLock(() => {
|
|
2271
|
+
const existing = state.readMoveIntent();
|
|
2272
|
+
if (existing) {
|
|
2273
|
+
const operationId = options.operationId || existing.operationId;
|
|
2274
|
+
if (operationId !== existing.operationId) {
|
|
2275
|
+
throw new state.StateError('conflict', `另一笔移动还没收尾(${existing.operationId}),先调用 resumeMove`);
|
|
2276
|
+
}
|
|
2277
|
+
return driveMove(projectScope, existing, options);
|
|
2278
|
+
}
|
|
2279
|
+
const projectIndex = loadIndex(projectScope);
|
|
2280
|
+
const projectRecord = projectIndex.entries.find((row) => row.id === id);
|
|
2281
|
+
const globalIndex = projectRecord ? null : loadIndex(globalScope());
|
|
2282
|
+
const globalRecord = globalIndex ? globalIndex.entries.find((row) => row.id === id) : null;
|
|
2283
|
+
if (!projectRecord && !globalRecord) throw new state.StateError('conflict', `没有这条记忆:${id}`);
|
|
2284
|
+
const sourceScope = projectRecord ? projectScope : globalScope();
|
|
2285
|
+
const sourceIndex = projectRecord ? projectIndex : globalIndex;
|
|
2286
|
+
const record = projectRecord || globalRecord;
|
|
2287
|
+
/** 实验记录只能在原项目内用:普通移动不能把它带进全局层。 */
|
|
2288
|
+
if (record.origin === EXPERIMENTAL_ORIGIN && options.target === 'global') {
|
|
2289
|
+
throw new state.StateError('conflict', '实验规则不能直接搬到全局,先采纳为普通项目规范');
|
|
2290
|
+
}
|
|
2291
|
+
const targetScope = options.target === 'global' ? globalScope() : projectScope;
|
|
2292
|
+
if (targetScope.dir === sourceScope.dir) throw new state.StateError('conflict', '源和目标在同一层');
|
|
2293
|
+
const targetIndex = loadIndex(targetScope);
|
|
2294
|
+
const duplicate = targetIndex.entries.find((row) => visibleContent(row) === visibleContent(record));
|
|
2295
|
+
if (duplicate) throw new state.StateError('conflict', `目标层已有相同内容(${duplicate.id}),不搬`);
|
|
2296
|
+
if (view.topicSlot(record).trim()) {
|
|
2297
|
+
const slot = targetIndex.entries.find((row) => (row.topicKey || '') && row.topicKey === record.topicKey
|
|
2298
|
+
&& view.conditionsKey(row.conditions) === view.conditionsKey(record.conditions) && row.id !== record.id);
|
|
2299
|
+
if (slot) throw new state.StateError('conflict', `目标层已有同一槽位(${slot.id}),不搬`);
|
|
2300
|
+
}
|
|
2301
|
+
/** 入边不重写:搬走前先证明这条记录自己的出边在目标层仍可见。 */
|
|
2302
|
+
for (const link of view.linksOf(record)) {
|
|
2303
|
+
const visible = targetIndex.entries.some((row) => row.id === link.id)
|
|
2304
|
+
|| (targetScope.key !== 'global' && sourceIndex.entries.some((row) => row.id === link.id));
|
|
2305
|
+
if (!visible) throw new state.StateError('conflict', `关联目标 ${link.id} 在目标层不可见,先解除关联再搬`);
|
|
2306
|
+
}
|
|
2307
|
+
const operationId = typeof options.operationId === 'string' && options.operationId ? options.operationId : `mov_${crypto.randomBytes(6).toString('hex')}`;
|
|
2308
|
+
const newId = `mem_${crypto.createHash('sha256').update(`${operationId}:${id}`).digest('hex').slice(0, 12)}`;
|
|
2309
|
+
/** 目标 epoch 由 operationId 决定:续跑时不需要再猜「现在应该是第几代」。 */
|
|
2310
|
+
const epochOf = (side) => `e${crypto.createHash('sha256').update(`${operationId}:${side}`).digest('hex').slice(0, 20)}`;
|
|
2311
|
+
const identity = (scope, index) => ({
|
|
2312
|
+
scopeKey: scope.key,
|
|
2313
|
+
cwd: typeof scope.cwd === 'string' ? scope.cwd : '',
|
|
2314
|
+
storeId: (index.__meta && index.__meta.storeId) || '',
|
|
2315
|
+
});
|
|
2316
|
+
const intent = state.writeMoveIntent({
|
|
2317
|
+
operationId,
|
|
2318
|
+
source: {
|
|
2319
|
+
...identity(sourceScope, sourceIndex),
|
|
2320
|
+
id,
|
|
2321
|
+
revision: record.revision || 1,
|
|
2322
|
+
epoch: sourceIndex.epoch,
|
|
2323
|
+
toEpoch: epochOf('out'),
|
|
2324
|
+
},
|
|
2325
|
+
target: {
|
|
2326
|
+
...identity(targetScope, targetIndex),
|
|
2327
|
+
newId,
|
|
2328
|
+
epoch: targetIndex.epoch,
|
|
2329
|
+
toEpoch: epochOf('in'),
|
|
2330
|
+
},
|
|
2331
|
+
topicKey: view.topicSlot(record),
|
|
2332
|
+
});
|
|
2333
|
+
return driveMove(projectScope, intent, options);
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/** 恢复入口:只认根目录的协调文件;没有就什么都不做。 */
|
|
2338
|
+
export function resumeMove(projectScope, options = {}) {
|
|
2339
|
+
return state.withRootLock(() => {
|
|
2340
|
+
const intent = state.readMoveIntent();
|
|
2341
|
+
if (!intent) return { status: 'clean' };
|
|
2342
|
+
return driveMove(projectScope, intent, options);
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
export function movePending() {
|
|
2347
|
+
return state.readMoveIntent();
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
|
|
2351
|
+
// ── 项目实验:少量规则、候选与整理回执 ────────────────────────────────
|
|
2352
|
+
|
|
2353
|
+
const EXPERIMENT_RULE_STATES = new Set(['pending', 'trial', 'active', 'paused']);
|
|
2354
|
+
|
|
2355
|
+
function learningRulesOf(index) {
|
|
2356
|
+
return index.entries.filter((record) => isExperimentalRecord(record) && record.status !== 'archived');
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function visibleLearningRule(record) {
|
|
2360
|
+
const state = record.learning?.state;
|
|
2361
|
+
return EXPERIMENT_RULE_STATES.has(state) && state !== 'paused';
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function learningRuleView(record) {
|
|
2365
|
+
return {
|
|
2366
|
+
id: record.id,
|
|
2367
|
+
revision: Number(record.revision) || 1,
|
|
2368
|
+
title: record.title,
|
|
2369
|
+
summary: record.summary || '',
|
|
2370
|
+
body: record.body || '',
|
|
2371
|
+
applicability: record.applicability || 'unresolved',
|
|
2372
|
+
conditions: Array.isArray(record.conditions) ? record.conditions : [],
|
|
2373
|
+
topicKey: record.topicKey || '',
|
|
2374
|
+
origin: record.origin,
|
|
2375
|
+
state: record.learning?.state || 'pending',
|
|
2376
|
+
samples: (Array.isArray(record.learning?.samples) ? record.learning.samples : []).slice(-4).map((row) => ({
|
|
2377
|
+
dimension: row.dimension || '作品',
|
|
2378
|
+
artifactVersion: row.artifactVersion || '',
|
|
2379
|
+
verified: row.verified === true,
|
|
2380
|
+
at: row.at || '',
|
|
2381
|
+
})),
|
|
2382
|
+
checkpoints: (Array.isArray(record.learning?.checkpoints) ? record.learning.checkpoints : []).slice(-3).map((row) => ({
|
|
2383
|
+
text: row.text || '',
|
|
2384
|
+
at: row.at || '',
|
|
2385
|
+
})),
|
|
2386
|
+
learning: JSON.parse(JSON.stringify(record.learning || {})),
|
|
2387
|
+
updated: record.updated || '',
|
|
2388
|
+
created: record.created || '',
|
|
2389
|
+
};
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
/**
|
|
2393
|
+
* 管理面读取:候选、试用、已生效与暂停都在同一处,正文与来源只给管理面;
|
|
2394
|
+
* 普通记忆读取面不会调用这个入口。
|
|
2395
|
+
*/
|
|
2396
|
+
export function learningSnapshot(scope, options = {}) {
|
|
2397
|
+
const index = loadIndex(scope, options);
|
|
2398
|
+
const evolution = index.__evolution;
|
|
2399
|
+
const rules = learningRulesOf(index).map(learningRuleView);
|
|
2400
|
+
return {
|
|
2401
|
+
evolution,
|
|
2402
|
+
enabled: evolution.enabled === true,
|
|
2403
|
+
compatible: evolution.compatible !== false,
|
|
2404
|
+
rules,
|
|
2405
|
+
counts: {
|
|
2406
|
+
pending: rules.filter((row) => row.state === 'pending').length,
|
|
2407
|
+
trial: rules.filter((row) => row.state === 'trial').length,
|
|
2408
|
+
active: rules.filter((row) => row.state === 'active').length,
|
|
2409
|
+
paused: rules.filter((row) => row.state === 'paused').length,
|
|
2410
|
+
},
|
|
2411
|
+
economy: economyOf(evolution.learning),
|
|
2412
|
+
effect: learning.effectSummary(evolution.learning),
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
/** 用量只报真实已知;缺字段时标未知,不补零。 */
|
|
2417
|
+
function economyOf(ledger) {
|
|
2418
|
+
const rows = Array.isArray(ledger?.receipts) ? ledger.receipts : [];
|
|
2419
|
+
let inputTokens = 0;
|
|
2420
|
+
let outputTokens = 0;
|
|
2421
|
+
let calls = 0;
|
|
2422
|
+
let knownCalls = 0;
|
|
2423
|
+
for (const row of rows) {
|
|
2424
|
+
if (row.status !== 'applied' && row.status !== 'no_change' && row.status !== 'failed') continue;
|
|
2425
|
+
calls += 1;
|
|
2426
|
+
if (row.usage && row.usage.known === true) {
|
|
2427
|
+
knownCalls += 1;
|
|
2428
|
+
inputTokens += Number(row.usage.inputTokens) || 0;
|
|
2429
|
+
outputTokens += Number(row.usage.outputTokens) || 0;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
return { calls, knownCalls, inputTokens: knownCalls ? inputTokens : null, outputTokens: knownCalls ? outputTokens : null, recent: rows.slice(-8).reverse() };
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** 给整理器的候选规则:只读、有限,不跨项目。 */
|
|
2436
|
+
export function learningRulesForOrganizer(scope) {
|
|
2437
|
+
const index = loadIndex(scope);
|
|
2438
|
+
return learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* 整理前扣一次尝试:开关、身份与限额都在项目锁内再确认。
|
|
2443
|
+
* 失败也计数,所以模型超时/调用失败不能靠重试绕开限额。
|
|
2444
|
+
*/
|
|
2445
|
+
export function beginLearningAttempt(scope, options = {}) {
|
|
2446
|
+
const episodeId = String(options.episodeId || '');
|
|
2447
|
+
if (!episodeId) return { ok: false, reason: '缺少作品标识' };
|
|
2448
|
+
const now = options.now || Date.now();
|
|
2449
|
+
return withLock(scope, () => {
|
|
2450
|
+
const index = loadIndex(scope);
|
|
2451
|
+
const evolution = index.__evolution;
|
|
2452
|
+
if (!evolution.enabled || evolution.compatible === false) return { ok: false, reason: '实验没有开启' };
|
|
2453
|
+
if (evolution.policyVersion !== learning.POLICY_VERSION) return { ok: false, reason: '实验策略版本不认' };
|
|
2454
|
+
const check = learning.quota(evolution.learning, { episodeId, now });
|
|
2455
|
+
if (!check.ok) return { ...check, learning: evolution.learning };
|
|
2456
|
+
const next = learning.recordAttempt(evolution.learning, { episodeId, now });
|
|
2457
|
+
try {
|
|
2458
|
+
const result = state.transact(scope, {
|
|
2459
|
+
operationId: options.operationId || '',
|
|
2460
|
+
expected: { generation: index.generation, epoch: index.epoch },
|
|
2461
|
+
}, (draft) => {
|
|
2462
|
+
const evo = draft.experimental && draft.experimental.projectEvolution;
|
|
2463
|
+
if (!evo || evo.enabled !== true) return { changed: false };
|
|
2464
|
+
evo.learning = next;
|
|
2465
|
+
return { changed: true };
|
|
2466
|
+
});
|
|
2467
|
+
return {
|
|
2468
|
+
ok: true,
|
|
2469
|
+
epoch: evolution.epoch,
|
|
2470
|
+
generation: index.generation,
|
|
2471
|
+
episodeId,
|
|
2472
|
+
learning: next,
|
|
2473
|
+
rules: learningRulesOf(index).filter(visibleLearningRule).slice(0, 8).map(learningRuleView),
|
|
2474
|
+
replayed: result.replayed === true,
|
|
2475
|
+
};
|
|
2476
|
+
} catch (error) {
|
|
2477
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2478
|
+
return { ok: false, reason: error instanceof Error ? error.message : '整理没有开始' };
|
|
2479
|
+
}
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function episodeOfEvolution(evolution, episodeId) {
|
|
2484
|
+
return evolution?.tasks?.episodes && typeof evolution.tasks.episodes === 'object'
|
|
2485
|
+
? evolution.tasks.episodes[episodeId] || null
|
|
2486
|
+
: null;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
function addLearningReceipt(evolution, row) {
|
|
2490
|
+
evolution.learning = evolution.learning && typeof evolution.learning === 'object'
|
|
2491
|
+
? evolution.learning
|
|
2492
|
+
: state.emptyLearning();
|
|
2493
|
+
evolution.learning.receipts = [...(Array.isArray(evolution.learning.receipts) ? evolution.learning.receipts : []), row]
|
|
2494
|
+
.slice(-learning.LIMITS.receipts);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* 提交门:锁内重查开关、epoch、作品版本、目标版本与来源;任何一项变了整批不写。
|
|
2499
|
+
* 旧提案可以留下一次调用用量,但不能靠重放把旧正文写进新状态。
|
|
2500
|
+
*/
|
|
2501
|
+
export function commitLearning(scope, batch = {}) {
|
|
2502
|
+
const changes = Array.isArray(batch.changes) ? batch.changes : [];
|
|
2503
|
+
const episodeId = String(batch.episodeId || '');
|
|
2504
|
+
const operationId = String(batch.operationId || '');
|
|
2505
|
+
const now = batch.now || Date.now();
|
|
2506
|
+
const at = new Date(now).toISOString();
|
|
2507
|
+
if (!episodeId) return { ok: false, reason: '缺少作品标识' };
|
|
2508
|
+
if (!operationId) return { ok: false, reason: '缺少提交标识' };
|
|
2509
|
+
if (changes.length > learning.LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
2510
|
+
return withLock(scope, () => {
|
|
2511
|
+
if (!(loadIndex(scope).__control || state.controlOf(null)).enabled) return { ok: false, reason: '本项目记忆已关闭' };
|
|
2512
|
+
let receipt = null;
|
|
2513
|
+
try {
|
|
2514
|
+
const result = state.transact(scope, {
|
|
2515
|
+
operationId,
|
|
2516
|
+
payloadDigest: crypto.createHash('sha256').update(JSON.stringify(changes)).digest('hex'),
|
|
2517
|
+
}, (draft) => {
|
|
2518
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
2519
|
+
if (!evolution || evolution.enabled !== true) throw new Error('实验没有开启,这次整理作废');
|
|
2520
|
+
if (evolution.policyVersion !== learning.POLICY_VERSION) throw new Error('实验策略版本不认');
|
|
2521
|
+
if (batch.epoch && evolution.epoch !== batch.epoch) throw new Error('实验已经重新开启过,这次整理作废');
|
|
2522
|
+
const episode = episodeOfEvolution(evolution, episodeId);
|
|
2523
|
+
if (!episode) throw new Error('作品已经失效,这次整理作废');
|
|
2524
|
+
if (batch.sessionId) {
|
|
2525
|
+
const focus = evolution.tasks?.sessions?.[batch.sessionId];
|
|
2526
|
+
if (!focus || focus.episodeId !== episodeId) throw new Error('这期已经换到别的作品,这次整理作废');
|
|
2527
|
+
}
|
|
2528
|
+
if (batch.episodeUpdatedAt && episode.updatedAt !== batch.episodeUpdatedAt) throw new Error('用户又改过这期,这次整理作废');
|
|
2529
|
+
const prepared = new Map((Array.isArray(batch.preparedTargets) ? batch.preparedTargets : []).map((row) => [row.id, row]));
|
|
2530
|
+
let touched = 0;
|
|
2531
|
+
const currentRules = draft.entries.filter((entry) => entry.status === 'active' && isExperimentalRecord(toFlat(entry)));
|
|
2532
|
+
for (const change of changes) {
|
|
2533
|
+
if (change.target !== 'new') {
|
|
2534
|
+
const entry = draft.entries.find((row) => row.id === change.id && row.status === 'active');
|
|
2535
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('目标规则已经不存在,这次整理作废');
|
|
2536
|
+
const expected = prepared.get(change.id);
|
|
2537
|
+
if (!expected) throw new Error('目标规则没有在整理前固定版本');
|
|
2538
|
+
if (Number(entry.revision) !== Number(expected.revision)) throw new Error('目标规则刚被改过,这次整理作废');
|
|
2539
|
+
const flat = toFlat(entry);
|
|
2540
|
+
if (expected.specDigest && learning.specDigestOf(flat) !== expected.specDigest) throw new Error('目标规则已经变过,这次整理作废');
|
|
2541
|
+
if (change.feedbackType === 'artifact_accept' || change.feedbackType === 'execution_miss') {
|
|
2542
|
+
const next = change.feedbackType === 'artifact_accept'
|
|
2543
|
+
? learning.addSample(flat.learning, {
|
|
2544
|
+
episodeId,
|
|
2545
|
+
artifactVersion: batch.episodeUpdatedAt || '',
|
|
2546
|
+
dimension: batch.dimension || learning.dimensionOf(batch.feedbackText),
|
|
2547
|
+
messageIds: batch.messageIds || [],
|
|
2548
|
+
at,
|
|
2549
|
+
})
|
|
2550
|
+
: learning.addCheckpoint(flat.learning, {
|
|
2551
|
+
text: change.currentText || `交付前核对:${flat.title}`,
|
|
2552
|
+
messageIds: batch.messageIds || [],
|
|
2553
|
+
at,
|
|
2554
|
+
});
|
|
2555
|
+
next.updatedAt = at;
|
|
2556
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2557
|
+
entry.updatedAt = at;
|
|
2558
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: next };
|
|
2559
|
+
touched += 1;
|
|
2560
|
+
continue;
|
|
2561
|
+
}
|
|
2562
|
+
const merged = learning.mergeChange(flat, change, { now });
|
|
2563
|
+
const evidence = {
|
|
2564
|
+
sourceType: 'human_feedback',
|
|
2565
|
+
feedbackType: change.feedbackType,
|
|
2566
|
+
sessionId: String(batch.sessionId || ''),
|
|
2567
|
+
episodeId,
|
|
2568
|
+
messageIds: (batch.messageIds || []).slice(-3),
|
|
2569
|
+
at,
|
|
2570
|
+
};
|
|
2571
|
+
merged.learning = {
|
|
2572
|
+
...learning.nextLearningState({
|
|
2573
|
+
existing: flat,
|
|
2574
|
+
change,
|
|
2575
|
+
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2576
|
+
specChanged: merged.specChanged === true,
|
|
2577
|
+
now,
|
|
2578
|
+
}),
|
|
2579
|
+
specDigest: merged.digest,
|
|
2580
|
+
versions: merged.learning.versions,
|
|
2581
|
+
};
|
|
2582
|
+
entry.current = {
|
|
2583
|
+
...entry.current,
|
|
2584
|
+
title: merged.current.title,
|
|
2585
|
+
summary: merged.current.summary,
|
|
2586
|
+
body: merged.current.body,
|
|
2587
|
+
when: merged.current.when,
|
|
2588
|
+
applicability: merged.current.applicability,
|
|
2589
|
+
conditions: merged.current.conditions,
|
|
2590
|
+
topicKey: merged.current.topicKey,
|
|
2591
|
+
};
|
|
2592
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2593
|
+
entry.updatedAt = at;
|
|
2594
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: merged.learning };
|
|
2595
|
+
} else {
|
|
2596
|
+
const evidence = {
|
|
2597
|
+
sourceType: 'human_feedback',
|
|
2598
|
+
feedbackType: change.feedbackType,
|
|
2599
|
+
sessionId: String(batch.sessionId || ''),
|
|
2600
|
+
episodeId,
|
|
2601
|
+
messageIds: (batch.messageIds || []).slice(-3),
|
|
2602
|
+
at,
|
|
2603
|
+
};
|
|
2604
|
+
const merged = learning.mergeChange(null, change, { now });
|
|
2605
|
+
/** 同主题、同条件的新规则并进已有条目:整理器换个措辞不该长出第二条。 */
|
|
2606
|
+
const signature = (list) => (Array.isArray(list) ? list : [])
|
|
2607
|
+
.map((row) => `${row.key}=${row.value}`).sort().join('\u0000');
|
|
2608
|
+
const duplicate = merged.current.topicKey
|
|
2609
|
+
? currentRules.find((row) => {
|
|
2610
|
+
const flat = toFlat(row);
|
|
2611
|
+
return flat.topicKey === merged.current.topicKey
|
|
2612
|
+
&& signature(flat.conditions) === signature(merged.current.conditions)
|
|
2613
|
+
&& flat.applicability === merged.current.applicability;
|
|
2614
|
+
})
|
|
2615
|
+
: null;
|
|
2616
|
+
if (duplicate) {
|
|
2617
|
+
const flat = toFlat(duplicate);
|
|
2618
|
+
const rem = learning.mergeChange(flat, change, { now });
|
|
2619
|
+
rem.learning = {
|
|
2620
|
+
...learning.nextLearningState({
|
|
2621
|
+
existing: flat,
|
|
2622
|
+
change,
|
|
2623
|
+
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2624
|
+
specChanged: rem.specChanged === true,
|
|
2625
|
+
now,
|
|
2626
|
+
}),
|
|
2627
|
+
specDigest: rem.digest,
|
|
2628
|
+
versions: rem.learning.versions,
|
|
2629
|
+
};
|
|
2630
|
+
duplicate.current = {
|
|
2631
|
+
...duplicate.current,
|
|
2632
|
+
title: rem.current.title,
|
|
2633
|
+
summary: rem.current.summary,
|
|
2634
|
+
body: rem.current.body,
|
|
2635
|
+
when: rem.current.when,
|
|
2636
|
+
applicability: rem.current.applicability,
|
|
2637
|
+
conditions: rem.current.conditions,
|
|
2638
|
+
topicKey: rem.current.topicKey,
|
|
2639
|
+
};
|
|
2640
|
+
duplicate.revision = Number(duplicate.revision) + 1;
|
|
2641
|
+
duplicate.updatedAt = at;
|
|
2642
|
+
duplicate.maintenance = { ...(duplicate.maintenance || {}), learning: rem.learning };
|
|
2643
|
+
touched += 1;
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
if (currentRules.length + (touched + 1) > learning.LIMITS.rules) throw new Error('项目实验规则已经到上限');
|
|
2647
|
+
const pending = currentRules.filter((entry) => toFlat(entry).learning?.state === 'pending').length;
|
|
2648
|
+
if (pending >= learning.LIMITS.pending) throw new Error('待确认候选已经到上限');
|
|
2649
|
+
merged.learning = {
|
|
2650
|
+
...learning.nextLearningState({
|
|
2651
|
+
existing: { learning: merged.learning },
|
|
2652
|
+
change,
|
|
2653
|
+
evidence: { ...evidence, explicitLongTerm: Boolean(batch.explicitLongTerm) },
|
|
2654
|
+
specChanged: merged.specChanged === true,
|
|
2655
|
+
now,
|
|
2656
|
+
}),
|
|
2657
|
+
specDigest: merged.digest,
|
|
2658
|
+
versions: merged.learning.versions,
|
|
2659
|
+
};
|
|
2660
|
+
const id = `mem_${crypto.createHash('sha256').update(`${operationId}:${touched}:${merged.current.topicKey}:${at}`).digest('hex').slice(0, 12)}`;
|
|
2661
|
+
const flat = {
|
|
2662
|
+
id,
|
|
2663
|
+
title: merged.current.title,
|
|
2664
|
+
summary: merged.current.summary,
|
|
2665
|
+
body: merged.current.body,
|
|
2666
|
+
kind: 'convention',
|
|
2667
|
+
tags: [],
|
|
2668
|
+
topicKey: merged.current.topicKey,
|
|
2669
|
+
triggers: [],
|
|
2670
|
+
applicability: merged.current.applicability,
|
|
2671
|
+
conditions: merged.current.conditions,
|
|
2672
|
+
selection: null,
|
|
2673
|
+
links: [],
|
|
2674
|
+
origin: EXPERIMENTAL_ORIGIN,
|
|
2675
|
+
ownerProjectId: scope.key,
|
|
2676
|
+
reviewState: 'active',
|
|
2677
|
+
created: at,
|
|
2678
|
+
updated: at,
|
|
2679
|
+
lastUsed: at,
|
|
2680
|
+
revision: 1,
|
|
2681
|
+
status: 'active',
|
|
2682
|
+
hits: 0,
|
|
2683
|
+
merges: 0,
|
|
2684
|
+
versions: [],
|
|
2685
|
+
sourceRefs: [evidence],
|
|
2686
|
+
checks: [],
|
|
2687
|
+
learning: merged.learning,
|
|
2688
|
+
};
|
|
2689
|
+
const created = fromFlat(flat);
|
|
2690
|
+
draft.entries.push(created);
|
|
2691
|
+
currentRules.push(created);
|
|
2692
|
+
touched += 1;
|
|
2693
|
+
continue;
|
|
2694
|
+
}
|
|
2695
|
+
touched += 1;
|
|
2696
|
+
}
|
|
2697
|
+
receipt = learning.receiptOf({
|
|
2698
|
+
operationId,
|
|
2699
|
+
episodeId,
|
|
2700
|
+
status: touched > 0 ? 'applied' : 'no_change',
|
|
2701
|
+
changes: touched,
|
|
2702
|
+
usage: batch.usage,
|
|
2703
|
+
at,
|
|
2704
|
+
});
|
|
2705
|
+
addLearningReceipt(evolution, receipt);
|
|
2706
|
+
return {
|
|
2707
|
+
changed: true,
|
|
2708
|
+
kind: 'learning',
|
|
2709
|
+
receipt: { learning: receipt },
|
|
2710
|
+
result: { receipt, touched },
|
|
2711
|
+
};
|
|
2712
|
+
});
|
|
2713
|
+
if (result.replayed === true) {
|
|
2714
|
+
const prior = result.receipt && result.receipt.learning ? result.receipt.learning : null;
|
|
2715
|
+
return { ok: true, replayed: true, receipt: prior, touched: prior ? prior.changes : null };
|
|
2716
|
+
}
|
|
2717
|
+
const out = result.result || { receipt, touched: changes.length };
|
|
2718
|
+
return { ok: true, ...out, replayed: false };
|
|
2719
|
+
} catch (error) {
|
|
2720
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2721
|
+
return { ok: false, reason: error instanceof Error ? error.message : '整理结果没有写入' };
|
|
2722
|
+
}
|
|
2723
|
+
});
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/** 用一次未通过校验或失败的调用留回执:不写正文,但保留真实用量。 */
|
|
2727
|
+
export function recordLearningFailure(scope, options = {}) {
|
|
2728
|
+
const now = options.now || Date.now();
|
|
2729
|
+
const at = new Date(now).toISOString();
|
|
2730
|
+
const receipt = learning.receiptOf({
|
|
2731
|
+
operationId: options.operationId,
|
|
2732
|
+
episodeId: options.episodeId,
|
|
2733
|
+
status: 'failed',
|
|
2734
|
+
changes: 0,
|
|
2735
|
+
reason: options.reason,
|
|
2736
|
+
usage: options.usage,
|
|
2737
|
+
at,
|
|
2738
|
+
});
|
|
2739
|
+
return withLock(scope, () => {
|
|
2740
|
+
try {
|
|
2741
|
+
const result = state.transact(scope, {
|
|
2742
|
+
operationId: options.operationId || '',
|
|
2743
|
+
}, (draft) => {
|
|
2744
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
2745
|
+
if (!evolution || evolution.enabled !== true) return { changed: false };
|
|
2746
|
+
if (options.epoch && evolution.epoch !== options.epoch) throw new Error('实验已经重新开启过,这次回执作废');
|
|
2747
|
+
addLearningReceipt(evolution, receipt);
|
|
2748
|
+
return { changed: true, kind: 'learning_failed' };
|
|
2749
|
+
});
|
|
2750
|
+
return { ok: true, receipt, replayed: result.replayed === true };
|
|
2751
|
+
} catch (error) {
|
|
2752
|
+
return { ok: false, reason: error instanceof Error ? error.message : '回执没有写入' };
|
|
2753
|
+
}
|
|
2754
|
+
});
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/** 管理面写操作的统一闸门:总开关关掉时服务端直接拒绝,不写盘也不返回旧正文。 */
|
|
2758
|
+
function managementClosed(scope) {
|
|
2759
|
+
const index = loadIndex(scope);
|
|
2760
|
+
return !(index.__control || state.controlOf(null)).enabled;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
/** 删除:仍按管理面看到的版本比对,删掉正文与旧版本,只留审计回执。 */
|
|
2764
|
+
export function removeLearningRule(scope, id, options = {}) {
|
|
2765
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2766
|
+
const operationId = String(options.operationId || '');
|
|
2767
|
+
const at = new Date(options.now || Date.now()).toISOString();
|
|
2768
|
+
return withLock(scope, () => {
|
|
2769
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2770
|
+
try {
|
|
2771
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2772
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
2773
|
+
if (!evolution) throw new Error('没有实验记录');
|
|
2774
|
+
if (options.epoch && evolution.epoch !== options.epoch) throw new Error('实验已经重新开启过,先刷新');
|
|
2775
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2776
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2777
|
+
if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再删除');
|
|
2778
|
+
const flat = toFlat(entry);
|
|
2779
|
+
entry.status = 'archived';
|
|
2780
|
+
entry.updatedAt = at;
|
|
2781
|
+
entry.current = { ...entry.current, body: '', summary: '' };
|
|
2782
|
+
const deletedLearning = {
|
|
2783
|
+
...(flat.learning || {}),
|
|
2784
|
+
state: 'paused',
|
|
2785
|
+
blockedReason: 'deleted',
|
|
2786
|
+
pausedBy: 'management',
|
|
2787
|
+
pausedAt: at,
|
|
2788
|
+
versions: [],
|
|
2789
|
+
specDigest: '',
|
|
2790
|
+
};
|
|
2791
|
+
/** 删掉的规范正文不能再从 lastText 里漏出来。 */
|
|
2792
|
+
delete deletedLearning.lastText;
|
|
2793
|
+
delete deletedLearning.trialUntil;
|
|
2794
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: deletedLearning };
|
|
2795
|
+
addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'deleted', changes: 1, at }));
|
|
2796
|
+
return { changed: true, kind: 'learning_delete' };
|
|
2797
|
+
});
|
|
2798
|
+
return { ok: true, replayed: result.replayed === true };
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2801
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有删除' };
|
|
2802
|
+
}
|
|
2803
|
+
});
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
/** 撤回:目标版本对不上就拒绝,不能把更新的版本退回旧正文。 */
|
|
2807
|
+
export function undoLearningChange(scope, id, options = {}) {
|
|
2808
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2809
|
+
const operationId = String(options.operationId || '');
|
|
2810
|
+
const now = options.now || Date.now();
|
|
2811
|
+
const at = new Date(now).toISOString();
|
|
2812
|
+
return withLock(scope, () => {
|
|
2813
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2814
|
+
try {
|
|
2815
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2816
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
2817
|
+
if (!evolution) throw new Error('没有实验记录');
|
|
2818
|
+
if (options.epoch && evolution.epoch !== options.epoch) throw new Error('实验已经重新开启过,先刷新');
|
|
2819
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2820
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2821
|
+
if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再撤回');
|
|
2822
|
+
const flat = toFlat(entry);
|
|
2823
|
+
const versions = Array.isArray(flat.learning?.versions) ? flat.learning.versions : [];
|
|
2824
|
+
const previous = versions[versions.length - 1];
|
|
2825
|
+
if (!previous) throw new Error('没有可撤回的上一版');
|
|
2826
|
+
entry.current = { ...entry.current, body: previous.body || entry.current.body };
|
|
2827
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2828
|
+
entry.updatedAt = at;
|
|
2829
|
+
entry.maintenance = {
|
|
2830
|
+
...(entry.maintenance || {}),
|
|
2831
|
+
learning: {
|
|
2832
|
+
...(flat.learning || {}),
|
|
2833
|
+
state: 'paused',
|
|
2834
|
+
blockedReason: 'undone',
|
|
2835
|
+
pausedBy: 'management',
|
|
2836
|
+
pausedAt: at,
|
|
2837
|
+
versions: versions.slice(0, -1),
|
|
2838
|
+
specDigest: learning.specDigestOf({ ...flat, body: previous.body || flat.body }),
|
|
2839
|
+
},
|
|
2840
|
+
};
|
|
2841
|
+
addLearningReceipt(evolution, learning.receiptOf({ operationId, episodeId: flat.learning?.evidence?.[0]?.episodeId || '', status: 'undone', changes: 1, at }));
|
|
2842
|
+
return { changed: true, kind: 'learning_undo' };
|
|
2843
|
+
});
|
|
2844
|
+
return { ok: true, replayed: result.replayed === true };
|
|
2845
|
+
} catch (error) {
|
|
2846
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2847
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有撤回' };
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
/** 固定/取消固定只改管理状态;取消固定不会自动恢复生效。 */
|
|
2853
|
+
export function pinLearningRule(scope, id, options = {}) {
|
|
2854
|
+
const pinned = options.pinned === true;
|
|
2855
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2856
|
+
const operationId = String(options.operationId || '');
|
|
2857
|
+
const at = new Date(options.now || Date.now()).toISOString();
|
|
2858
|
+
return withLock(scope, () => {
|
|
2859
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2860
|
+
try {
|
|
2861
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2862
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
2863
|
+
if (options.epoch && evolution && evolution.epoch !== options.epoch) throw new Error('实验已经重新开启过,先刷新');
|
|
2864
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2865
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2866
|
+
if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新');
|
|
2867
|
+
const learningState = toFlat(entry).learning || {};
|
|
2868
|
+
entry.maintenance = {
|
|
2869
|
+
...(entry.maintenance || {}),
|
|
2870
|
+
learning: { ...learningState, pinned, updatedAt: at },
|
|
2871
|
+
};
|
|
2872
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2873
|
+
entry.updatedAt = at;
|
|
2874
|
+
return { changed: true, kind: 'learning_pin' };
|
|
2875
|
+
});
|
|
2876
|
+
return { ok: true, replayed: result.replayed === true };
|
|
2877
|
+
} catch (error) {
|
|
2878
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有改动' };
|
|
2879
|
+
}
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/**
|
|
2884
|
+
* 管理面编辑:只保存内容,不改变学习状态;正文变了就清掉旧证据并留上一版。
|
|
2885
|
+
* 已生效规则改写后降回试用,避免拿新正文沿用旧证据直接当默认。
|
|
2886
|
+
*/
|
|
2887
|
+
export function editLearningRule(scope, id, options = {}) {
|
|
2888
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2889
|
+
const operationId = String(options.operationId || '');
|
|
2890
|
+
const now = options.now || Date.now();
|
|
2891
|
+
const at = new Date(now).toISOString();
|
|
2892
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2893
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2894
|
+
if (!title && !body) throw new Error('没有要保存的内容');
|
|
2895
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2896
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2897
|
+
return withLock(scope, () => {
|
|
2898
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2899
|
+
try {
|
|
2900
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2901
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2902
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2903
|
+
if (!Number.isInteger(expectedRevision) || Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再保存');
|
|
2904
|
+
const flat = toFlat(entry);
|
|
2905
|
+
const nextTitle = title || flat.title;
|
|
2906
|
+
const nextBody = body || flat.body;
|
|
2907
|
+
if (nextTitle === flat.title && nextBody === flat.body) return { changed: false };
|
|
2908
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2909
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2910
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2911
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2912
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2913
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2914
|
+
learningState.supportedEpisodeIds = [];
|
|
2915
|
+
learningState.evidence = [];
|
|
2916
|
+
learningState.updatedAt = at;
|
|
2917
|
+
if (learningState.state === 'active') {
|
|
2918
|
+
learningState.state = 'trial';
|
|
2919
|
+
learningState.trialUntil = new Date(now + learning.LIMITS.trialMs).toISOString();
|
|
2920
|
+
}
|
|
2921
|
+
if (learningState.state === 'paused') {
|
|
2922
|
+
learningState.blockedReason = 'edited';
|
|
2923
|
+
learningState.pausedAt = at;
|
|
2924
|
+
learningState.pausedBy = 'management';
|
|
2925
|
+
}
|
|
2926
|
+
entry.current = nextCurrent;
|
|
2927
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
2928
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2929
|
+
entry.updatedAt = at;
|
|
2930
|
+
return { changed: true, kind: 'learning_edit' };
|
|
2931
|
+
});
|
|
2932
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
2933
|
+
} catch (error) {
|
|
2934
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2935
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有保存' };
|
|
2936
|
+
}
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
/** 采纳:候选/试用转为生效,默认在适用任务中参与召回;可选同时保存改过的标题或正文。 */
|
|
2941
|
+
export function adoptLearningRule(scope, id, options = {}) {
|
|
2942
|
+
const expectedRevision = Number(options.expectedRevision);
|
|
2943
|
+
const operationId = String(options.operationId || '');
|
|
2944
|
+
const now = options.now || Date.now();
|
|
2945
|
+
const at = new Date(now).toISOString();
|
|
2946
|
+
const title = typeof options.title === 'string' ? options.title.trim() : '';
|
|
2947
|
+
const body = typeof options.body === 'string' ? options.body.trim() : '';
|
|
2948
|
+
if (!Number.isInteger(expectedRevision) || expectedRevision < 1) throw new Error('缺少版本,先刷新');
|
|
2949
|
+
if (title && title.length > LIMITS.titleChars) throw new Error(`标题超过 ${LIMITS.titleChars} 字`);
|
|
2950
|
+
if (body && Buffer.byteLength(body) > LIMITS.entryBytes) throw new Error(`正文超过单条 ${LIMITS.entryBytes} 字节`);
|
|
2951
|
+
return withLock(scope, () => {
|
|
2952
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2953
|
+
try {
|
|
2954
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
2955
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
2956
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
2957
|
+
if (Number(entry.revision) !== expectedRevision) throw new Error('这条规则刚被改过,先刷新再采纳');
|
|
2958
|
+
const flat = toFlat(entry);
|
|
2959
|
+
const learningState = { ...(flat.learning || {}) };
|
|
2960
|
+
if (learningState.state === 'active' && !title && !body) return { changed: false };
|
|
2961
|
+
const nextTitle = title || flat.title;
|
|
2962
|
+
const nextBody = body || flat.body;
|
|
2963
|
+
const nextCurrent = { ...entry.current, title: nextTitle, body: nextBody };
|
|
2964
|
+
if (!learningState.specDigest) learningState.specDigest = learning.specDigestOf(flat);
|
|
2965
|
+
if (nextBody !== flat.body || nextTitle !== flat.title) {
|
|
2966
|
+
learningState.versions = [...(Array.isArray(learningState.versions) ? learningState.versions : []),
|
|
2967
|
+
{ digest: learningState.specDigest, body: flat.body, at }].slice(-learning.LIMITS.versions);
|
|
2968
|
+
learningState.specDigest = learning.specDigestOf(nextCurrent);
|
|
2969
|
+
learningState.supportedEpisodeIds = [];
|
|
2970
|
+
learningState.evidence = [];
|
|
2971
|
+
}
|
|
2972
|
+
learningState.state = 'active';
|
|
2973
|
+
delete learningState.trialUntil;
|
|
2974
|
+
delete learningState.blockedReason;
|
|
2975
|
+
delete learningState.pausedAt;
|
|
2976
|
+
delete learningState.pausedBy;
|
|
2977
|
+
learningState.updatedAt = at;
|
|
2978
|
+
entry.current = nextCurrent;
|
|
2979
|
+
entry.maintenance = { ...(entry.maintenance || {}), learning: learningState };
|
|
2980
|
+
entry.revision = Number(entry.revision) + 1;
|
|
2981
|
+
entry.updatedAt = at;
|
|
2982
|
+
return { changed: true, kind: 'learning_adopt' };
|
|
2983
|
+
});
|
|
2984
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
2985
|
+
} catch (error) {
|
|
2986
|
+
if (error && error.code === 'conflict') return { ok: false, reason: '项目状态刚被改过,重试一次', retry: true };
|
|
2987
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有采纳' };
|
|
2988
|
+
}
|
|
2989
|
+
});
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/** 结构化负反馈先在本地暂停命中的试用规则,再决定是否调用整理器。 */
|
|
2993
|
+
export function pauseLearningRules(scope, ids, options = {}) {
|
|
2994
|
+
const targets = Array.isArray(ids) ? ids.filter(Boolean).slice(0, 12) : [];
|
|
2995
|
+
if (!targets.length) return { ok: true, changed: 0 };
|
|
2996
|
+
const at = new Date(options.now || Date.now()).toISOString();
|
|
2997
|
+
return withLock(scope, () => {
|
|
2998
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
2999
|
+
try {
|
|
3000
|
+
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
3001
|
+
let changed = 0;
|
|
3002
|
+
for (const entry of draft.entries) {
|
|
3003
|
+
if (!targets.includes(entry.id) || entry.status !== 'active' || !isExperimentalRecord(toFlat(entry))) continue;
|
|
3004
|
+
const flat = toFlat(entry);
|
|
3005
|
+
if (flat.learning?.state === 'paused') continue;
|
|
3006
|
+
entry.maintenance = {
|
|
3007
|
+
...(entry.maintenance || {}),
|
|
3008
|
+
learning: learning.pauseLearning(flat.learning, { by: options.by || 'feedback', reason: options.reason || 'feedback_pending', now: options.now }),
|
|
3009
|
+
};
|
|
3010
|
+
entry.revision = Number(entry.revision) + 1;
|
|
3011
|
+
entry.updatedAt = at;
|
|
3012
|
+
changed += 1;
|
|
3013
|
+
}
|
|
3014
|
+
return changed ? { changed: true, kind: 'learning_pause' } : { changed: false };
|
|
3015
|
+
});
|
|
3016
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
3017
|
+
} catch (error) {
|
|
3018
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有暂停' };
|
|
3019
|
+
}
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
3022
|
+
|
|
3023
|
+
/** 管理面恢复试用:用户明确复核后从暂停回到试用,不会直接升为已生效。 */
|
|
3024
|
+
export function resumeLearningRule(scope, id, options = {}) {
|
|
3025
|
+
const operationId = String(options.operationId || '');
|
|
3026
|
+
const at = new Date(options.now || Date.now()).toISOString();
|
|
3027
|
+
return withLock(scope, () => {
|
|
3028
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
3029
|
+
try {
|
|
3030
|
+
const result = state.transact(scope, { operationId }, (draft) => {
|
|
3031
|
+
const entry = draft.entries.find((row) => row.id === id && row.status === 'active');
|
|
3032
|
+
if (!entry || !isExperimentalRecord(toFlat(entry))) throw new Error('没有这条实验规则');
|
|
3033
|
+
const flat = toFlat(entry);
|
|
3034
|
+
if (flat.learning?.state !== 'paused') throw new Error('这条规则没有暂停');
|
|
3035
|
+
entry.maintenance = {
|
|
3036
|
+
...(entry.maintenance || {}),
|
|
3037
|
+
learning: learning.resumeLearning(flat.learning, { by: 'management', now: options.now }),
|
|
3038
|
+
};
|
|
3039
|
+
entry.revision = Number(entry.revision) + 1;
|
|
3040
|
+
entry.updatedAt = at;
|
|
3041
|
+
return { changed: true, kind: 'learning_resume' };
|
|
3042
|
+
});
|
|
3043
|
+
return { ok: true, replayed: result.replayed === true };
|
|
3044
|
+
} catch (error) {
|
|
3045
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有恢复' };
|
|
3046
|
+
}
|
|
3047
|
+
});
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** 到期的试用转暂停、过期候选清掉;只在已有管理边界的写事务里调用。 */
|
|
3051
|
+
export function expireLearning(scope, options = {}) {
|
|
3052
|
+
const now = options.now || Date.now();
|
|
3053
|
+
const at = new Date(now).toISOString();
|
|
3054
|
+
return withLock(scope, () => {
|
|
3055
|
+
if (managementClosed(scope)) return { ok: false, code: 'memory_disabled', reason: '本项目记忆已关闭' };
|
|
3056
|
+
try {
|
|
3057
|
+
const result = state.transact(scope, { operationId: options.operationId || '' }, (draft) => {
|
|
3058
|
+
const evolution = draft.experimental && draft.experimental.projectEvolution;
|
|
3059
|
+
if (!evolution || evolution.enabled !== true) return { changed: false };
|
|
3060
|
+
const flat = draft.entries.filter((entry) => entry.status === 'active').map(toFlat);
|
|
3061
|
+
const plan = learning.lifecycle(flat, now);
|
|
3062
|
+
let changed = 0;
|
|
3063
|
+
draft.entries = draft.entries.filter((entry) => !(entry.status === 'active' && plan.purge.includes(entry.id)));
|
|
3064
|
+
changed += plan.purge.length;
|
|
3065
|
+
for (const entry of draft.entries) {
|
|
3066
|
+
if (entry.status !== 'active' || !plan.paused.includes(entry.id)) continue;
|
|
3067
|
+
const current = toFlat(entry);
|
|
3068
|
+
entry.maintenance = {
|
|
3069
|
+
...(entry.maintenance || {}),
|
|
3070
|
+
learning: learning.pauseLearning(current.learning, { by: 'ttl', reason: 'trial_expired', now }),
|
|
3071
|
+
};
|
|
3072
|
+
entry.updatedAt = at;
|
|
3073
|
+
changed += 1;
|
|
3074
|
+
}
|
|
3075
|
+
return changed ? { changed: true, kind: 'learning_expire' } : { changed: false };
|
|
3076
|
+
});
|
|
3077
|
+
return { ok: true, changed: result.changed === true, replayed: result.replayed === true };
|
|
3078
|
+
} catch (error) {
|
|
3079
|
+
return { ok: false, reason: error instanceof Error ? error.message : '没有清理' };
|
|
3080
|
+
}
|
|
3081
|
+
});
|
|
359
3082
|
}
|