dsh-lite-memory 0.18.1 → 0.18.19
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 +13 -13
- package/TRIGGERS.md +1 -1
- package/client.js +184 -34
- package/docs/RELEASE-0.18.10.md +25 -0
- package/docs/RELEASE-0.18.11.md +48 -0
- package/docs/RELEASE-0.18.12.md +27 -0
- package/docs/RELEASE-0.18.13.md +13 -0
- package/docs/RELEASE-0.18.14.md +13 -0
- package/docs/RELEASE-0.18.15.md +5 -0
- package/docs/RELEASE-0.18.2.md +21 -0
- package/docs/RELEASE-0.18.3.md +16 -0
- package/docs/RELEASE-0.18.4.md +15 -0
- package/docs/RELEASE-0.18.5.md +21 -0
- package/docs/RELEASE-0.18.6.md +18 -0
- package/docs/RELEASE-0.18.7.md +23 -0
- package/docs/RELEASE-0.18.8.md +18 -0
- package/docs/RELEASE-0.18.9.md +24 -0
- package/index.js +347 -123
- package/learning.js +65 -6
- package/package.json +3 -3
- package/state.js +47 -6
- package/store.js +180 -18
- package/test/client.test.mjs +47 -25
- package/test/contract.test.mjs +221 -86
- package/test/control.test.mjs +2 -2
- package/test/eval-seed.test.mjs +113 -0
- package/test/evolution-read.test.mjs +10 -10
- package/test/ha.test.mjs +55 -5
- package/test/layer-read.test.mjs +293 -0
- package/test/learning.test.mjs +80 -0
- package/test/palace.test.mjs +2 -0
- package/test/recall.test.mjs +67 -9
- package/test/state.test.mjs +57 -1
- package/test/store.test.mjs +49 -5
- package/test/view.test.mjs +3 -0
- package/view.js +85 -12
package/learning.js
CHANGED
|
@@ -40,6 +40,11 @@ const NEGATIVE = /不要|不用|别用|别再|停用|取消这条|这条不要|
|
|
|
40
40
|
const CORRECTION = /改成|改为|换成|重做|重来|再来|下次|不对|错了|不是这样|又没|怎么又|还是没|没按|不好|不行|不满意|不要|别/;
|
|
41
41
|
const EXECUTION_MISS = /之前说过|已经说过|不是说好|怎么又|又没|还是没|没按|又忘|重复/;
|
|
42
42
|
const UNCLEAR = /不太对|再想想|感觉不对|说不上|有点怪|不对劲/;
|
|
43
|
+
/**
|
|
44
|
+
* 点名项目范围才算全项目授权;「以后都」不等于「所有项目」。
|
|
45
|
+
* 「所有口播」「一律」这种覆盖全部对象的说法也算,但只限当前项目层。
|
|
46
|
+
*/
|
|
47
|
+
const PROJECT_WIDE = /(这个项目|本项目|所有项目|全项目|整个项目|全库|每个项目|所有(?!项目)|一律)/;
|
|
43
48
|
|
|
44
49
|
const has = (pattern, text) => pattern.test(text);
|
|
45
50
|
|
|
@@ -182,6 +187,8 @@ export function candidateFrom({ messages, anchor, sessionId, projectId, now } =
|
|
|
182
187
|
if (!context.projectId) return null;
|
|
183
188
|
/** 没有作品绑定时,规范纠正同样按项目候选收集;单次局部改动交给整理器判空,不再用电平词一刀切。 */
|
|
184
189
|
if (cue.type !== 'spec_change') return null;
|
|
190
|
+
/** 作用域在收集时就固定:只有原句点名项目范围才给全项目,否则先待确认。 */
|
|
191
|
+
const applicability = PROJECT_WIDE.test(text) ? 'all_in_scope' : 'unresolved';
|
|
185
192
|
return {
|
|
186
193
|
projectId: context.projectId,
|
|
187
194
|
sessionId: context.sessionId,
|
|
@@ -195,7 +202,7 @@ export function candidateFrom({ messages, anchor, sessionId, projectId, now } =
|
|
|
195
202
|
text,
|
|
196
203
|
sourceExcerpt,
|
|
197
204
|
cue,
|
|
198
|
-
expectedTarget: { scope: 'project', projectId, applicability
|
|
205
|
+
expectedTarget: { scope: 'project', projectId, applicability },
|
|
199
206
|
episodeUpdatedAt: '',
|
|
200
207
|
at,
|
|
201
208
|
};
|
|
@@ -356,13 +363,36 @@ export function pendingChangeFrom(feedback = {}) {
|
|
|
356
363
|
if (!currentText) return null;
|
|
357
364
|
const first = (lines[0] || currentText).split(/[,。!?;]/)[0].trim();
|
|
358
365
|
const topicKey = String(feedback.cue?.dimension || first || currentText).slice(0, 60);
|
|
366
|
+
/**
|
|
367
|
+
* 兜底不是授权:只在文本自己说到全项目时才按全项目收,否则保留它原本的适用范围
|
|
368
|
+
* (有任务种类就挂条件,没有就待确认),不能让一句「以后开头先给结论」变成全库规则。
|
|
369
|
+
*/
|
|
370
|
+
const expected = feedback.expectedTarget && typeof feedback.expectedTarget === 'object' ? feedback.expectedTarget : null;
|
|
371
|
+
const taskKind = String(feedback.cue?.taskKind || expected?.taskKind || '').trim();
|
|
372
|
+
const expectedConditions = ['taskKind', 'seriesId', 'phase']
|
|
373
|
+
.filter((key) => String(expected?.[key] || '').trim())
|
|
374
|
+
.map((key) => ({ key, value: String(expected[key]).trim() }));
|
|
375
|
+
/**
|
|
376
|
+
* 兜底不重新推导作用域:候选收集时已经固定 expectedTarget 就照抄(只能沿用或收窄),
|
|
377
|
+
* 没有 expectedTarget 的旧调用才退回原句判断。
|
|
378
|
+
*/
|
|
379
|
+
const applicability = expected
|
|
380
|
+
? (expected.applicability === 'all_in_scope'
|
|
381
|
+
? 'all_in_scope'
|
|
382
|
+
: expectedConditions.length ? 'conditional' : 'unresolved')
|
|
383
|
+
: /(所有|全都|一律|任何|默认|整个项目|这个项目|本项目)/.test(currentText)
|
|
384
|
+
? 'all_in_scope'
|
|
385
|
+
: taskKind ? 'conditional' : 'unresolved';
|
|
386
|
+
const conditions = expected
|
|
387
|
+
? (applicability === 'conditional' ? expectedConditions : [])
|
|
388
|
+
: applicability === 'conditional' ? [{ key: 'taskKind', value: taskKind }] : [];
|
|
359
389
|
return {
|
|
360
390
|
target: 'new',
|
|
361
391
|
feedbackType: 'spec_change',
|
|
362
392
|
topicKey,
|
|
363
393
|
currentText,
|
|
364
|
-
applicability
|
|
365
|
-
conditions
|
|
394
|
+
applicability,
|
|
395
|
+
conditions,
|
|
366
396
|
evidence: ['u0'],
|
|
367
397
|
persistence: 'project_candidate',
|
|
368
398
|
/** 摘录从候选一路带到提交路径,模型返回空时也不丢来源。 */
|
|
@@ -504,7 +534,7 @@ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase
|
|
|
504
534
|
return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt), sourceExcerpt, messageIds };
|
|
505
535
|
}
|
|
506
536
|
/** 严格解析:截断、工具调用、额外字段、超限都整批拒绝,不做第二次修复调用。 */
|
|
507
|
-
export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
|
|
537
|
+
export function parseOutput(text, { finish, refs, rules, ranges, expectedTarget } = {}) {
|
|
508
538
|
if (finish && finish.kind !== 'stop' && finish.kind !== 'completed') return { ok: false, reason: '整理没有正常结束' };
|
|
509
539
|
const raw = String(text || '').trim();
|
|
510
540
|
if (!raw) return { ok: false, reason: '整理没有返回内容' };
|
|
@@ -522,14 +552,14 @@ export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
|
|
|
522
552
|
if (data.changes.length > LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
|
|
523
553
|
const changes = [];
|
|
524
554
|
for (const change of data.changes) {
|
|
525
|
-
const checked = validateChange(change, { refs, rules, ranges });
|
|
555
|
+
const checked = validateChange(change, { refs, rules, ranges, expectedTarget });
|
|
526
556
|
if (!checked.ok) return checked;
|
|
527
557
|
changes.push(checked.change);
|
|
528
558
|
}
|
|
529
559
|
return { ok: true, changes };
|
|
530
560
|
}
|
|
531
561
|
|
|
532
|
-
export function validateChange(change, { refs, rules, ranges } = {}) {
|
|
562
|
+
export function validateChange(change, { refs, rules, ranges, expectedTarget } = {}) {
|
|
533
563
|
if (!change || typeof change !== 'object' || Array.isArray(change)) return { ok: false, reason: '变化不是对象' };
|
|
534
564
|
for (const key of Object.keys(change)) if (!ALLOWED_KEYS.has(key)) return { ok: false, reason: `变化里有未知字段:${key}` };
|
|
535
565
|
const byRef = new Map((Array.isArray(rules) ? rules : []).map((row) => [row.ref, row]));
|
|
@@ -590,6 +620,35 @@ export function validateChange(change, { refs, rules, ranges } = {}) {
|
|
|
590
620
|
if (!allowed) return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
|
|
591
621
|
}
|
|
592
622
|
}
|
|
623
|
+
/** 收集时固定的作用域只能沿用或收窄:整理器不能把待确认候选抬成全项目规则。 */
|
|
624
|
+
const expected = expectedTarget && typeof expectedTarget === 'object' ? expectedTarget : null;
|
|
625
|
+
const expectedConditions = ['taskKind', 'seriesId', 'phase']
|
|
626
|
+
.filter((key) => String(expected?.[key] || '').trim())
|
|
627
|
+
.map((key) => ({ key, value: String(expected[key]).trim() }));
|
|
628
|
+
const expectedApplicability = expected
|
|
629
|
+
? (expected.applicability === 'all_in_scope'
|
|
630
|
+
? 'all_in_scope'
|
|
631
|
+
: expectedConditions.length ? 'conditional' : 'unresolved')
|
|
632
|
+
: '';
|
|
633
|
+
if (expectedApplicability && applicability === 'all_in_scope' && expectedApplicability !== 'all_in_scope') {
|
|
634
|
+
return { ok: false, reason: '适用范围不能从待确认抬成全项目' };
|
|
635
|
+
}
|
|
636
|
+
if (expected && expected.scope === 'episode' && applicability === 'all_in_scope') {
|
|
637
|
+
return { ok: false, reason: '作品规则不能抬成全项目' };
|
|
638
|
+
}
|
|
639
|
+
const effectiveConditions = conditions !== undefined ? conditions : normalizeConditions(existing?.conditions);
|
|
640
|
+
if (applicability === 'all_in_scope' && effectiveConditions.length) {
|
|
641
|
+
return { ok: false, reason: '全项目规则不能带条件' };
|
|
642
|
+
}
|
|
643
|
+
if (applicability === 'conditional' && !conditions && !existing && !sampleOnly) {
|
|
644
|
+
return { ok: false, reason: '条件适用必须给出条件' };
|
|
645
|
+
}
|
|
646
|
+
if (conditions && expectedConditions.length) {
|
|
647
|
+
const has = new Set(conditions.map((row) => `${row.key}=${row.value}`));
|
|
648
|
+
if (expectedConditions.some((row) => !has.has(`${row.key}=${row.value}`))) {
|
|
649
|
+
return { ok: false, reason: '条件不能删掉收集时的任务范围' };
|
|
650
|
+
}
|
|
651
|
+
}
|
|
593
652
|
const evidence = Array.isArray(change.evidence) ? change.evidence.map((value) => String(value)) : [];
|
|
594
653
|
if (!evidence.length || evidence.length > LIMITS.evidence) return { ok: false, reason: '证据数量不对' };
|
|
595
654
|
for (const ref of evidence) if (!refs?.feedback?.[ref]) return { ok: false, reason: '证据不在本次反馈里' };
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-lite-memory",
|
|
3
|
-
"version": "0.18.
|
|
4
|
-
"description": "On-demand memory for the DeepSeek Harness: nothing
|
|
3
|
+
"version": "0.18.19",
|
|
4
|
+
"description": "On-demand memory for the DeepSeek Harness: nothing resident, at most three small cards on a real hit, bodies read on demand.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"engines": {
|
|
8
8
|
"dsh": ">=0.1.5"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs",
|
|
11
|
+
"test": "node test/store.test.mjs && node test/state.test.mjs && node test/view.test.mjs && node test/anchor.test.mjs && node test/recall.test.mjs && node test/evolution-read.test.mjs && node test/lexical.test.mjs && node test/layer-read.test.mjs && node test/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/runtime-context.test.mjs && node test/client.test.mjs && node test/palace.test.mjs && node test/contract.test.mjs && node test/safety.test.mjs && node test/ha.test.mjs && node test/record.test.mjs && node test/eval-seed.test.mjs",
|
|
12
12
|
"bench": "node bench/bench.mjs",
|
|
13
13
|
"bench:evolution": "node bench/evolution.mjs"
|
|
14
14
|
},
|
package/state.js
CHANGED
|
@@ -106,12 +106,13 @@ export function evolutionOf(state) {
|
|
|
106
106
|
export function controlOf(state) {
|
|
107
107
|
const raw = state && state.control;
|
|
108
108
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
109
|
-
return { enabled: true, epoch: '', changedAt: '', compatible: true };
|
|
109
|
+
return { enabled: true, epoch: '', changedAt: '', recoveryPending: false, compatible: true };
|
|
110
110
|
}
|
|
111
111
|
return {
|
|
112
|
-
enabled: raw.enabled !== false,
|
|
112
|
+
enabled: raw.enabled !== false && raw.recoveryPending !== true,
|
|
113
113
|
epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
|
|
114
114
|
changedAt: typeof raw.changedAt === 'string' ? raw.changedAt : '',
|
|
115
|
+
recoveryPending: raw.recoveryPending === true,
|
|
115
116
|
compatible: true,
|
|
116
117
|
};
|
|
117
118
|
}
|
|
@@ -143,13 +144,39 @@ export function applyControl(draft, enabled) {
|
|
|
143
144
|
return { changed: true, enabled: next, epoch: config.epoch };
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/**
|
|
148
|
+
* 旧策略生成的实验规则没记下作用域指纹,无法证明它该在哪些任务里出现。
|
|
149
|
+
* 只把它们转成待复核:正文、来源、版本原样保留,不删除也不改写内容。
|
|
150
|
+
*/
|
|
151
|
+
function quarantineLegacyRules(draft, now) {
|
|
152
|
+
let touched = 0;
|
|
153
|
+
for (const entry of Array.isArray(draft.entries) ? draft.entries : []) {
|
|
154
|
+
if (!entry || entry.origin !== 'project_evolution') continue;
|
|
155
|
+
const maintenance = entry.maintenance || (entry.maintenance = {});
|
|
156
|
+
if (maintenance.reviewState === 'legacy_unreviewed') continue;
|
|
157
|
+
if (maintenance.learning && maintenance.learning.specDigest) continue;
|
|
158
|
+
maintenance.reviewState = 'legacy_unreviewed';
|
|
159
|
+
maintenance.legacyReview = { reason: 'scope_unverified', at: now };
|
|
160
|
+
const learning = maintenance.learning;
|
|
161
|
+
if (learning && (learning.state === 'trial' || learning.state === 'active')) {
|
|
162
|
+
learning.state = 'paused';
|
|
163
|
+
learning.blockedReason = 'needs_scope_review';
|
|
164
|
+
learning.pausedAt = now;
|
|
165
|
+
learning.pausedBy = 'legacy_scope';
|
|
166
|
+
}
|
|
167
|
+
touched += 1;
|
|
168
|
+
}
|
|
169
|
+
return touched;
|
|
170
|
+
}
|
|
171
|
+
|
|
146
172
|
/** 自进化开关只停运行层:规则、候选、试用、证据和额度都保留,恢复时接着用。 */
|
|
147
173
|
export function applyEvolution(draft, enabled) {
|
|
148
174
|
const now = new Date().toISOString();
|
|
149
175
|
const current = evolutionOf(draft);
|
|
150
176
|
if (!current.compatible) throw new StateError('invalid', '实验配置版本不认,先不要改动开关');
|
|
151
177
|
const next = Boolean(enabled);
|
|
152
|
-
|
|
178
|
+
const quarantined = next ? quarantineLegacyRules(draft, now) : 0;
|
|
179
|
+
if (current.enabled === next) return { changed: quarantined > 0, enabled: next, quarantined };
|
|
153
180
|
const config = {
|
|
154
181
|
enabled: next,
|
|
155
182
|
epoch: crypto.randomUUID(),
|
|
@@ -174,7 +201,7 @@ export function applyEvolution(draft, enabled) {
|
|
|
174
201
|
}
|
|
175
202
|
}
|
|
176
203
|
}
|
|
177
|
-
return { changed: true, enabled: next, epoch: config.epoch };
|
|
204
|
+
return { changed: true, enabled: next, epoch: config.epoch, quarantined };
|
|
178
205
|
}
|
|
179
206
|
|
|
180
207
|
/** 容量与预算都归这里管:写入路径只报「要写什么」,能不能落地由 state 决定。 */
|
|
@@ -1142,6 +1169,20 @@ export function migrateLegacy(scope, options = {}) {
|
|
|
1142
1169
|
});
|
|
1143
1170
|
}
|
|
1144
1171
|
|
|
1172
|
+
/** 恢复出来的状态把总开关留在关闭态并记下原因,避免用一份旧快照静默恢复授权。 */
|
|
1173
|
+
function withRecoveryMarker(payload) {
|
|
1174
|
+
const raw = payload && payload.control && typeof payload.control === 'object' && !Array.isArray(payload.control) ? payload.control : {};
|
|
1175
|
+
return {
|
|
1176
|
+
...payload,
|
|
1177
|
+
control: {
|
|
1178
|
+
enabled: false,
|
|
1179
|
+
epoch: typeof raw.epoch === 'string' ? raw.epoch : '',
|
|
1180
|
+
changedAt: new Date().toISOString(),
|
|
1181
|
+
recoveryPending: true,
|
|
1182
|
+
},
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1145
1186
|
/**
|
|
1146
1187
|
* 读当前状态:只有「完全没有」才算新库;坏文件、未知版本、迁移痕迹都报错,
|
|
1147
1188
|
* 绝不用空库覆盖。
|
|
@@ -1173,7 +1214,7 @@ export function load(scope, options = {}) {
|
|
|
1173
1214
|
}
|
|
1174
1215
|
if (recovered) {
|
|
1175
1216
|
return {
|
|
1176
|
-
state: recovered,
|
|
1217
|
+
state: withRecoveryMarker(recovered),
|
|
1177
1218
|
exists: true,
|
|
1178
1219
|
recovered: true,
|
|
1179
1220
|
recovery: {
|
|
@@ -1204,7 +1245,7 @@ export function load(scope, options = {}) {
|
|
|
1204
1245
|
const prev = readEnvelope(prevFile(scope));
|
|
1205
1246
|
if (prev) {
|
|
1206
1247
|
// current 丢了但 prev 完好:恢复 prev,并标记需要重写 current。
|
|
1207
|
-
return { state: prev, exists: false, recovered: true };
|
|
1248
|
+
return { state: withRecoveryMarker(prev), exists: false, recovered: true };
|
|
1208
1249
|
}
|
|
1209
1250
|
return { state: emptyState(scope), exists: false, recovered: false };
|
|
1210
1251
|
}
|
package/store.js
CHANGED
|
@@ -1011,6 +1011,24 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1011
1011
|
if (draft.id && !record) throw new Error('没有这条记忆,改用新增');
|
|
1012
1012
|
let mergedFrom = null;
|
|
1013
1013
|
let similarList = [];
|
|
1014
|
+
/**
|
|
1015
|
+
* 同名就是同一条:标题是唯一句柄,重复存同一个标题必须是更新,不能悄悄另建一条
|
|
1016
|
+
* (否则按标题读、按标题忘都会撞上两条)。前置条件明确不同的同名才允许是另一条。
|
|
1017
|
+
*/
|
|
1018
|
+
if (!record && !distinct) {
|
|
1019
|
+
const wantedConditions = view.conditionsKey(draft.conditions || []);
|
|
1020
|
+
/** 只改摘要/正文时没重写适用条件,别因此把有条件的那条挤成另一条。 */
|
|
1021
|
+
const statedConditions = Boolean(draft.has.when || draft.has.conditions || draft.has.applicability);
|
|
1022
|
+
const named = live.find((item) => [item.title, ...(item.aliases || [])]
|
|
1023
|
+
.some((name) => String(name || '').trim().toLowerCase() === String(draft.title || '').trim().toLowerCase())
|
|
1024
|
+
&& (!statedConditions || view.conditionsKey(item.conditions || []) === wantedConditions)
|
|
1025
|
+
/** 同名但两边都明确写了不同的 when,是用户表达的另一种情况,算另一条。 */
|
|
1026
|
+
&& !(draft.has.when && String(item.when || '') && String(item.when || '') !== String(draft.when || '')));
|
|
1027
|
+
if (named) {
|
|
1028
|
+
record = named;
|
|
1029
|
+
mergedFrom = named.title === draft.title ? null : named.title;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1014
1032
|
if (!record) {
|
|
1015
1033
|
/** distinct 是用户明确说的「这是另一条」:同名也另起一条,不被规范化后的精确同名吞掉。 */
|
|
1016
1034
|
const hit = distinct ? null : resolveTarget(live, draft);
|
|
@@ -1045,6 +1063,13 @@ function applyDraft(scope, index, source, now, options = {}) {
|
|
|
1045
1063
|
}
|
|
1046
1064
|
}
|
|
1047
1065
|
const writeMode = source && source.writeMode === 'replace_current' ? 'replace_current' : 'patch';
|
|
1066
|
+
/** 改名不能撞上另一条现行记忆:标题是唯一句柄,撞了以后按标题读会分不清。 */
|
|
1067
|
+
if (record && draft.has.title && cleanTitle(draft.title) !== record.title) {
|
|
1068
|
+
const wanted = cleanTitle(draft.title).toLowerCase();
|
|
1069
|
+
const clash = live.find((item) => item.id !== record.id
|
|
1070
|
+
&& [item.title, ...(item.aliases || [])].some((name) => String(name || '').trim().toLowerCase() === wanted));
|
|
1071
|
+
if (clash) throw new Error(`已经有同名记忆「${clash.title}」,先改那一条的标题`);
|
|
1072
|
+
}
|
|
1048
1073
|
if (record && writeMode === 'replace_current') {
|
|
1049
1074
|
for (const key of ['title', 'summary', 'when', 'body', 'kind', 'tags']) {
|
|
1050
1075
|
if (!draft.has[key]) throw new Error(`整份替换要给出 ${key},空值也要显式写`);
|
|
@@ -1553,8 +1578,10 @@ export function peek(scope, query, limit = 3, options = {}) {
|
|
|
1553
1578
|
}
|
|
1554
1579
|
|
|
1555
1580
|
export function readEntry(scope, id, options = {}) {
|
|
1556
|
-
|
|
1557
|
-
|
|
1581
|
+
/** 显式指定层时只读那一层;默认仍只读当前项目和全局。 */
|
|
1582
|
+
const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : runtimeLayers(scope);
|
|
1583
|
+
const found = withLocks(sources, () => {
|
|
1584
|
+
for (const source of sources) {
|
|
1558
1585
|
const index = loadIndex(source);
|
|
1559
1586
|
const record = index.entries.find((item) => item.id === id);
|
|
1560
1587
|
const archived = record ? null : index.archived.find((item) => item.id === id);
|
|
@@ -1575,8 +1602,9 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1575
1602
|
return null;
|
|
1576
1603
|
});
|
|
1577
1604
|
if (!found) return null;
|
|
1578
|
-
/**
|
|
1579
|
-
|
|
1605
|
+
/** 读过才算用过;归档、被取代、过期或条件不匹配的读取都不算「在用」。 */
|
|
1606
|
+
const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
|
|
1607
|
+
if (!found.archived && countsAsUsage) touchUsed(found.source, [id]);
|
|
1580
1608
|
return found.record;
|
|
1581
1609
|
}
|
|
1582
1610
|
|
|
@@ -1587,11 +1615,14 @@ export function readEntry(scope, id, options = {}) {
|
|
|
1587
1615
|
export function forget(scope, id, options = {}) {
|
|
1588
1616
|
const mode = options.mode;
|
|
1589
1617
|
if (mode !== 'archive' && mode !== 'purge') return { id, mode: 'needs_resolution' };
|
|
1590
|
-
|
|
1618
|
+
/** 显式指定的层就是唯一范围;不然才按运行时「当前项目 + 全局」找。 */
|
|
1619
|
+
const sources = Array.isArray(options.layers) && options.layers.length ? options.layers : null;
|
|
1620
|
+
const scopeList = sources || [scope, globalScope()];
|
|
1621
|
+
return withLocks(scopeList, () => {
|
|
1591
1622
|
/** 屏障优先:上次彻底删除没做完时,先按原层 + 原 operationId 收尾,再决定这条在哪。 */
|
|
1592
1623
|
let replayed = null;
|
|
1593
1624
|
if (mode === 'purge') {
|
|
1594
|
-
for (const source of
|
|
1625
|
+
for (const source of scopeList) {
|
|
1595
1626
|
const pending = state.purgeIntent(source);
|
|
1596
1627
|
if (!pending) continue;
|
|
1597
1628
|
/** 冷存里有待删条目的正文副本:先隔离,state.purge 的残留扫描才收得干净。 */
|
|
@@ -1612,7 +1643,7 @@ export function forget(scope, id, options = {}) {
|
|
|
1612
1643
|
}
|
|
1613
1644
|
if (replayed) return replayed;
|
|
1614
1645
|
}
|
|
1615
|
-
for (const source of
|
|
1646
|
+
for (const source of scopeList) {
|
|
1616
1647
|
const index = loadIndex(source);
|
|
1617
1648
|
const active = index.entries.find((item) => item.id === id);
|
|
1618
1649
|
const archived = active ? null : index.archived.find((item) => item.id === id);
|
|
@@ -1664,8 +1695,8 @@ export function forget(scope, id, options = {}) {
|
|
|
1664
1695
|
|
|
1665
1696
|
/** 直接读邻居:只给当前投影,不缓存目标正文;目标不在了就标 invalid,不拿旧版本顶上。 */
|
|
1666
1697
|
export function readLinks(scope, id, options = {}) {
|
|
1667
|
-
return withLocks(
|
|
1668
|
-
for (const source of
|
|
1698
|
+
return withLocks(runtimeLayers(scope), () => {
|
|
1699
|
+
for (const source of runtimeLayers(scope)) {
|
|
1669
1700
|
const index = loadIndex(source);
|
|
1670
1701
|
const record = index.entries.find((item) => item.id === id);
|
|
1671
1702
|
if (!record) continue;
|
|
@@ -1684,7 +1715,7 @@ export function readLinks(scope, id, options = {}) {
|
|
|
1684
1715
|
|
|
1685
1716
|
/** 关联目标按当前投影现场解引用:先项目层再全局层,归档和删除都算不存在。 */
|
|
1686
1717
|
function findCurrent(id, scope, options = {}) {
|
|
1687
|
-
for (const source of
|
|
1718
|
+
for (const source of runtimeLayers(scope)) {
|
|
1688
1719
|
let index;
|
|
1689
1720
|
try {
|
|
1690
1721
|
index = loadIndex(source);
|
|
@@ -1823,9 +1854,51 @@ export function findAnywhere(id) {
|
|
|
1823
1854
|
return null;
|
|
1824
1855
|
}
|
|
1825
1856
|
|
|
1857
|
+
/** 运行时只允许当前项目和全局层:别的项目仅供人类管理面只读枚举。 */
|
|
1858
|
+
export function runtimeLayers(scope) {
|
|
1859
|
+
const out = [];
|
|
1860
|
+
const seen = new Set();
|
|
1861
|
+
const push = (item) => {
|
|
1862
|
+
if (!item || !item.dir || seen.has(item.dir)) return;
|
|
1863
|
+
seen.add(item.dir);
|
|
1864
|
+
out.push(item);
|
|
1865
|
+
};
|
|
1866
|
+
push(scope);
|
|
1867
|
+
push(globalScope());
|
|
1868
|
+
return out;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1826
1871
|
/** 按 id 在项目层与全局层里找位置,不靠文案猜作用域;管理动作也要看得见已归档的条目。 */
|
|
1872
|
+
/**
|
|
1873
|
+
* 标题是唯一对外的句柄:当前项目找不到,就去全局和别的项目层找。
|
|
1874
|
+
* 只用于读路径(读正文、查关联、解析标题);写入仍然只动自己那层。
|
|
1875
|
+
*/
|
|
1876
|
+
export function layersFor(scope) {
|
|
1877
|
+
const out = [];
|
|
1878
|
+
const seen = new Set();
|
|
1879
|
+
const push = (item) => {
|
|
1880
|
+
if (!item || !item.dir || seen.has(item.dir)) return;
|
|
1881
|
+
seen.add(item.dir);
|
|
1882
|
+
out.push(item);
|
|
1883
|
+
};
|
|
1884
|
+
push(scope);
|
|
1885
|
+
push(globalScope());
|
|
1886
|
+
try {
|
|
1887
|
+
for (const row of state.knownProjectScopes()) push({ key: row.key, dir: row.dir, cwd: row.cwd });
|
|
1888
|
+
} catch { /* 层列表读不出来就只查当前层和全局,不影响读取 */ }
|
|
1889
|
+
return out;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1827
1892
|
export function locate(scope, id, options = {}) {
|
|
1828
|
-
|
|
1893
|
+
/**
|
|
1894
|
+
* 默认只看当前层和全局:面板/HTTP 用 id 探正文时跨层就是越权。
|
|
1895
|
+
* 工具侧按标题读,明确传 crossLayer 才跨层(标题是用户给的句柄);
|
|
1896
|
+
* 显式指定某一层时只给那一层,绝不拿项目层去命中全局条目。
|
|
1897
|
+
*/
|
|
1898
|
+
const sources = Array.isArray(options.layers) && options.layers.length
|
|
1899
|
+
? options.layers
|
|
1900
|
+
: options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
|
|
1901
|
+
for (const source of sources) {
|
|
1829
1902
|
const index = loadIndex(source, options);
|
|
1830
1903
|
const active = index.entries.find((item) => item.id === id);
|
|
1831
1904
|
if (active) return { scope: source, record: active, archived: false };
|
|
@@ -1835,6 +1908,35 @@ export function locate(scope, id, options = {}) {
|
|
|
1835
1908
|
return null;
|
|
1836
1909
|
}
|
|
1837
1910
|
|
|
1911
|
+
/**
|
|
1912
|
+
* 标题是界面和模型唯一的句柄:在给定范围里按标题(含别名)找那一条。
|
|
1913
|
+
* 同层可能同名但适用条件不同,所以带了版次就先按版次收敛,再退回第一条。
|
|
1914
|
+
*/
|
|
1915
|
+
export function locateByTitle(scope, title, options = {}) {
|
|
1916
|
+
const wanted = normalizeText(title, 120).toLowerCase();
|
|
1917
|
+
if (!wanted) return null;
|
|
1918
|
+
const sources = options.crossLayer ? layersFor(scope) : runtimeLayers(scope);
|
|
1919
|
+
const want = Number.isInteger(options.revision) ? options.revision : null;
|
|
1920
|
+
for (const source of sources) {
|
|
1921
|
+
const index = loadIndex(source, options);
|
|
1922
|
+
const pick = (list) => {
|
|
1923
|
+
const rows = list.filter((item) => [item.title, ...(Array.isArray(item.aliases) ? item.aliases : [])]
|
|
1924
|
+
.some((name) => normalizeText(name, 120).toLowerCase() === wanted));
|
|
1925
|
+
if (rows.length === 0) return null;
|
|
1926
|
+
if (want !== null) {
|
|
1927
|
+
const exact = rows.find((item) => (Number(item.revision) || 1) === want);
|
|
1928
|
+
if (exact) return exact;
|
|
1929
|
+
}
|
|
1930
|
+
return rows[0];
|
|
1931
|
+
};
|
|
1932
|
+
const active = pick(index.entries);
|
|
1933
|
+
if (active) return { scope: source, record: active, archived: false };
|
|
1934
|
+
const archived = pick(index.archived);
|
|
1935
|
+
if (archived) return { scope: source, record: archived, archived: true };
|
|
1936
|
+
}
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1838
1940
|
function forgottenFingerprints(index) {
|
|
1839
1941
|
const out = new Set();
|
|
1840
1942
|
for (const row of index.suppressions || []) {
|
|
@@ -2114,7 +2216,7 @@ export function renderIndex(entries, budget = 400) {
|
|
|
2114
2216
|
const stateTag = item.state === 'archived' ? ' · 已归档' : item.state === 'needs_context' ? ' · 适用范围待确认' : item.state === 'not_applicable' ? ' · 当前条件不适用' : '';
|
|
2115
2217
|
const originTag = item.origin === 'project_evolution' ? ' · 实验' : '';
|
|
2116
2218
|
const tag = item.scope === 'global' ? '[global]' : item.scopeName ? `[${item.scopeName}]` : '';
|
|
2117
|
-
const head = `${tag ? `${tag} ` : ''}
|
|
2219
|
+
const head = `${tag ? `${tag} ` : ''}「${item.title}」${item.when ? `(${item.when})` : ''}${originTag}${stateTag}`;
|
|
2118
2220
|
const line = stale ? `- ${head}` : `- ${head}|${item.summary}`;
|
|
2119
2221
|
if (used + line.length > budget) break;
|
|
2120
2222
|
lines.push(line);
|
|
@@ -2288,6 +2390,30 @@ export function readCitationBody(sessionId, id) {
|
|
|
2288
2390
|
const data = readSessionFile(sessionId);
|
|
2289
2391
|
const row = ((data && data.items) || []).find((item) => item && item.id === id);
|
|
2290
2392
|
if (!row) return null;
|
|
2393
|
+
return readCitationRow(sessionId, row);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** 按标题读引用正文:界面手里只有标题,id 留在引用文件里。 */
|
|
2397
|
+
export function readCitationBodyByTitle(sessionId, title, options = {}) {
|
|
2398
|
+
const wanted = normalizeText(title, 120).toLowerCase();
|
|
2399
|
+
if (!wanted) return null;
|
|
2400
|
+
const data = readSessionFile(sessionId);
|
|
2401
|
+
const want = Number.isInteger(options.revision) ? options.revision : null;
|
|
2402
|
+
const scopeWanted = options.scope === 'global' || options.scope === 'project' ? options.scope : '';
|
|
2403
|
+
/** 引用行只存身份,标题要从当前索引解析出来再比;行上没有 title 字段。 */
|
|
2404
|
+
const matches = ((data && data.items) || [])
|
|
2405
|
+
.map((row) => ({ row, found: resolveCitation(row) }))
|
|
2406
|
+
.filter((item) => item.found
|
|
2407
|
+
&& (!scopeWanted || (item.found.scope.key === 'global' ? 'global' : 'project') === scopeWanted)
|
|
2408
|
+
&& normalizeText(item.found.row.title || '', 120).toLowerCase() === wanted);
|
|
2409
|
+
if (matches.length === 0) return null;
|
|
2410
|
+
const chosen = (want !== null
|
|
2411
|
+
&& matches.find((item) => (Number(item.found.entry.revision) || 1) === want)) || matches[0];
|
|
2412
|
+
return readCitationRow(sessionId, chosen.row);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function readCitationRow(sessionId, row) {
|
|
2416
|
+
const id = row.id;
|
|
2291
2417
|
const found = resolveCitation(row);
|
|
2292
2418
|
if (!found) return null;
|
|
2293
2419
|
let body;
|
|
@@ -2300,6 +2426,7 @@ export function readCitationBody(sessionId, id) {
|
|
|
2300
2426
|
}
|
|
2301
2427
|
return {
|
|
2302
2428
|
...found.row,
|
|
2429
|
+
status: found.entry.status || 'active',
|
|
2303
2430
|
scopeLabel: found.scope.key === 'global' ? '全局' : path.basename(found.scope.cwd || '') || found.scope.key,
|
|
2304
2431
|
body: body.slice(0, LIMITS.entryBytes),
|
|
2305
2432
|
};
|
|
@@ -2474,12 +2601,15 @@ function crossLayerSimilar(projectScope, targetScope, draft) {
|
|
|
2474
2601
|
}
|
|
2475
2602
|
|
|
2476
2603
|
function bindSameTopic(projectScope, ref) {
|
|
2477
|
-
if (!ref || typeof ref !== 'object'
|
|
2604
|
+
if (!ref || typeof ref !== 'object') return { error: 'sameTopicAs 要写成 {scope, title}' };
|
|
2478
2605
|
const source = ref.scope === 'global' ? globalScope() : projectScope;
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2606
|
+
/** 句柄统一用标题:id 是底层实现,模型手里不该出现;老调用传 id 仍认。 */
|
|
2607
|
+
const byTitle = typeof ref.title === 'string' ? normalizeText(ref.title, 120).toLowerCase() : '';
|
|
2608
|
+
const record = loadIndex(source).entries.find((item) => (typeof ref.id === 'string' && item.id === ref.id)
|
|
2609
|
+
|| (byTitle && [item.title, ...(item.aliases || [])].some((name) => normalizeText(name, 120).toLowerCase() === byTitle)));
|
|
2610
|
+
if (!record) return { error: `sameTopicAs 指向的记忆不存在:${ref.title || ref.id || ''}` };
|
|
2611
|
+
if (ref.revision !== undefined && Number(ref.revision) !== Number(record.revision || 1)) {
|
|
2612
|
+
return { error: `sameTopicAs 的目标已经改过:「${record.title}」` };
|
|
2483
2613
|
}
|
|
2484
2614
|
return { topicKey: view.topicSlot(record) };
|
|
2485
2615
|
}
|
|
@@ -2531,8 +2661,21 @@ export function pendingConflictsOf(scope) {
|
|
|
2531
2661
|
* 保存的唯一入口:先路由,再在两层里查重(完全相同 → existing_ref;只是相近 → 只给候选),
|
|
2532
2662
|
* 最后才写。写之前判不出来的范围返回 needsScope,不替用户选。
|
|
2533
2663
|
*/
|
|
2664
|
+
function saveTargetOf(projectScope, source) {
|
|
2665
|
+
/** 更新已有条目:先按 id 认出它到底在哪一层,只锁那一层。 */
|
|
2666
|
+
const id = source && typeof source.id === 'string' ? source.id.trim() : '';
|
|
2667
|
+
if (id) {
|
|
2668
|
+
const found = locateInLayers(projectScope, id);
|
|
2669
|
+
if (found) return found.scope;
|
|
2670
|
+
}
|
|
2671
|
+
/** 新增条目:归属由路由定,写全局就不该占住项目锁,反之亦然。 */
|
|
2672
|
+
const routed = routeScope(source, { project: projectScope });
|
|
2673
|
+
return routed.chosenScope === 'global' ? globalScope() : projectScope;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2534
2676
|
export function saveEntry(projectScope, source) {
|
|
2535
|
-
|
|
2677
|
+
/** 只锁真正要写的那一层:别的项目写它自己的条目时,不会因为共享全局锁互相等待。 */
|
|
2678
|
+
return withLocks([saveTargetOf(projectScope, source)], () => {
|
|
2536
2679
|
const control = loadIndex(projectScope).__control || state.controlOf(null);
|
|
2537
2680
|
if (!control.enabled) {
|
|
2538
2681
|
return { needsScope: true, chosenScope: '', reasonCode: 'memory_disabled', requiredConditions: [], needsResolution: false, similarList: [], draft: null };
|
|
@@ -2554,7 +2697,23 @@ export function saveEntry(projectScope, source) {
|
|
|
2554
2697
|
if (next.id) {
|
|
2555
2698
|
const found = locateInLayers(projectScope, next.id);
|
|
2556
2699
|
if (!found) throw new Error('没有这条记忆,改用新增');
|
|
2700
|
+
/** 写了 scope 就只改那一层:同名条目在别层时不能悄悄改到别处。 */
|
|
2701
|
+
const explicitScope = String(source.scope || '').trim();
|
|
2702
|
+
if (explicitScope === 'global' && found.scope.key !== 'global') {
|
|
2703
|
+
throw new Error('这条记忆在项目层;scope="global" 只改全局同名条目');
|
|
2704
|
+
}
|
|
2705
|
+
if (explicitScope === 'project' && found.scope.key === 'global') {
|
|
2706
|
+
throw new Error('这条记忆在全局层;只有明确说「所有项目都这样」才改它');
|
|
2707
|
+
}
|
|
2557
2708
|
targetScope = found.scope;
|
|
2709
|
+
/** 面板拿旧版本改正文:中间被别处改过就拒绝,不覆盖别人的修改。 */
|
|
2710
|
+
if (source.expectedRevision !== undefined) {
|
|
2711
|
+
const current = loadIndex(targetScope).entries.find((item) => item.id === next.id && item.status !== 'archived');
|
|
2712
|
+
if (!current) throw new Error('没有这条记忆,改用新增');
|
|
2713
|
+
if (!Number.isInteger(Number(source.expectedRevision)) || Number(current.revision) !== Number(source.expectedRevision)) {
|
|
2714
|
+
throw new state.StateError('conflict', '这条记忆已经被更新过,刷新后再改');
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2558
2717
|
}
|
|
2559
2718
|
/** 更新已有项目条目也必须落在确认过的根目录里。 */
|
|
2560
2719
|
if (targetScope.key !== 'global' && targetScope.confirmed === false) {
|
|
@@ -2574,6 +2733,8 @@ export function saveEntry(projectScope, source) {
|
|
|
2574
2733
|
return {
|
|
2575
2734
|
needsResolution: true,
|
|
2576
2735
|
crossLayer: true,
|
|
2736
|
+
/** 相近那条在哪一层:跨层「用同一个标题再存一次」得落在它那层,否则会另建一条。 */
|
|
2737
|
+
crossScope: near[0] && near[0].scope === 'global' ? 'global' : 'project',
|
|
2577
2738
|
similarList: near,
|
|
2578
2739
|
similar: near[0],
|
|
2579
2740
|
draft,
|
|
@@ -2926,6 +3087,7 @@ function learningRuleView(record) {
|
|
|
2926
3087
|
conditions: Array.isArray(record.conditions) ? record.conditions : [],
|
|
2927
3088
|
topicKey: record.topicKey || '',
|
|
2928
3089
|
origin: record.origin,
|
|
3090
|
+
reviewState: record.reviewState === 'legacy_unreviewed' ? 'legacy_unreviewed' : 'active',
|
|
2929
3091
|
state: record.learning?.state || 'pending',
|
|
2930
3092
|
samples: (Array.isArray(record.learning?.samples) ? record.learning.samples : []).slice(-4).map((row) => ({
|
|
2931
3093
|
dimension: row.dimension || '作品',
|