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.
- package/docs/RELEASE-0.18.33.md +7 -0
- package/index.js +9 -9
- package/learning.js +12 -0
- package/package.json +1 -1
- package/store.js +7 -3
- package/test/learning.test.mjs +30 -2
- package/test/store.test.mjs +22 -0
- package/view.js +12 -6
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
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:
|
|
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
|
-
|
|
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
|
|
package/test/learning.test.mjs
CHANGED
|
@@ -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,
|
|
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,
|
|
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) {
|
package/test/store.test.mjs
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
246
|
-
if (record
|
|
247
|
-
|
|
248
|
-
if (record.supersededBy) return
|
|
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
|
|
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' };
|