dsh-lite-memory 0.17.0 → 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.
Files changed (42) hide show
  1. package/README.md +15 -15
  2. package/TRIGGERS.md +1 -1
  3. package/client.js +343 -44
  4. package/docs/BENCH.md +10 -8
  5. package/docs/RELEASE-0.18.0.md +35 -0
  6. package/docs/RELEASE-0.18.1.md +18 -0
  7. package/docs/RELEASE-0.18.10.md +25 -0
  8. package/docs/RELEASE-0.18.11.md +48 -0
  9. package/docs/RELEASE-0.18.12.md +27 -0
  10. package/docs/RELEASE-0.18.13.md +13 -0
  11. package/docs/RELEASE-0.18.14.md +13 -0
  12. package/docs/RELEASE-0.18.15.md +5 -0
  13. package/docs/RELEASE-0.18.2.md +21 -0
  14. package/docs/RELEASE-0.18.3.md +16 -0
  15. package/docs/RELEASE-0.18.4.md +15 -0
  16. package/docs/RELEASE-0.18.5.md +21 -0
  17. package/docs/RELEASE-0.18.6.md +18 -0
  18. package/docs/RELEASE-0.18.7.md +23 -0
  19. package/docs/RELEASE-0.18.8.md +18 -0
  20. package/docs/RELEASE-0.18.9.md +24 -0
  21. package/index.js +403 -131
  22. package/learning.js +65 -6
  23. package/package.json +7 -4
  24. package/state.js +56 -8
  25. package/store.js +361 -33
  26. package/test/client.test.mjs +79 -17
  27. package/test/contract.test.mjs +231 -93
  28. package/test/control.test.mjs +2 -2
  29. package/test/crossing.test.mjs +22 -1
  30. package/test/eval-seed.test.mjs +113 -0
  31. package/test/evolution-read.test.mjs +33 -10
  32. package/test/ha.test.mjs +186 -0
  33. package/test/layer-read.test.mjs +293 -0
  34. package/test/learning.test.mjs +80 -0
  35. package/test/palace.test.mjs +2 -0
  36. package/test/recall.test.mjs +80 -9
  37. package/test/record.test.mjs +130 -0
  38. package/test/safety.test.mjs +119 -0
  39. package/test/state.test.mjs +57 -1
  40. package/test/store.test.mjs +49 -5
  41. package/test/view.test.mjs +3 -0
  42. package/view.js +97 -13
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: 'unresolved' },
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: 'all_in_scope',
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.17.0",
4
- "description": "On-demand memory for the DeepSeek Harness: nothing in the system prompt, one muted line only when something is saved, hit or suggested.",
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",
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
  },
@@ -66,6 +66,9 @@
66
66
  "bugs": {
67
67
  "url": "https://github.com/regrevia/dsh-lite-memory/issues"
68
68
  },
69
- "author": "regrevia",
69
+ "author": {
70
+ "name": "regrevia",
71
+ "url": "https://github.com/regrevia"
72
+ },
70
73
  "license": "MIT"
71
74
  }
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
- if (current.enabled === next) return { changed: false, enabled: next };
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 决定。 */
@@ -535,13 +562,17 @@ export function emptyState(scope) {
535
562
  candidates: [],
536
563
  receipts: [],
537
564
  suppressions: [],
565
+ conflicts: [],
538
566
  control: { enabled: true, epoch: crypto.randomUUID(), changedAt: now },
539
567
  experimental: { projectEvolution: newEvolution() },
540
568
  };
541
569
  }
542
570
 
571
+ /** 坏文件不自动覆盖(可能还能人工找回),但错误里必须给出下一步,不能只说「坏了」。 */
543
572
  function fail(file, why, code = 'corrupt') {
544
- return new StateError(code, `记忆状态${why},已停止读写:${file}`);
573
+ const name = path.basename(file);
574
+ const prev = name === 'state.json' ? 'state.prev.json' : `${name}.prev`;
575
+ return new StateError(code, `记忆状态${why},已停止读写:${file}。恢复:把 ${name} 改名成 ${name}.bad 后重试,会自动从上一份完好的 ${prev} 恢复;${prev} 也不在就删掉整个目录重建(旧文件可人工找回)`);
545
576
  }
546
577
 
547
578
  function validEntry(entry) {
@@ -566,7 +597,7 @@ export function validateState(data) {
566
597
  if (typeof data[key] !== 'string' || !data[key]) throw new StateError('invalid', `记忆状态缺少 ${key}`);
567
598
  }
568
599
  if (!Number.isInteger(data.generation) || data.generation < 0) throw new StateError('invalid', '记忆状态 generation 不对');
569
- for (const key of ['entries', 'candidates', 'receipts', 'suppressions']) {
600
+ for (const key of ['entries', 'candidates', 'receipts', 'suppressions', 'conflicts']) {
570
601
  if (!Array.isArray(data[key])) throw new StateError('invalid', `记忆状态 ${key} 不是数组`);
571
602
  }
572
603
  const ids = new Set();
@@ -662,9 +693,12 @@ function readEnvelope(file) {
662
693
  if (upgraded.experimental === undefined) {
663
694
  upgraded.experimental = { projectEvolution: { enabled: false, epoch: '', policyVersion: EVOLUTION_POLICY_VERSION, feedbackGeneration: 0, learning: emptyLearning() } };
664
695
  }
696
+ if (!Array.isArray(upgraded.conflicts)) upgraded.conflicts = [];
665
697
  validateState(upgraded);
666
698
  return upgraded;
667
699
  }
700
+ /** 旧库没有冲突队列:读时补空数组。 */
701
+ if (!Array.isArray(data.payload.conflicts)) data.payload.conflicts = [];
668
702
  validateState(data.payload);
669
703
  return data.payload;
670
704
  }
@@ -1135,6 +1169,20 @@ export function migrateLegacy(scope, options = {}) {
1135
1169
  });
1136
1170
  }
1137
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
+
1138
1186
  /**
1139
1187
  * 读当前状态:只有「完全没有」才算新库;坏文件、未知版本、迁移痕迹都报错,
1140
1188
  * 绝不用空库覆盖。
@@ -1166,7 +1214,7 @@ export function load(scope, options = {}) {
1166
1214
  }
1167
1215
  if (recovered) {
1168
1216
  return {
1169
- state: recovered,
1217
+ state: withRecoveryMarker(recovered),
1170
1218
  exists: true,
1171
1219
  recovered: true,
1172
1220
  recovery: {
@@ -1197,7 +1245,7 @@ export function load(scope, options = {}) {
1197
1245
  const prev = readEnvelope(prevFile(scope));
1198
1246
  if (prev) {
1199
1247
  // current 丢了但 prev 完好:恢复 prev,并标记需要重写 current。
1200
- return { state: prev, exists: false, recovered: true };
1248
+ return { state: withRecoveryMarker(prev), exists: false, recovered: true };
1201
1249
  }
1202
1250
  return { state: emptyState(scope), exists: false, recovered: false };
1203
1251
  }