dsh-lite-memory 0.18.31 → 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,8 @@
1
+ # 0.18.32
2
+
3
+ ## 归属判定:操作系统名收不住本机痕迹
4
+
5
+ - 正文或依据里出现本机路径、回环地址、端口时,只有 `machine` / `runtime` 条件才算把这条规则收在具体环境里。
6
+ - 只写 `targetOs`(例如 `macos`)不再让本机脚本升到全局层:换一台同系统的机器,路径仍然不存在。
7
+ - 干净的跨项目流程只声明平台条件时仍可进全局层,平台条件按可信上下文生效。
8
+ - 回归覆盖:`test/store.test.mjs` 新增「只写操作系统名收不住本机路径」两类用例。
@@ -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.31",
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
 
@@ -2507,6 +2511,8 @@ export const MEMORY_CLASSES = ['personal_preference', 'project_fact', 'environme
2507
2511
  export const DURABILITIES = ['durable', 'task'];
2508
2512
 
2509
2513
  const ENVIRONMENT_KEYS = ['machine', 'targetOs', 'runtime', 'shell'];
2514
+ /** 一台机器/一次运行才算收住本机痕迹;只写操作系统名(targetOs)换台机器照样不成立。 */
2515
+ const MACHINE_SCOPE_KEYS = ['machine', 'runtime'];
2510
2516
 
2511
2517
  /**
2512
2518
  * 范围路由:用户明确说的范围最高;没有就按记忆类别提案,设备事实必须带设备条件。
@@ -2568,8 +2574,16 @@ export function routeScope(draft, options = {}) {
2568
2574
  return false;
2569
2575
  }
2570
2576
  })();
2577
+ /** 本机痕迹只被「本机/运行时」条件收住;targetOs 是平台,不是这台机器。 */
2578
+ const machineScoped = (() => {
2579
+ try {
2580
+ return (view.normalizeConditions(source.conditions) || []).some((item) => MACHINE_SCOPE_KEYS.includes(item.key));
2581
+ } catch {
2582
+ return false;
2583
+ }
2584
+ })();
2571
2585
  /** 真正挡回项目层的只有「本机痕迹 + 没有声明适用环境」;只写命令名不算本机痕迹。 */
2572
- const machineConflict = machineBound && !envScoped;
2586
+ const machineConflict = machineBound && !machineScoped;
2573
2587
  const base = { chosenScope: '', reasonCode: '', requiredConditions: [], needsScope: false, explicit };
2574
2588
  /** 通用层拿不出普适证据时:有项目就留在项目层,没有就问一次运行环境。 */
2575
2589
  const fallbackToProject = (reasonCode) => {
@@ -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);
@@ -1089,6 +1111,22 @@ test('归属路由:本机痕迹的「通用流程」留在项目层,干净
1089
1111
  assert.equal(evidencePath.chosenScope, 'project', '依据本身是本机文件不算普适证据');
1090
1112
  assert.equal(evidencePath.reasonCode, 'machine_bound_procedure');
1091
1113
 
1114
+ const osOnlyProcedure = store.routeScope({
1115
+ title: '本机部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh',
1116
+ memoryClass: 'portable_procedure', scope: 'global',
1117
+ conditions: [{ key: 'targetOs', value: 'macos' }],
1118
+ }, { project });
1119
+ assert.equal(osOnlyProcedure.chosenScope, 'project', '只写操作系统名收不住本机路径');
1120
+ assert.equal(osOnlyProcedure.reasonCode, 'machine_bound_scope');
1121
+
1122
+ const osOnlyClass = store.routeScope({
1123
+ title: '本机部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh',
1124
+ memoryClass: 'portable_procedure',
1125
+ conditions: [{ key: 'targetOs', value: 'macos' }],
1126
+ }, { project });
1127
+ assert.equal(osOnlyClass.chosenScope, 'project', 'targetOs 不是机器条件,不能凭它搬去全局层');
1128
+ assert.equal(osOnlyClass.reasonCode, 'machine_bound_procedure');
1129
+
1092
1130
  const scopedProcedure = store.routeScope({
1093
1131
  title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
1094
1132
  body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
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' };