dsh-lite-memory 0.18.32 → 0.18.33

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.
@@ -0,0 +1,7 @@
1
+ # 0.18.33:作品规则不串到别的任务
2
+
3
+ - 作品里学到的规范自动带上当时的任务范围:模型写成「全项目」也收窄成任务条件,不再一条代码习惯跑进数据库任务。
4
+ - 收集时固定的任务范围只补不丢:整理器漏写时补回,写成别的条件时自动合并,不整条作废。
5
+ - 生命周期先于适用范围:归档、被取代、过期的条目读取时如实报状态,不再显示成现行规则,也不回写 `lastUsed`。
6
+ - 空正文读取只回标题与状态,不占正文额度、不计引用、不算「用过」。
7
+ - 没有可用模型时不先扣自进化滚动额度,避免扣了却发不出调用。
package/index.js CHANGED
@@ -2449,6 +2449,8 @@ export function apply(ctx, config) {
2449
2449
  }
2450
2450
  const revision = record.revision || 1;
2451
2451
  const body = String(record.body ?? '');
2452
+ /** 空正文只回标题与状态:没有正文可交付,就不占正文额度、不算引用。 */
2453
+ const hasBody = body.trim() !== '';
2452
2454
  const value = {
2453
2455
  title: record.title,
2454
2456
  when: record.when || '',
@@ -2474,14 +2476,14 @@ export function apply(ctx, config) {
2474
2476
  return receipt;
2475
2477
  }
2476
2478
  const bytes = jsonBytes(value);
2477
- if (!affordableTool(win, bytes, 1)) return { scope: '', status: 'budget_exhausted' };
2479
+ if (!affordableTool(win, bytes, hasBody ? 1 : 0)) return { scope: '', status: 'budget_exhausted' };
2478
2480
  win.bytes += bytes;
2479
2481
  win.toolBytes += bytes;
2480
- win.bodies += 1;
2482
+ if (hasBody) win.bodies += 1;
2481
2483
  win.reads += 1;
2482
- win.readBodies.set(record.id, revision);
2484
+ if (hasBody) win.readBodies.set(record.id, revision);
2483
2485
  }
2484
- recordCitations(scope, sessionIdOf(execution), [record]);
2486
+ if (hasBody) recordCitations(scope, sessionIdOf(execution), [record]);
2485
2487
  return value;
2486
2488
  },
2487
2489
  }), 'lite-memory: memory_read');
@@ -2959,6 +2961,9 @@ export function apply(ctx, config) {
2959
2961
  const operationId = `learn_${crypto.createHash('sha256')
2960
2962
  .update(`${scope.key}|${candidate.episodeId}|${candidate.messageIds.join(',')}`)
2961
2963
  .digest('hex').slice(0, 16)}`;
2964
+ /** 没有可用模型就别先扣滚动额度:扣了却发不出调用,额度会白烧一整窗口。 */
2965
+ const llm = getService('llm');
2966
+ if (!llm || typeof llm.prepareCall !== 'function') return;
2962
2967
  const begin = beginLearningAttempt(scope, { episodeId: candidate.episodeId, operationId: `${operationId}_start`, now: Date.now() });
2963
2968
  if (!begin || begin.ok !== true || begin.replayed) return;
2964
2969
  const ranges = rangesOf(scope);
@@ -2977,11 +2982,6 @@ export function apply(ctx, config) {
2977
2982
  revision: record.revision || 1,
2978
2983
  specDigest: learning.specDigestOf(record),
2979
2984
  }));
2980
- const llm = getService('llm');
2981
- if (!llm || typeof llm.prepareCall !== 'function') {
2982
- recordLearningFailure(scope, { operationId: `${operationId}_fail`, episodeId: candidate.episodeId, reason: '宿主没有可用模型', epoch: begin.epoch });
2983
- return;
2984
- }
2985
2985
  const controller = new AbortController();
2986
2986
  learningAborts.set(scope.key, controller);
2987
2987
  const deadline = setTimeout(() => { try { controller.abort(); } catch { /* already aborted */ } }, learning.LIMITS.timeoutMs);
