dsh-lite-memory 0.3.0 → 0.15.0

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 (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
package/learning.js ADDED
@@ -0,0 +1,585 @@
1
+ import crypto from 'node:crypto';
2
+ import * as view from './view.js';
3
+
4
+ /** 一期固定策略版本:版本不认就停止整理,不猜兼容。 */
5
+ export const POLICY_VERSION = 1;
6
+
7
+ export const LIMITS = {
8
+ inputBytes: 8192,
9
+ maxChanges: 2,
10
+ maxTokens: 768,
11
+ outputBytes: 2048,
12
+ timeoutMs: 15000,
13
+ attemptsPerEpisode: 2,
14
+ attemptsPerProject: 6,
15
+ windowMs: 24 * 60 * 60 * 1000,
16
+ trialMs: 30 * 24 * 60 * 60 * 1000,
17
+ pausedMs: 30 * 24 * 60 * 60 * 1000,
18
+ rules: 32,
19
+ pending: 12,
20
+ evidence: 3,
21
+ versions: 2,
22
+ receipts: 64,
23
+ samples: 4,
24
+ checkpoints: 3,
25
+ };
26
+
27
+ export const FEEDBACK_TYPES = ['spec_change', 'execution_miss', 'task_exception', 'aspect_accept', 'artifact_accept', 'unclear'];
28
+ export const LEARNING_STATES = ['pending', 'trial', 'active', 'paused'];
29
+ export const PHASES = ['brief', 'theme', 'outline', 'script', 'shoot', 'edit', 'publish'];
30
+ const ALLOWED_KEYS = new Set(['target', 'feedbackType', 'topicKey', 'currentText', 'title', 'summary', 'applicability', 'conditions', 'evidence', 'persistence']);
31
+
32
+ const LONG_TERM = /以后|今后|长期|默认|每次都|每次|一直|统一|永远|从现在起|记住/;
33
+ const EXPLICIT_ACCEPT = /可以了|就这样|这版|就用这版|用这版|挺好|满意|保留|沿用|继续用|定稿|通过/;
34
+ const ARTIFACT_ACCEPT = /这版可以|可以用这版|就这版|这版就用|定稿|这版通过/;
35
+ const DIMENSION = /字幕|开头|配色|节奏|配乐|镜头|标题|封面|结尾|转场|旁白|音效|画面/;
36
+ const EXCEPTION = /这次|这次就|本期|这期|这一期|这个作品|这一条|本篇|本视频/;
37
+ const NEGATIVE = /不要|不用|别用|别再|停用|取消这条|这条不要|不喜欢|去掉|删掉/;
38
+ const CORRECTION = /改成|改为|换成|重做|重来|再来|下次|不对|错了|不是这样|又没|怎么又|还是没|没按|不好|不行|不满意|不要|别/;
39
+ const EXECUTION_MISS = /之前说过|已经说过|不是说好|怎么又|又没|还是没|没按|又忘|重复/;
40
+ const UNCLEAR = /不太对|再想想|感觉不对|说不上|有点怪|不对劲/;
41
+
42
+ const has = (pattern, text) => pattern.test(text);
43
+
44
+ /** 从反馈里取一个可命名的验收维度;没有就记为作品整体。 */
45
+ export function dimensionOf(input) {
46
+ const text = String(input || '');
47
+ const match = text.match(DIMENSION);
48
+ return match ? match[0] : '作品';
49
+ }
50
+
51
+ /** 只判断“值得检查”,不把正则命中直接当长期规则。 */
52
+ export function classifyFeedback(input) {
53
+ const text = String(input || '').trim();
54
+ if (!text) return { worth: false, type: 'unclear', longTerm: false, explicitLongTerm: false, exception: false, negative: false, dimension: '' };
55
+ const longTerm = has(LONG_TERM, text);
56
+ const exception = has(EXCEPTION, text);
57
+ const negative = has(NEGATIVE, text);
58
+ const dimensionMatch = text.match(DIMENSION);
59
+ const dimension = dimensionMatch ? dimensionMatch[0] : '';
60
+ if (has(ARTIFACT_ACCEPT, text) && !longTerm) {
61
+ return { worth: true, type: 'artifact_accept', longTerm: false, explicitLongTerm: false, exception, negative: false, dimension };
62
+ }
63
+ if (has(EXECUTION_MISS, text) && !has(/改成|改为|换成/, text)) {
64
+ return { worth: true, type: 'execution_miss', longTerm, explicitLongTerm: longTerm, exception, negative, dimension };
65
+ }
66
+ if (has(UNCLEAR, text) && !has(CORRECTION, text)) {
67
+ return { worth: true, type: 'unclear', longTerm, explicitLongTerm: false, exception, negative: false, dimension };
68
+ }
69
+ if (has(EXPLICIT_ACCEPT, text) && !has(CORRECTION, text)) {
70
+ return { worth: true, type: dimension ? 'aspect_accept' : 'artifact_accept', longTerm, explicitLongTerm: longTerm, exception, negative: false, dimension };
71
+ }
72
+ if (exception && has(CORRECTION, text)) {
73
+ return { worth: true, type: 'task_exception', longTerm: false, explicitLongTerm: false, exception: true, negative, dimension };
74
+ }
75
+ if (has(CORRECTION, text)) {
76
+ return { worth: true, type: 'spec_change', longTerm, explicitLongTerm: longTerm && !exception, exception, negative, dimension };
77
+ }
78
+ return { worth: false, type: 'unclear', longTerm, explicitLongTerm: false, exception, negative: false, dimension };
79
+ }
80
+
81
+ export function isLongTerm(text) {
82
+ return has(LONG_TERM, String(text || ''));
83
+ }
84
+
85
+ /** 只有已绑定作品的人类反馈才进入整理候选;初始任务要求本身不是纠正。 */
86
+ export function candidateFrom({ messages, anchor, sessionId, projectId, now } = {}) {
87
+ if (!anchor || !anchor.episodeId) return null;
88
+ if (!['active', 'continuable', 'proposed'].includes(anchor.status)) return null;
89
+ const ids = [];
90
+ const texts = [];
91
+ for (const message of Array.isArray(messages) ? messages : []) {
92
+ if (!message || message.source?.kind !== 'user') continue;
93
+ const text = view.messageText ? view.messageText(message) : messageText(message);
94
+ if (!text) continue;
95
+ ids.push(String(message.id ?? ''));
96
+ texts.push(text);
97
+ }
98
+ if (!texts.length) return null;
99
+ const text = texts.join('\n').slice(0, 1200);
100
+ const cue = classifyFeedback(text);
101
+ if (!cue.worth) return null;
102
+ return {
103
+ projectId,
104
+ sessionId: String(sessionId || ''),
105
+ episodeId: anchor.episodeId,
106
+ taskKind: anchor.taskKind || '',
107
+ seriesId: anchor.seriesId || '',
108
+ phase: anchor.phase || '',
109
+ messageIds: ids.filter(Boolean).slice(-8),
110
+ text,
111
+ cue,
112
+ episodeUpdatedAt: anchor.updatedAt || '',
113
+ at: now || Date.now(),
114
+ };
115
+ }
116
+
117
+ function messageText(message) {
118
+ const content = message && message.content;
119
+ if (typeof content === 'string') return content.trim();
120
+ if (!Array.isArray(content)) return '';
121
+ return content
122
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
123
+ .map((block) => block.text)
124
+ .join('\n')
125
+ .trim();
126
+ }
127
+
128
+ const bounded = (value, max) => {
129
+ const text = String(value ?? '');
130
+ return text.length > max ? text.slice(0, max) : text;
131
+ };
132
+
133
+ export function specDigestOf(record) {
134
+ const current = record || {};
135
+ const material = JSON.stringify({
136
+ topicKey: current.topicKey || '',
137
+ body: current.body || current.currentText || '',
138
+ applicability: current.applicability || '',
139
+ conditions: normalizeConditions(current.conditions),
140
+ });
141
+ return `sd_${crypto.createHash('sha256').update(material).digest('hex').slice(0, 16)}`;
142
+ }
143
+
144
+ export function normalizeConditions(conditions) {
145
+ const out = [];
146
+ const seen = new Set();
147
+ for (const row of Array.isArray(conditions) ? conditions : []) {
148
+ if (!row || typeof row !== 'object') continue;
149
+ const key = String(row.key || '').trim();
150
+ const value = String(row.value || '').trim();
151
+ if (!view.CONDITION_KEYS.includes(key) || !value) continue;
152
+ const signature = `${key}\u0000${value}`;
153
+ if (seen.has(signature)) continue;
154
+ seen.add(signature);
155
+ out.push({ key, value });
156
+ }
157
+ return out.slice(0, 8);
158
+ }
159
+
160
+ /** 构造一次受限调用输入:无工具、无前台历史,只有身份、少量规则和反馈映射。 */
161
+ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase, feedback, rules, ranges, maxBytes = LIMITS.inputBytes } = {}) {
162
+ const refs = { feedback: {}, rules: {} };
163
+ const source = (Array.isArray(rules) ? rules : []).filter((record) => record && record.id);
164
+ /** 用户这次纠正涉及的规则先入输入:固定取前 6 条会把真正相关的旧经验挤掉。 */
165
+ const dimension = String(feedback?.cue?.dimension || '');
166
+ const prioritized = dimension
167
+ ? [...source.filter((record) => `${record.title || ''}${record.summary || ''}${record.body || ''}`.includes(dimension)),
168
+ ...source.filter((record) => !`${record.title || ''}${record.summary || ''}${record.body || ''}`.includes(dimension))]
169
+ : source;
170
+ refs.feedback.u0 = feedback.messageIds[0] || '';
171
+ const project = {
172
+ projectId: bounded(projectId, 80),
173
+ episodeId: bounded(episodeId, 80),
174
+ ...(taskKind ? { taskKind } : {}),
175
+ ...(seriesId ? { seriesId } : {}),
176
+ ...(phase ? { phase } : {}),
177
+ };
178
+ const rangesInput = {
179
+ taskKind: (ranges?.taskKinds || []).slice(0, 8),
180
+ seriesId: (ranges?.series || []).slice(0, 8),
181
+ phase: PHASES.slice(),
182
+ };
183
+ const system = '你是项目记忆整理器,只输出 JSON。只能依据给定反馈修改已有规则或新建一条项目规则;不得调用工具,不得输出 JSON 之外的文字。没有可确定的长期变化时输出 {"changes":[]}。';
184
+ const feedbackHead = {
185
+ type: feedback.cue?.type || 'unclear',
186
+ longTerm: Boolean(feedback.cue?.explicitLongTerm),
187
+ };
188
+ const tail = {
189
+ allowed: { conditions: view.CONDITION_KEYS, ranges: rangesInput, maxChanges: LIMITS.maxChanges, execution_miss: '仅已有规则,currentText 写检查点', artifact_accept: '仅已有规则,记验收样例' },
190
+ output: {
191
+ changes: [{
192
+ target: 'r0 或 new', feedbackType: FEEDBACK_TYPES, topicKey: '短主题键', currentText: '当前可执行规范', applicability: 'all_in_scope|conditional|unresolved', conditions: [{ key: 'taskKind', value: 'video' }], evidence: ['u0'], persistence: 'project_candidate',
193
+ }],
194
+ },
195
+ };
196
+ const render = (selected, feedbackText) => JSON.stringify({
197
+ project,
198
+ feedback: { ...feedbackHead, refs: [['u0', feedbackText]] },
199
+ rules: selected,
200
+ ...tail,
201
+ });
202
+ /** 反馈正文按整段回退,不切开 JSON;仍放不下就是本次调用不可行。 */
203
+ let feedbackText = bounded(feedback.text, 600);
204
+ let prompt = render([], feedbackText);
205
+ while (Buffer.byteLength(system) + Buffer.byteLength(prompt) > maxBytes && feedbackText.length > 0) {
206
+ feedbackText = feedbackText.slice(0, Math.floor(feedbackText.length * 0.6));
207
+ prompt = render([], feedbackText);
208
+ }
209
+ if (Buffer.byteLength(system) + Buffer.byteLength(prompt) > maxBytes) {
210
+ return { system, prompt: '', messages: [], refs, rules: [], bytes: Buffer.byteLength(system), overLimit: true };
211
+ }
212
+ const inputRules = [];
213
+ for (const record of prioritized.slice(0, 6)) {
214
+ const ref = `r${inputRules.length}`;
215
+ refs.rules[ref] = record.id;
216
+ const row = {
217
+ ref,
218
+ revision: record.revision || 1,
219
+ title: bounded(record.title, 60),
220
+ summary: bounded(record.summary, 120),
221
+ body: bounded(record.body, 360),
222
+ applicability: record.applicability || 'unresolved',
223
+ conditions: normalizeConditions(record.conditions),
224
+ state: record.learning?.state || record.state || 'trial',
225
+ };
226
+ const next = render([...inputRules, row], feedbackText);
227
+ if (Buffer.byteLength(system) + Buffer.byteLength(next) > maxBytes) break;
228
+ inputRules.push(row);
229
+ prompt = next;
230
+ }
231
+ return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt) };
232
+ }
233
+ /** 严格解析:截断、工具调用、额外字段、超限都整批拒绝,不做第二次修复调用。 */
234
+ export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
235
+ if (finish && finish.kind !== 'stop' && finish.kind !== 'completed') return { ok: false, reason: '整理没有正常结束' };
236
+ const raw = String(text || '').trim();
237
+ if (!raw) return { ok: false, reason: '整理没有返回内容' };
238
+ if (Buffer.byteLength(raw, 'utf8') > LIMITS.outputBytes) return { ok: false, reason: '整理输出太长' };
239
+ if (/<[^>]+>/.test(raw) && !raw.startsWith('{')) return { ok: false, reason: '整理返回了工具调用' };
240
+ let data;
241
+ try {
242
+ data = JSON.parse(raw);
243
+ } catch {
244
+ return { ok: false, reason: '整理 JSON 不完整' };
245
+ }
246
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return { ok: false, reason: '整理结果不是对象' };
247
+ for (const key of Object.keys(data)) if (key !== 'changes') return { ok: false, reason: '整理结果有未知字段' };
248
+ if (!Array.isArray(data.changes)) return { ok: false, reason: '整理结果缺少变化列表' };
249
+ if (data.changes.length > LIMITS.maxChanges) return { ok: false, reason: '一次改动太多' };
250
+ const changes = [];
251
+ for (const change of data.changes) {
252
+ const checked = validateChange(change, { refs, rules, ranges });
253
+ if (!checked.ok) return checked;
254
+ changes.push(checked.change);
255
+ }
256
+ return { ok: true, changes };
257
+ }
258
+
259
+ export function validateChange(change, { refs, rules, ranges } = {}) {
260
+ if (!change || typeof change !== 'object' || Array.isArray(change)) return { ok: false, reason: '变化不是对象' };
261
+ for (const key of Object.keys(change)) if (!ALLOWED_KEYS.has(key)) return { ok: false, reason: `变化里有未知字段:${key}` };
262
+ const byRef = new Map((Array.isArray(rules) ? rules : []).map((row) => [row.ref, row]));
263
+ const target = String(change.target || '').trim();
264
+ if (!target) return { ok: false, reason: '变化缺少目标' };
265
+ if (target !== 'new' && !byRef.has(target)) return { ok: false, reason: '变化目标不在本次输入里' };
266
+ if (!FEEDBACK_TYPES.includes(change.feedbackType)) return { ok: false, reason: '反馈类型不认' };
267
+ if (change.feedbackType === 'task_exception' || change.feedbackType === 'unclear') {
268
+ return { ok: false, reason: '这类反馈不直接形成长期规则' };
269
+ }
270
+ const sampleOnly = change.feedbackType === 'artifact_accept';
271
+ const checkpointOnly = change.feedbackType === 'execution_miss';
272
+ if ((sampleOnly || checkpointOnly) && target === 'new') {
273
+ return { ok: false, reason: sampleOnly ? '验收依据只能挂到已有规则' : '执行检查点只能挂到已有规则' };
274
+ }
275
+ const existing = target === 'new' ? null : {
276
+ id: refs?.rules?.[target] || '',
277
+ revision: byRef.get(target).revision,
278
+ conditions: byRef.get(target).conditions,
279
+ };
280
+ if (change.persistence !== undefined && change.persistence !== 'project_candidate') return { ok: false, reason: '持久化方式不认' };
281
+ const topicKey = change.topicKey === undefined ? (existing || sampleOnly ? undefined : '') : String(change.topicKey || '').trim();
282
+ if (!existing && !sampleOnly && !topicKey) return { ok: false, reason: '新规则缺少主题键' };
283
+ if (topicKey !== undefined && (!topicKey || topicKey.length > 60)) return { ok: false, reason: '主题键不合法' };
284
+ const currentText = change.currentText === undefined ? (existing || sampleOnly ? undefined : '') : String(change.currentText || '').trim();
285
+ if (!existing && !sampleOnly && !currentText) return { ok: false, reason: '新规则缺少当前规范' };
286
+ if (currentText !== undefined && (!currentText || currentText.length > 240 || currentText.split('\n').length > 6)) {
287
+ return { ok: false, reason: '当前规范不合法' };
288
+ }
289
+ let applicability;
290
+ if (change.applicability !== undefined) {
291
+ if (!['all_in_scope', 'conditional', 'unresolved'].includes(change.applicability)) return { ok: false, reason: '适用范围不认' };
292
+ applicability = change.applicability;
293
+ }
294
+ let conditions;
295
+ if (change.conditions !== undefined) {
296
+ if (!Array.isArray(change.conditions)) return { ok: false, reason: '条件不是列表' };
297
+ conditions = normalizeConditions(change.conditions);
298
+ if (conditions.length !== change.conditions.length) return { ok: false, reason: '条件里有不认的键或空值' };
299
+ for (const condition of conditions) {
300
+ const allowed = conditionValueAllowed(condition, { ranges, existing });
301
+ if (!allowed) return { ok: false, reason: '条件值不在本项目的栏目或阶段里' };
302
+ }
303
+ }
304
+ const evidence = Array.isArray(change.evidence) ? change.evidence.map((value) => String(value)) : [];
305
+ if (!evidence.length || evidence.length > LIMITS.evidence) return { ok: false, reason: '证据数量不对' };
306
+ for (const ref of evidence) if (!refs?.feedback?.[ref]) return { ok: false, reason: '证据不在本次反馈里' };
307
+ const title = change.title === undefined ? undefined : String(change.title || '').trim().slice(0, 40);
308
+ const summary = change.summary === undefined ? undefined : String(change.summary || '').trim().slice(0, 80);
309
+ return {
310
+ ok: true,
311
+ change: {
312
+ target,
313
+ id: existing?.id || '',
314
+ feedbackType: change.feedbackType,
315
+ ...(topicKey !== undefined ? { topicKey } : {}),
316
+ ...(currentText !== undefined ? { currentText } : {}),
317
+ ...(title !== undefined ? { title } : {}),
318
+ ...(summary !== undefined ? { summary } : {}),
319
+ ...(applicability !== undefined ? { applicability } : {}),
320
+ ...(conditions !== undefined ? { conditions } : {}),
321
+ evidence,
322
+ },
323
+ };
324
+ }
325
+
326
+ function conditionValueAllowed(condition, { ranges, existing }) {
327
+ if (condition.key === 'seriesId') return Boolean(ranges?.series?.includes(condition.value) || (existing?.conditions || []).some((row) => row.key === 'seriesId' && row.value === condition.value));
328
+ if (condition.key === 'taskKind') return Boolean(ranges?.taskKinds?.includes(condition.value) || (existing?.conditions || []).some((row) => row.key === 'taskKind' && row.value === condition.value));
329
+ if (condition.key === 'phase') return PHASES.includes(condition.value) || (existing?.conditions || []).some((row) => row.key === 'phase' && row.value === condition.value);
330
+ return true;
331
+ }
332
+
333
+ function titleFrom(text, topicKey) {
334
+ const clean = String(text || '').replace(/^[「『"']|[」』"']$/g, '').split(/[。!?;;\n]/)[0].trim();
335
+ return (clean || topicKey || '项目实验规则').slice(0, 18);
336
+ }
337
+
338
+ /** 把通过校验的变化并进现有条目;新条目从不冒充全项目规则。 */
339
+ export function mergeChange(existing, change, { now } = {}) {
340
+ const at = new Date(now || Date.now()).toISOString();
341
+ const body = change.currentText !== undefined ? change.currentText : existing?.body || '';
342
+ const conditions = change.conditions !== undefined ? change.conditions : normalizeConditions(existing?.conditions);
343
+ const topicKey = change.topicKey !== undefined ? change.topicKey : existing?.topicKey || '';
344
+ const applicability = change.applicability !== undefined
345
+ ? change.applicability
346
+ : existing?.applicability || (conditions.length ? 'conditional' : 'unresolved');
347
+ const current = {
348
+ topicKey,
349
+ title: change.title || existing?.title || titleFrom(body, topicKey),
350
+ summary: change.summary || existing?.summary || `项目经验:${topicKey || titleFrom(body, topicKey)}`,
351
+ body,
352
+ applicability,
353
+ conditions,
354
+ when: conditions.length ? conditions.map((row) => `${row.key}=${row.value}`).join('、') : (existing?.when || ''),
355
+ };
356
+ const previousDigest = existing ? (existing.learning?.specDigest || specDigestOf(existing)) : '';
357
+ const digest = specDigestOf(current);
358
+ const specChanged = !existing || previousDigest !== digest;
359
+ const learning = existing?.learning ? JSON.parse(JSON.stringify(existing.learning)) : {
360
+ state: 'pending',
361
+ supportedEpisodeIds: [],
362
+ evidence: [],
363
+ versions: [],
364
+ createdAt: at,
365
+ };
366
+ if (specChanged && existing) {
367
+ if (learning.specDigest) {
368
+ learning.versions = [...(learning.versions || []), { digest: learning.specDigest, body: existing.body, at }].slice(-LIMITS.versions);
369
+ }
370
+ learning.supportedEpisodeIds = [];
371
+ learning.evidence = [];
372
+ }
373
+ learning.specDigest = digest;
374
+ learning.updatedAt = at;
375
+ return { current, learning, specChanged, digest };
376
+ }
377
+
378
+ /** 认可样例只记作品版本与验收维度,不抬规则证据;路径未校验就标未验证。 */
379
+ export function addSample(learning, sample = {}) {
380
+ const next = learning ? JSON.parse(JSON.stringify(learning)) : {};
381
+ const rows = (Array.isArray(next.samples) ? next.samples : []).filter((row) => row && row.episodeId && row.dimension);
382
+ const row = {
383
+ episodeId: bounded(sample.episodeId, 80),
384
+ artifactVersion: bounded(sample.artifactVersion || '', 40),
385
+ dimension: bounded(sample.dimension || '作品', 20),
386
+ messageIds: (Array.isArray(sample.messageIds) ? sample.messageIds : []).slice(-3).map((id) => bounded(id, 80)),
387
+ verified: sample.verified === true,
388
+ at: new Date(sample.at || Date.now()).toISOString(),
389
+ };
390
+ const kept = rows.filter((item) => !(item.episodeId === row.episodeId && item.dimension === row.dimension));
391
+ next.samples = [...kept, row].slice(-LIMITS.samples);
392
+ return next;
393
+ }
394
+
395
+ /** 执行没做到时补检查点:不改规范正文,也不加同义禁令。 */
396
+ export function addCheckpoint(learning, checkpoint = {}) {
397
+ const next = learning ? JSON.parse(JSON.stringify(learning)) : {};
398
+ const text = bounded(String(checkpoint.text || '').trim(), 160);
399
+ if (!text) return next;
400
+ const rows = (Array.isArray(next.checkpoints) ? next.checkpoints : []).filter((row) => row && row.text);
401
+ next.checkpoints = [...rows.filter((row) => row.text !== text), {
402
+ text,
403
+ messageIds: (Array.isArray(checkpoint.messageIds) ? checkpoint.messageIds : []).slice(-3).map((id) => bounded(id, 80)),
404
+ at: new Date(checkpoint.at || Date.now()).toISOString(),
405
+ }].slice(-LIMITS.checkpoints);
406
+ return next;
407
+ }
408
+
409
+ /** 状态只看证据强度:明确长期要求或两个独立作品才 active;条件不确定就 pending。 */
410
+ export function nextLearningState({ existing, change, evidence, now, specChanged = false } = {}) {
411
+ const at = new Date(now || Date.now()).toISOString();
412
+ const learning = existing?.learning ? JSON.parse(JSON.stringify(existing.learning)) : { state: 'pending', supportedEpisodeIds: [], evidence: [] };
413
+ const priorState = LEARNING_STATES.includes(learning.state) ? learning.state : 'pending';
414
+ /** 规范正文或适用条件变了:旧证据不再支持新规范,只保留本次这一条。 */
415
+ const episodes = specChanged ? [] : (Array.isArray(learning.supportedEpisodeIds) ? learning.supportedEpisodeIds.slice() : []);
416
+ if (evidence?.episodeId && !episodes.includes(evidence.episodeId)) episodes.push(evidence.episodeId);
417
+ const anchors = specChanged ? [] : (Array.isArray(learning.evidence) ? learning.evidence.slice() : []);
418
+ if (evidence?.episodeId && !anchors.some((row) => row.episodeId === evidence.episodeId)) anchors.push(evidence);
419
+ learning.supportedEpisodeIds = episodes.slice(-LIMITS.evidence);
420
+ learning.evidence = anchors.slice(-LIMITS.evidence).map((row) => ({ ...row }));
421
+ learning.updatedAt = at;
422
+ learning.lastFeedbackType = change?.feedbackType || learning.lastFeedbackType || '';
423
+ if (change?.currentText) learning.lastText = change.currentText;
424
+ if (priorState === 'paused' && !specChanged && !Boolean(evidence?.explicitLongTerm)) {
425
+ learning.state = 'paused';
426
+ return learning;
427
+ }
428
+ if (priorState === 'paused') {
429
+ delete learning.blockedReason;
430
+ delete learning.pausedAt;
431
+ delete learning.pausedBy;
432
+ }
433
+ const conditions = normalizeConditions(change?.conditions !== undefined ? change.conditions : existing?.conditions);
434
+ const determinate = (change?.applicability || existing?.applicability) === 'conditional' && conditions.length > 0;
435
+ const explicit = Boolean(evidence?.explicitLongTerm);
436
+ if (explicit) learning.state = 'active';
437
+ else if (priorState === 'active' && existing?.learning?.specDigest === specDigestOf({
438
+ topicKey: change?.topicKey !== undefined ? change.topicKey : existing?.topicKey,
439
+ body: change?.currentText !== undefined ? change.currentText : existing?.body,
440
+ applicability: change?.applicability !== undefined ? change.applicability : existing?.applicability,
441
+ conditions: change?.conditions !== undefined ? change.conditions : existing?.conditions,
442
+ })) learning.state = 'active';
443
+ else if (episodes.length >= 2) learning.state = 'active';
444
+ else if (determinate && change?.feedbackType !== 'execution_miss') learning.state = 'trial';
445
+ else if (priorState === 'trial' && existing?.learning?.specDigest === specDigestOf({
446
+ topicKey: change?.topicKey !== undefined ? change.topicKey : existing?.topicKey,
447
+ body: change?.currentText !== undefined ? change.currentText : existing?.body,
448
+ applicability: change?.applicability !== undefined ? change.applicability : existing?.applicability,
449
+ conditions: change?.conditions !== undefined ? change.conditions : existing?.conditions,
450
+ })) learning.state = 'trial';
451
+ else learning.state = 'pending';
452
+ if (learning.state === 'trial') learning.trialUntil = new Date((now || Date.now()) + LIMITS.trialMs).toISOString();
453
+ else delete learning.trialUntil;
454
+ if (learning.state === 'active') {
455
+ learning.acceptedAt = learning.acceptedAt || at;
456
+ delete learning.trialUntil;
457
+ }
458
+ return learning;
459
+ }
460
+
461
+ export function pauseLearning(learning, { by = 'feedback', reason = 'feedback_pending', now } = {}) {
462
+ const next = learning ? JSON.parse(JSON.stringify(learning)) : { supportedEpisodeIds: [], evidence: [] };
463
+ next.state = 'paused';
464
+ next.blockedReason = reason;
465
+ next.pausedBy = by;
466
+ next.pausedAt = new Date(now || Date.now()).toISOString();
467
+ return next;
468
+ }
469
+
470
+ export function resumeLearning(learning, { by = 'management', now } = {}) {
471
+ const next = learning ? JSON.parse(JSON.stringify(learning)) : {};
472
+ next.state = 'trial';
473
+ delete next.blockedReason;
474
+ delete next.pausedAt;
475
+ delete next.pausedBy;
476
+ next.revalidatedBy = by;
477
+ next.updatedAt = new Date(now || Date.now()).toISOString();
478
+ next.trialUntil = new Date((now || Date.now()) + LIMITS.trialMs).toISOString();
479
+ return next;
480
+ }
481
+
482
+ /** 频次先在项目锁内扣,失败也计数;超限返回延后原因。 */
483
+ export function quota(learning, { episodeId, now } = {}) {
484
+ const at = now || Date.now();
485
+ const ledger = learning && typeof learning === 'object' ? learning : {};
486
+ const attempts = ledger.attempts && typeof ledger.attempts === 'object' ? ledger.attempts : {};
487
+ const recent = (Array.isArray(ledger.recent) ? ledger.recent : []).filter((value) => at - Date.parse(value) < LIMITS.windowMs);
488
+ const episodeCount = Number(attempts[episodeId]?.count || 0);
489
+ if (episodeCount >= LIMITS.attemptsPerEpisode) return { ok: false, reason: 'episode_limit', episodeCount, projectCount: recent.length };
490
+ if (recent.length >= LIMITS.attemptsPerProject) return { ok: false, reason: 'project_limit', episodeCount, projectCount: recent.length };
491
+ return { ok: true, episodeCount, projectCount: recent.length };
492
+ }
493
+
494
+ export function recordAttempt(learning, { episodeId, now } = {}) {
495
+ const at = now || Date.now();
496
+ const iso = new Date(at).toISOString();
497
+ const next = learning ? JSON.parse(JSON.stringify(learning)) : { attempts: {}, recent: [], receipts: [] };
498
+ next.attempts = next.attempts && typeof next.attempts === 'object' ? next.attempts : {};
499
+ const row = next.attempts[episodeId] && typeof next.attempts[episodeId] === 'object' ? next.attempts[episodeId] : { count: 0 };
500
+ row.count = Math.min(LIMITS.attemptsPerEpisode, Number(row.count || 0) + 1);
501
+ row.lastAt = iso;
502
+ next.attempts[episodeId] = row;
503
+ next.recent = [...(Array.isArray(next.recent) ? next.recent : []), iso].filter((value) => at - Date.parse(value) < LIMITS.windowMs).slice(-32);
504
+ next.receipts = Array.isArray(next.receipts) ? next.receipts.slice(-LIMITS.receipts) : [];
505
+ return next;
506
+ }
507
+
508
+ /** usage 缺失就是未知,不补零;缓存与 reasoning 不机械相加。 */
509
+ export function usageOf(chunks) {
510
+ let input = 0;
511
+ let output = 0;
512
+ let cached = 0;
513
+ let known = false;
514
+ for (const chunk of Array.isArray(chunks) ? chunks : []) {
515
+ const usage = chunk && chunk.type === 'usage' ? chunk.usage || chunk : null;
516
+ if (!usage || typeof usage !== 'object') continue;
517
+ const inputTokens = usage.inputTokens ?? usage.promptTokens;
518
+ const outputTokens = usage.outputTokens ?? usage.completionTokens;
519
+ if (typeof inputTokens === 'number') { input = inputTokens; known = true; }
520
+ if (typeof outputTokens === 'number') { output = outputTokens; known = true; }
521
+ if (usage.cachedInputTokens !== undefined) cached = Number(usage.cachedInputTokens) || 0;
522
+ }
523
+ return known ? { known: true, inputTokens: input, outputTokens: output, cachedInputTokens: cached } : { known: false };
524
+ }
525
+
526
+ export function receiptOf({ operationId, episodeId, status, changes, usage, at, reason } = {}) {
527
+ return {
528
+ operationId: String(operationId || ''),
529
+ episodeId: String(episodeId || ''),
530
+ status: String(status || ''),
531
+ ...(reason ? { reason: String(reason) } : {}),
532
+ changes: Number(changes || 0),
533
+ ...(usage ? { usage } : {}),
534
+ at: new Date(at || Date.now()).toISOString(),
535
+ };
536
+ }
537
+
538
+ /** 最近回执就是效果摘要的来源;只报发生过的次数,未知用量不补零。 */
539
+ export function effectSummary(learning) {
540
+ const rows = (Array.isArray(learning?.receipts) ? learning.receipts : []).slice(-LIMITS.receipts);
541
+ const episodes = new Set();
542
+ let applied = 0;
543
+ let noChange = 0;
544
+ let failed = 0;
545
+ let knownCalls = 0;
546
+ let inputTokens = 0;
547
+ let outputTokens = 0;
548
+ for (const row of rows) {
549
+ if (!row) continue;
550
+ if (row.episodeId) episodes.add(row.episodeId);
551
+ if (row.status === 'applied' || row.status === 'deleted' || row.status === 'undone') applied += 1;
552
+ else if (row.status === 'no_change') noChange += 1;
553
+ else failed += 1;
554
+ if (row.usage && row.usage.known === true) {
555
+ knownCalls += 1;
556
+ inputTokens += Number(row.usage.inputTokens || 0);
557
+ outputTokens += Number(row.usage.outputTokens || 0);
558
+ }
559
+ }
560
+ return {
561
+ window: rows.length,
562
+ applied,
563
+ noChange,
564
+ failed,
565
+ episodes: episodes.size,
566
+ knownCalls,
567
+ inputTokens: knownCalls ? inputTokens : null,
568
+ outputTokens: knownCalls ? outputTokens : null,
569
+ };
570
+ }
571
+
572
+ /** 到期的 trial/paused 按边界清理;active 与用户固定规范不走 TTL。 */
573
+ export function lifecycle(entries, now) {
574
+ const at = now || Date.now();
575
+ const purge = [];
576
+ const paused = [];
577
+ for (const entry of Array.isArray(entries) ? entries : []) {
578
+ const learning = entry?.learning;
579
+ if (!learning || entry.origin !== 'project_evolution' || learning.pinned === true) continue;
580
+ if (learning.state === 'pending' && learning.createdAt && at - Date.parse(learning.createdAt) > LIMITS.trialMs) purge.push(entry.id);
581
+ else if (learning.state === 'trial' && learning.trialUntil && at - Date.parse(learning.trialUntil) > 0) paused.push(entry.id);
582
+ else if (learning.state === 'paused' && learning.pausedAt && at - Date.parse(learning.pausedAt) > LIMITS.pausedMs) purge.push(entry.id);
583
+ }
584
+ return { purge, paused };
585
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.3.0",
3
+ "version": "0.15.0",
4
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.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -8,7 +8,9 @@
8
8
  "dsh": ">=0.1.5"
9
9
  },
10
10
  "scripts": {
11
- "test": "node test/store.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/control.test.mjs && node test/learning.test.mjs && node test/scenario.test.mjs && node test/crossing.test.mjs && node test/client.test.mjs && node test/contract.test.mjs",
12
+ "bench": "node bench/bench.mjs",
13
+ "bench:evolution": "node bench/evolution.mjs"
12
14
  },
13
15
  "exports": {
14
16
  ".": "./index.js",
@@ -20,13 +22,19 @@
20
22
  "files": [
21
23
  "index.js",
22
24
  "store.js",
25
+ "state.js",
26
+ "view.js",
27
+ "anchor.js",
28
+ "learning.js",
23
29
  "client.js",
24
30
  "cordis.patch.yml",
25
31
  "locale",
26
32
  "icon.svg",
27
33
  "README.md",
28
34
  "TRIGGERS.md",
35
+ "docs",
29
36
  "LICENSE",
37
+ "bench",
30
38
  "test"
31
39
  ],
32
40
  "dsh": {