package/learning.js CHANGED
@@ -626,6 +626,18 @@ export function validateChange(change, { refs, rules, ranges, expectedTarget } =
626
626
  const expectedConditions = ['taskKind', 'seriesId', 'phase']
627
627
  .filter((key) => String(expected?.[key] || '').trim())
628
628
  .map((key) => ({ key, value: String(expected[key]).trim() }));
629
+ /** 作品里学到的规范必须带上当时的任务范围:模型想写成全项目时收窄成条件规则,而不是丢掉这条反馈。 */
630
+ const narrowToEpisode = target === 'new' && expected?.scope === 'episode' && expectedConditions.length > 0;
631
+ if (narrowToEpisode) {
632
+ if (applicability === undefined || applicability === 'all_in_scope') applicability = 'conditional';
633
+ const merged = conditions !== undefined ? [...conditions] : [];
634
+ for (const row of expectedConditions) {
635
+ if (merged.some((item) => item.key === row.key && item.value === row.value)) continue;
636
+ if (!conditionValueAllowed(row, { ranges, existing })) return { ok: false, reason: '收集时的任务范围不在本项目里' };
637
+ merged.push(row);
638
+ }
639
+ conditions = merged;
640
+ }
629
641
  const expectedApplicability = expected
630
642
  ? (expected.applicability === 'all_in_scope'
631
643
  ? 'all_in_scope'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.18.32",
3
+ "version": "0.18.33",
4
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",
package/store.js CHANGED
@@ -1653,23 +1653,27 @@ export function readEntry(scope, id, options = {}) {
1653
1653
  if (!record && !archived) continue;
1654
1654
  if (!readableRecord(record || archived, options, index)) continue;
1655
1655
  const body = readBody(source, id);
1656
+ /** 生命周期先判定:过期/被取代的条目不能再报成现行规则。 */
1657
+ const lifecycle = view.lifecycleStatusOf(record || archived, options.now || Date.now());
1656
1658
  return {
1657
1659
  source,
1658
1660
  archived: Boolean(archived),
1661
+ lifecycle,
1659
1662
  record: {
1660
1663
  ...(record || archived),
1661
1664
  scope: source.key === 'global' ? 'global' : 'project',
1662
1665
  body,
1663
- status: archived ? 'archived' : view.statusOf(record, options.context || {}),
1666
+ status: lifecycle || view.statusOf(record, options.context || {}),
1664
1667
  },
1665
1668
  };
1666
1669
  }
1667
1670
  return null;
1668
1671
  });
1669
1672
  if (!found) return null;
1670
- /** 读过才算用过;归档、被取代、过期或条件不匹配的读取都不算「在用」。 */
1673
+ /** 读过才算用过;归档、被取代、过期、空正文或条件不匹配的读取都不算「在用」。 */
1671
1674
  const countsAsUsage = typeof options.usage !== 'function' || options.usage(found.record) !== false;
1672
- if (!found.archived && countsAsUsage) touchUsed(found.source, [id]);
1675
+ const hasBody = typeof found.record.body === 'string' && found.record.body.trim() !== '';
1676
+ if (!found.archived && !found.lifecycle && hasBody && countsAsUsage) touchUsed(found.source, [id]);
1673
1677
  return found.record;
1674
1678
  }
1675
1679
 
@@ -8,6 +8,7 @@ const store = await import('../store.js');
8
8
  const learning = await import('../learning.js');
9
9
  const anchor = await import('../anchor.js');
10
10
  const state = await import('../state.js');
11
+ const view = await import('../view.js');
11
12
 
12
13
  let passed = 0;
13
14
  const test = (name, fn) => {
@@ -996,18 +997,45 @@ test('L03d 作用域在收集时固定,整理只能沿用或收窄', () => {
996
997
  assert.equal(learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: wide.expectedTarget }).ok, true);
997
998
 
998
999
  const video = { scope: 'episode', episodeId: 'ep_x', taskKind: 'video' };
1000
+ /** 作品里学到的规范:想说成全项目时收窄成任务条件,而不是整条作废。 */
999
1001
  const episodeWide = learning.validateChange({ ...base, applicability: 'all_in_scope' }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1000
- assert.equal(episodeWide.ok, false, '作品规则不能抬成全项目');
1002
+ assert.equal(episodeWide.ok, true, episodeWide.reason);
1003
+ assert.equal(episodeWide.change.applicability, 'conditional');
1004
+ assert.deepEqual(episodeWide.change.conditions, [{ key: 'taskKind', value: 'video' }]);
1001
1005
  const droppedKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1002
- assert.equal(droppedKind.ok, false, '不能删掉收集时的任务范围');
1006
+ assert.equal(droppedKind.ok, true, droppedKind.reason);
1007
+ assert.deepEqual(droppedKind.change.conditions, [{ key: 'phase', value: 'script' }, { key: 'taskKind', value: 'video' }], '缺的任务范围自动补回,不能丢');
1003
1008
  const keptKind = learning.validateChange({ ...base, applicability: 'conditional', conditions: [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }] }, { refs, rules: [], ranges: RANGES, expectedTarget: video });
1004
1009
  assert.equal(keptKind.ok, true, keptKind.reason);
1010
+ assert.deepEqual(keptKind.change.conditions, [{ key: 'taskKind', value: 'video' }, { key: 'phase', value: 'script' }]);
1005
1011
 
1006
1012
  const fallback = learning.pendingChangeFrom({ text: '以后所有开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: narrow.expectedTarget });
1007
1013
  assert.equal(fallback.applicability, 'unresolved', '兜底不重新推导作用域');
1008
1014
  assert.equal(learning.pendingChangeFrom({ text: '以后开头都先给结论。', cue: { explicitLongTerm: true }, expectedTarget: wide.expectedTarget }).applicability, 'all_in_scope');
1009
1015
  });
1010
1016
 
1017
+ test('L03d2 代码任务学到的规则不会在数据库任务里生效', () => {
1018
+ const refs = { feedback: { u0: 'm1' }, rules: {} };
1019
+ const parsed = learning.parseOutput(JSON.stringify({
1020
+ changes: [{
1021
+ target: 'new', feedbackType: 'spec_change', topicKey: '迁移',
1022
+ currentText: '先跑迁移再改代码', applicability: 'all_in_scope', evidence: ['u0'],
1023
+ }],
1024
+ }), {
1025
+ finish: { kind: 'stop' }, refs, rules: [], ranges: { ...RANGES, taskKinds: ['code', 'database'] },
1026
+ expectedTarget: { scope: 'episode', projectId: 'p', episodeId: 'ep_code', taskKind: 'code' },
1027
+ });
1028
+ assert.equal(parsed.ok, true, parsed.reason);
1029
+ const change = parsed.changes[0];
1030
+ assert.equal(change.applicability, 'conditional');
1031
+ assert.deepEqual(change.conditions, [{ key: 'taskKind', value: 'code' }]);
1032
+ const code = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'code' } }, 'auto');
1033
+ const db = view.eligibility({ id: 'mem_1', title: '迁移', status: 'active', origin: 'project_evolution', conditions: change.conditions, learning: { state: 'active' } }, { task: { kind: 'database' } }, 'auto');
1034
+ assert.equal(code.ok, true);
1035
+ assert.equal(db.ok, false);
1036
+ assert.equal(db.reason, 'condition_mismatch');
1037
+ });
1038
+
1011
1039
  test('L03e 先按相关性排序再截断,输入顺序不挤掉真正相关的旧规则', () => {
1012
1040
  const rules = [];
1013
1041
  for (let index = 0; index < 32; index += 1) {
@@ -162,6 +162,28 @@ test('expired or superseded records do not block a new same-title save', () => {
162
162
  assert.notEqual(replacement.record.id, second.record.id);
163
163
  });
164
164
 
165
+ test('expired reads report their lifecycle and empty reads do not count as usage', () => {
166
+ const scope = fresh('lifecycle-read');
167
+ const first = upsert(scope, { title: '过期条目', summary: '已经过了有效期', body: '旧正文' });
168
+ const index = loadIndex(scope);
169
+ index.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
170
+ index.entries.find((row) => row.id === first.record.id).lastUsed = '2026-01-01T00:00:00.000Z';
171
+ saveIndex(scope, index);
172
+ const expired = readEntry(scope, first.record.id);
173
+ assert.equal(expired.status, 'expired');
174
+ assert.equal(expired.body, '旧正文');
175
+ assert.equal(loadIndex(scope).entries.find((row) => row.id === first.record.id).lastUsed, '2026-01-01T00:00:00.000Z');
176
+
177
+ const empty = upsert(scope, { title: '空正文条目', summary: '只有摘要', body: '' }).record;
178
+ loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
179
+ const saved = loadIndex(scope);
180
+ saved.entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
181
+ saveIndex(scope, saved);
182
+ const record = readEntry(scope, empty.id);
183
+ assert.equal(record.body, '');
184
+ assert.equal(loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed, '2026-01-01T00:00:00.000Z', '空读不算用过');
185
+ });
186
+
165
187
  test('oversized body is rejected, old data untouched', () => {
166
188
  const scope = fresh('oversize');
167
189
  const big = 'x'.repeat(LIMITS.entryBytes + 1);
package/view.js CHANGED
@@ -241,13 +241,19 @@ export function contextWithTask(context = {}, task = {}) {
241
241
  return out;
242
242
  }
243
243
 
244
- export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
245
- if (!record) return { ok: false, status: 'gone', reason: 'gone' };
246
- if (record.status === 'archived') return { ok: false, status: 'gone', reason: 'archived' };
247
- /** 生命周期与手动检索共用一条:被取代或过期的条目不再像现行规则一样进入召回和正文读取。 */
248
- if (record.supersededBy) return { ok: false, status: 'gone', reason: 'superseded' };
244
+ /** 生命周期先于适用范围:归档、被取代、过期这三类都不该再算现行规则。 */
245
+ export function lifecycleStatusOf(record, now = Date.now()) {
246
+ if (!record) return 'gone';
247
+ if (record.status === 'archived') return 'archived';
248
+ if (record.supersededBy) return 'superseded';
249
249
  const validUntil = Date.parse(record.validUntil || '');
250
- if (Number.isFinite(validUntil) && validUntil <= now) return { ok: false, status: 'gone', reason: 'expired' };
250
+ if (Number.isFinite(validUntil) && validUntil <= now) return 'expired';
251
+ return '';
252
+ }
253
+
254
+ export function eligibility(record, runtime = {}, purpose = 'read', now = Date.now()) {
255
+ const lifecycle = lifecycleStatusOf(record, now);
256
+ if (lifecycle) return { ok: false, status: 'gone', reason: lifecycle };
251
257
  const pid = runtime && runtime.projectId;
252
258
  if (pid && record.ownerProjectId && record.ownerProjectId !== pid) {
253
259
  return { ok: false, status: 'not_applicable', reason: 'owner_mismatch' };