dsh-lite-memory 0.16.2 → 0.17.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.
package/learning.js CHANGED
@@ -22,12 +22,14 @@ export const LIMITS = {
22
22
  receipts: 64,
23
23
  samples: 4,
24
24
  checkpoints: 3,
25
+ callRules: 6,
26
+ organizerRules: 8,
25
27
  };
26
28
 
27
29
  export const FEEDBACK_TYPES = ['spec_change', 'execution_miss', 'task_exception', 'aspect_accept', 'artifact_accept', 'unclear'];
28
30
  export const LEARNING_STATES = ['pending', 'trial', 'active', 'paused'];
29
31
  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']);
32
+ const ALLOWED_KEYS = new Set(['target', 'feedbackType', 'topicKey', 'currentText', 'title', 'summary', 'applicability', 'conditions', 'evidence', 'persistence', 'sourceExcerpt']);
31
33
 
32
34
  const LONG_TERM = /以后|今后|长期|默认|每次都|每次|一直|统一|永远|从现在起|记住/;
33
35
  const EXPLICIT_ACCEPT = /可以了|就这样|这版|就用这版|用这版|挺好|满意|保留|沿用|继续用|定稿|通过/;
@@ -69,6 +71,10 @@ export function classifyFeedback(input) {
69
71
  if (has(EXPLICIT_ACCEPT, text) && !has(CORRECTION, text)) {
70
72
  return { worth: true, type: dimension ? 'aspect_accept' : 'artifact_accept', longTerm, explicitLongTerm: longTerm, exception, negative: false, dimension };
71
73
  }
74
+ /** 不是纠正句也可以立规范:“这个项目默认用 pnpm”这类声明本身就是长期要求。 */
75
+ if (longTerm && !exception) {
76
+ return { worth: true, type: 'spec_change', longTerm: true, explicitLongTerm: true, exception: false, negative, dimension };
77
+ }
72
78
  if (exception && has(CORRECTION, text)) {
73
79
  return { worth: true, type: 'task_exception', longTerm: false, explicitLongTerm: false, exception: true, negative, dimension };
74
80
  }
@@ -82,10 +88,40 @@ export function isLongTerm(text) {
82
88
  return has(LONG_TERM, String(text || ''));
83
89
  }
84
90
 
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;
91
+ const BOUND_STATUSES = ['active', 'continuable', 'proposed'];
92
+
93
+ /**
94
+ * 一次整理运行能用到的身份只从调用方传入的上下文取:项目、会话、请求、作品、任务。
95
+ * 未绑定但带着 episodeId 的任务是「项目层候选」,不是「已绑定但作品失效」。
96
+ */
97
+ export function runtimeContextOf(input = {}) {
98
+ const raw = input && typeof input === 'object' ? input : {};
99
+ const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null;
100
+ const episodeId = String(anchor?.episodeId ?? raw.episodeId ?? '');
101
+ const status = String(anchor ? (anchor.status ?? '') : (raw.status ?? ''));
102
+ return {
103
+ projectId: String(raw.projectId ?? ''),
104
+ sessionId: String(raw.sessionId ?? ''),
105
+ requestId: String(raw.requestId ?? ''),
106
+ episodeId,
107
+ status,
108
+ bound: Boolean(episodeId) && BOUND_STATUSES.includes(status),
109
+ taskKind: String(anchor?.taskKind ?? raw.taskKind ?? ''),
110
+ seriesId: String(anchor?.seriesId ?? raw.seriesId ?? ''),
111
+ phase: String(anchor?.phase ?? raw.phase ?? ''),
112
+ anchorProjectId: String(anchor?.projectId ?? ''),
113
+ episodeUpdatedAt: String(anchor?.updatedAt ?? ''),
114
+ };
115
+ }
116
+
117
+ /** 人类反馈原文的短摘录;来源是历史消息,不重写。 */
118
+ export function sourceExcerptOf(input, { max = 160 } = {}) {
119
+ const clean = String(input || '').replace(/\s+/g, ' ').trim();
120
+ if (!clean) return '';
121
+ return clean.length > max ? `${clean.slice(0, max)}…` : clean;
122
+ }
123
+
124
+ function humanFeedback(messages) {
89
125
  const ids = [];
90
126
  const texts = [];
91
127
  for (const message of Array.isArray(messages) ? messages : []) {
@@ -95,22 +131,73 @@ export function candidateFrom({ messages, anchor, sessionId, projectId, now } =
95
131
  ids.push(String(message.id ?? ''));
96
132
  texts.push(text);
97
133
  }
98
- if (!texts.length) return null;
99
- const text = texts.join('\n').slice(0, 1200);
134
+ return { ids: ids.filter(Boolean), text: texts.join('\n').slice(0, 1200) };
135
+ }
136
+
137
+ /**
138
+ * 有作品绑定就走作品候选;没有绑定、但属于同一项目的规范纠正或长期要求,
139
+ * 仍可形成 project 作用域的 pending 候选。跨项目一律不接收,也不把整个项目绑到单条栏目。
140
+ */
141
+ export function candidateFrom({ messages, anchor, sessionId, projectId, now } = {}) {
142
+ /**
143
+ * 只有仍可用的作品绑定才算作品候选。任务状态里常常保留一个 unbound 的 episodeId,
144
+ * 旧逻辑见到 episodeId 就直接拒绝,导致这类项目永远走不到项目层候选。
145
+ */
146
+ const context = runtimeContextOf({ projectId, sessionId, anchor });
147
+ const bound = context.bound;
148
+ if (context.anchorProjectId && context.projectId && context.anchorProjectId !== context.projectId) return null;
149
+ const { ids, text } = humanFeedback(messages);
150
+ if (!text) return null;
100
151
  const cue = classifyFeedback(text);
101
152
  if (!cue.worth) return null;
153
+ const at = now || Date.now();
154
+ const messageIds = ids.slice(-8);
155
+ const sourceExcerpt = sourceExcerptOf(text);
156
+ if (bound) {
157
+ return {
158
+ projectId: context.projectId,
159
+ sessionId: context.sessionId,
160
+ scope: 'episode',
161
+ state: 'pending',
162
+ episodeId: context.episodeId,
163
+ taskKind: context.taskKind,
164
+ seriesId: context.seriesId,
165
+ phase: context.phase,
166
+ messageIds,
167
+ text,
168
+ sourceExcerpt,
169
+ cue,
170
+ expectedTarget: {
171
+ scope: 'episode',
172
+ projectId: context.projectId,
173
+ episodeId: context.episodeId,
174
+ ...(context.taskKind ? { taskKind: context.taskKind } : {}),
175
+ ...(context.seriesId ? { seriesId: context.seriesId } : {}),
176
+ ...(context.phase ? { phase: context.phase } : {}),
177
+ },
178
+ episodeUpdatedAt: context.episodeUpdatedAt,
179
+ at,
180
+ };
181
+ }
182
+ if (!context.projectId) return null;
183
+ /** 没有作品绑定时,规范纠正同样按项目候选收集;单次局部改动交给整理器判空,不再用电平词一刀切。 */
184
+ if (cue.type !== 'spec_change') return null;
102
185
  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),
186
+ projectId: context.projectId,
187
+ sessionId: context.sessionId,
188
+ scope: 'project',
189
+ state: 'pending',
190
+ episodeId: '',
191
+ taskKind: '',
192
+ seriesId: '',
193
+ phase: '',
194
+ messageIds,
110
195
  text,
196
+ sourceExcerpt,
111
197
  cue,
112
- episodeUpdatedAt: anchor.updatedAt || '',
113
- at: now || Date.now(),
198
+ expectedTarget: { scope: 'project', projectId, applicability: 'unresolved' },
199
+ episodeUpdatedAt: '',
200
+ at,
114
201
  };
115
202
  }
116
203
 
@@ -157,36 +244,222 @@ export function normalizeConditions(conditions) {
157
244
  return out.slice(0, 8);
158
245
  }
159
246
 
247
+ const STATE_WEIGHT = { active: 3, trial: 2, pending: 1, paused: 0 };
248
+
249
+ function gramsOf(input) {
250
+ const text = String(input || '').toLowerCase();
251
+ const out = new Set();
252
+ for (const word of text.match(/[a-z0-9_]+/g) || []) if (word.length >= 2) out.add(word);
253
+ for (const run of text.match(/[\u4e00-\u9fa5]+/g) || []) {
254
+ for (let index = 0; index + 1 < run.length; index += 1) out.add(run.slice(index, index + 2));
255
+ }
256
+ return out;
257
+ }
258
+
259
+ function relevanceOf(record, context) {
260
+ const query = String(context.query || '').toLowerCase();
261
+ const dimensions = (Array.isArray(context.dimensions) ? context.dimensions : [])
262
+ .map((value) => String(value || '').toLowerCase()).filter(Boolean);
263
+ const topicKey = String(record.topicKey || '').toLowerCase();
264
+ const conditions = normalizeConditions(record.conditions);
265
+ const hay = [
266
+ topicKey,
267
+ record.title || '',
268
+ record.summary || '',
269
+ record.body || '',
270
+ conditions.map((row) => `${row.key}=${row.value}`).join(' '),
271
+ ].join(' ').toLowerCase();
272
+ let score = 0;
273
+ if (topicKey && query) {
274
+ if (query.includes(topicKey)) score += 150;
275
+ else {
276
+ const shared = [...gramsOf(topicKey)].filter((gram) => query.includes(gram)).length;
277
+ if (shared) score += 90;
278
+ }
279
+ }
280
+ for (const dimension of dimensions) if (hay.includes(dimension)) score += 80;
281
+ const queryGrams = gramsOf(query);
282
+ const fieldWeights = [[record.title, 10], [record.summary, 6], [record.body, 4]];
283
+ for (const [text, weight] of fieldWeights) {
284
+ let hits = 0;
285
+ for (const gram of gramsOf(text)) if (queryGrams.has(gram)) hits += 1;
286
+ score += Math.min(hits, 6) * weight;
287
+ }
288
+ for (const row of conditions) {
289
+ if (row.key === 'taskKind' && context.taskKind && row.value === context.taskKind) score += 15;
290
+ else if (row.key === 'seriesId' && context.seriesId && row.value === context.seriesId) score += 15;
291
+ else if (row.key === 'phase' && context.phase && row.value === context.phase) score += 15;
292
+ }
293
+ return score;
294
+ }
295
+
296
+ /** 先按相关性和可用性排序,再截断;排序稳定可复现,不依赖输入顺序。 */
297
+ export function rankRules(rules, context = {}) {
298
+ const rows = (Array.isArray(rules) ? rules : [])
299
+ .filter((record) => record && record.id)
300
+ .map((record, index) => ({
301
+ record,
302
+ index,
303
+ relevance: relevanceOf(record, context),
304
+ stateWeight: STATE_WEIGHT[record.learning?.state || record.state] ?? 1,
305
+ updated: String(record.updated || record.updatedAt || record.created || ''),
306
+ id: String(record.id),
307
+ }));
308
+ rows.sort((a, b) => b.relevance - a.relevance
309
+ || b.stateWeight - a.stateWeight
310
+ || b.updated.localeCompare(a.updated)
311
+ || a.id.localeCompare(b.id)
312
+ || a.index - b.index);
313
+ return rows.map((row) => row.record);
314
+ }
315
+
316
+ export function selectRules(rules, context = {}) {
317
+ const limit = Number(context.limit || LIMITS.organizerRules);
318
+ return rankRules(rules, context).slice(0, Math.max(1, limit));
319
+ }
320
+
321
+ /**
322
+ * 整理器可见规则:先按状态、最近使用、更新时间稳定排序,再取上限;
323
+ * 不能先 slice 再排序,否则真正可用的旧经验会被输入顺序挤掉。
324
+ */
325
+ export function orderOrganizerRules(rules, { limit = LIMITS.organizerRules } = {}) {
326
+ const cap = Math.max(1, Number(limit) || LIMITS.organizerRules);
327
+ const rows = (Array.isArray(rules) ? rules : [])
328
+ .filter((record) => record && record.id)
329
+ .map((record, index) => {
330
+ const lastUsed = Date.parse(record.lastUsedAt || record.learning?.lastUsedAt || record.learning?.usedAt || '');
331
+ const updated = Date.parse(record.learning?.updatedAt || record.updatedAt || record.updated || '');
332
+ return {
333
+ record,
334
+ index,
335
+ stateWeight: STATE_WEIGHT[record.learning?.state || record.state] ?? 1,
336
+ lastUsed: Number.isFinite(lastUsed) ? lastUsed : 0,
337
+ updated: Number.isFinite(updated) ? updated : 0,
338
+ id: String(record.id),
339
+ };
340
+ });
341
+ rows.sort((a, b) => b.stateWeight - a.stateWeight
342
+ || b.lastUsed - a.lastUsed
343
+ || b.updated - a.updated
344
+ || a.id.localeCompare(b.id)
345
+ || a.index - b.index);
346
+ return rows.slice(0, cap).map((row) => row.record);
347
+ }
348
+
160
349
  /** 构造一次受限调用输入:无工具、无前台历史,只有身份、少量规则和反馈映射。 */
350
+ /** 明确长期要求本身就是可信收集信号:整理器返回空时至少落一条规则,不让它无声消失。 */
351
+ export function pendingChangeFrom(feedback = {}) {
352
+ const text = String(feedback.text || '').replace(/\r/g, '').trim();
353
+ if (!text) return null;
354
+ const lines = text.split('\n').map((line) => line.trim()).filter(Boolean).slice(0, 6);
355
+ const currentText = lines.join('\n').slice(0, 240);
356
+ if (!currentText) return null;
357
+ const first = (lines[0] || currentText).split(/[,。!?;]/)[0].trim();
358
+ const topicKey = String(feedback.cue?.dimension || first || currentText).slice(0, 60);
359
+ return {
360
+ target: 'new',
361
+ feedbackType: 'spec_change',
362
+ topicKey,
363
+ currentText,
364
+ applicability: 'all_in_scope',
365
+ conditions: [],
366
+ evidence: ['u0'],
367
+ persistence: 'project_candidate',
368
+ /** 摘录从候选一路带到提交路径,模型返回空时也不丢来源。 */
369
+ sourceExcerpt: String(feedback.sourceExcerpt || sourceExcerptOf(text)).slice(0, 160),
370
+ };
371
+ }
372
+
373
+ /**
374
+ * 收集与展示共用的准入判断;启发式推断只能得到「待确认候选」,
375
+ * 不会因为写进存储就变成用户确认,除非候选本身带着明确的确认标记。
376
+ */
377
+ export function admissionOf(candidate, runtime = {}, purpose = 'collect') {
378
+ const mode = String(purpose || 'collect');
379
+ if (!['collect', 'display'].includes(mode)) return { ok: false, reason: '用途不认' };
380
+ if (!candidate || typeof candidate !== 'object') return { ok: false, reason: '没有候选' };
381
+ const context = runtimeContextOf({ ...(runtime || {}), projectId: runtime?.projectId ?? candidate.projectId, sessionId: runtime?.sessionId ?? candidate.sessionId });
382
+ if (!context.projectId) return { ok: false, reason: '缺少项目身份' };
383
+ if (candidate.projectId && String(candidate.projectId) !== context.projectId) return { ok: false, reason: '候选不属于这个项目' };
384
+ const cue = candidate.cue && typeof candidate.cue === 'object' ? candidate.cue : classifyFeedback(candidate.text);
385
+ if (!cue?.worth) return { ok: false, reason: '反馈不值得检查' };
386
+ const scope = candidate.scope === 'project' ? 'project' : 'episode';
387
+ if (scope === 'episode') {
388
+ if (!context.bound) return { ok: false, reason: '作品绑定已经失效', scope };
389
+ if (candidate.episodeId && String(candidate.episodeId) !== context.episodeId) return { ok: false, reason: '候选不属于这期作品', scope };
390
+ } else {
391
+ if (context.bound) return { ok: false, reason: '有作品绑定时不走项目候选', scope };
392
+ if (cue.type !== 'spec_change') return { ok: false, reason: '项目候选只收规范纠正', scope };
393
+ }
394
+ const confirmed = candidate.confirmed === true || runtime?.confirmed === true;
395
+ return {
396
+ ok: true,
397
+ purpose: mode,
398
+ scope,
399
+ projectId: context.projectId,
400
+ episodeId: scope === 'episode' ? context.episodeId : '',
401
+ cueType: cue.type,
402
+ longTerm: Boolean(cue.explicitLongTerm),
403
+ confirmed,
404
+ requiresUserConfirmation: !confirmed,
405
+ /** 写入后仍是待确认候选,不冒充已确认的长期规则。 */
406
+ persistence: 'project_candidate',
407
+ visibility: 'candidate',
408
+ };
409
+ }
410
+
161
411
  export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase, feedback, rules, ranges, maxBytes = LIMITS.inputBytes } = {}) {
412
+ const context = runtimeContextOf({ projectId, episodeId, taskKind, seriesId, phase });
162
413
  const refs = { feedback: {}, rules: {} };
163
414
  const source = (Array.isArray(rules) ? rules : []).filter((record) => record && record.id);
164
- /** 用户这次纠正涉及的规则先入输入:固定取前 6 条会把真正相关的旧经验挤掉。 */
415
+ /** 先按本次反馈的相关性排序,再取前几条:固定取前 6 条会把真正相关的旧经验挤掉。 */
165
416
  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] || '';
417
+ const prioritized = selectRules(source, {
418
+ query: feedback?.text || '',
419
+ dimensions: dimension ? [dimension] : [],
420
+ taskKind: context.taskKind,
421
+ seriesId: context.seriesId,
422
+ phase: context.phase,
423
+ limit: LIMITS.callRules,
424
+ });
425
+ const messageIds = (Array.isArray(feedback?.messageIds) ? feedback.messageIds : [])
426
+ .map((id) => String(id || '')).filter(Boolean).slice(-8);
427
+ const sourceExcerpt = String(feedback?.sourceExcerpt || sourceExcerptOf(feedback?.text || '')).slice(0, 160);
428
+ refs.feedback.u0 = messageIds[0] || '';
171
429
  const project = {
172
- projectId: bounded(projectId, 80),
173
- episodeId: bounded(episodeId, 80),
174
- ...(taskKind ? { taskKind } : {}),
175
- ...(seriesId ? { seriesId } : {}),
176
- ...(phase ? { phase } : {}),
430
+ projectId: bounded(context.projectId, 80),
431
+ ...(context.episodeId ? { episodeId: bounded(context.episodeId, 80) } : { scope: 'project' }),
432
+ ...(context.taskKind ? { taskKind: context.taskKind } : {}),
433
+ ...(context.seriesId ? { seriesId: context.seriesId } : {}),
434
+ ...(context.phase ? { phase: context.phase } : {}),
177
435
  };
178
436
  const rangesInput = {
179
437
  taskKind: (ranges?.taskKinds || []).slice(0, 8),
180
438
  seriesId: (ranges?.series || []).slice(0, 8),
181
439
  phase: PHASES.slice(),
182
440
  };
183
- const system = '你是项目记忆整理器,只输出 JSON。只能依据给定反馈修改已有规则或新建一条项目规则;不得调用工具,不得输出 JSON 之外的文字。没有可确定的长期变化时输出 {"changes":[]}。';
441
+ const system = '你是项目记忆整理器,只输出 JSON。只能依据给定反馈修改已有规则或新建一条项目规则;不得调用工具,不得输出 JSON 之外的文字。'
442
+ + '可复用的做法、偏好、禁止项,或对作品的可迁移纠正,都应落成规则;反馈没有说“以后”也可以新建,保存为待确认候选,由用户决定。'
443
+ + '同一主题已有规则时更新那条,不要新建重复规则;验收和执行检查点只能挂到已有规则。'
444
+ + '只改某一处文案、换一次值这类单次局部改动不建规则。'
445
+ + '只有明确仅限这一次、没有可执行内容、或无法判断时才输出 {"changes":[]}。';
184
446
  const feedbackHead = {
185
- type: feedback.cue?.type || 'unclear',
186
- longTerm: Boolean(feedback.cue?.explicitLongTerm),
447
+ type: feedback?.cue?.type || 'unclear',
448
+ longTerm: Boolean(feedback?.cue?.explicitLongTerm),
449
+ scope: feedback?.scope === 'project' ? 'project' : 'episode',
450
+ ...(feedback?.cue?.dimension ? { dimension: feedback.cue.dimension } : {}),
451
+ ...(sourceExcerpt ? { sourceExcerpt } : {}),
452
+ ...(messageIds.length ? { messageIds } : {}),
187
453
  };
188
454
  const tail = {
189
- allowed: { conditions: view.CONDITION_KEYS, ranges: rangesInput, maxChanges: LIMITS.maxChanges, execution_miss: '仅已有规则,currentText 写检查点', artifact_accept: '仅已有规则,记验收样例' },
455
+ allowed: {
456
+ conditions: view.CONDITION_KEYS,
457
+ ranges: rangesInput,
458
+ maxChanges: LIMITS.maxChanges,
459
+ execution_miss: '仅已有规则,currentText 写检查点',
460
+ artifact_accept: '仅已有规则,记验收样例',
461
+ existing: '同主题改 r0,不新建重复规则',
462
+ },
190
463
  output: {
191
464
  changes: [{
192
465
  target: 'r0 或 new', feedbackType: FEEDBACK_TYPES, topicKey: '短主题键', currentText: '当前可执行规范', applicability: 'all_in_scope|conditional|unresolved', conditions: [{ key: 'taskKind', value: 'video' }], evidence: ['u0'], persistence: 'project_candidate',
@@ -228,7 +501,7 @@ export function buildCallInput({ projectId, episodeId, taskKind, seriesId, phase
228
501
  inputRules.push(row);
229
502
  prompt = next;
230
503
  }
231
- return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt) };
504
+ return { system, prompt, messages: [{ role: 'user', content: prompt }], refs, rules: inputRules, bytes: Buffer.byteLength(system) + Buffer.byteLength(prompt), sourceExcerpt, messageIds };
232
505
  }
233
506
  /** 严格解析:截断、工具调用、额外字段、超限都整批拒绝,不做第二次修复调用。 */
234
507
  export function parseOutput(text, { finish, refs, rules, ranges } = {}) {
@@ -277,6 +550,22 @@ export function validateChange(change, { refs, rules, ranges } = {}) {
277
550
  revision: byRef.get(target).revision,
278
551
  conditions: byRef.get(target).conditions,
279
552
  };
553
+ /** 反馈类型决定能改什么:例外、检查点、认可都不能借机重写规范。 */
554
+ if (checkpointOnly && (change.topicKey !== undefined || change.title !== undefined
555
+ || change.summary !== undefined || change.applicability !== undefined || change.conditions !== undefined)) {
556
+ return { ok: false, reason: '执行检查点只记录检查点,不改规范' };
557
+ }
558
+ if (change.feedbackType === 'aspect_accept') {
559
+ if (target === 'new') return { ok: false, reason: '认可只能挂到匹配的已有规则' };
560
+ if (change.currentText !== undefined || change.topicKey !== undefined || change.title !== undefined
561
+ || change.summary !== undefined || change.applicability !== undefined) {
562
+ return { ok: false, reason: '认可只记录验收,不改规范' };
563
+ }
564
+ if (change.conditions !== undefined
565
+ && JSON.stringify(normalizeConditions(change.conditions)) !== JSON.stringify(normalizeConditions(existing?.conditions))) {
566
+ return { ok: false, reason: '认可条件与目标规则不一致' };
567
+ }
568
+ }
280
569
  if (change.persistence !== undefined && change.persistence !== 'project_candidate') return { ok: false, reason: '持久化方式不认' };
281
570
  const topicKey = change.topicKey === undefined ? (existing || sampleOnly ? undefined : '') : String(change.topicKey || '').trim();
282
571
  if (!existing && !sampleOnly && !topicKey) return { ok: false, reason: '新规则缺少主题键' };
@@ -319,6 +608,7 @@ export function validateChange(change, { refs, rules, ranges } = {}) {
319
608
  ...(applicability !== undefined ? { applicability } : {}),
320
609
  ...(conditions !== undefined ? { conditions } : {}),
321
610
  evidence,
611
+ ...(change.sourceExcerpt !== undefined ? { sourceExcerpt: bounded(change.sourceExcerpt, 160) } : {}),
322
612
  },
323
613
  };
324
614
  }
@@ -406,22 +696,59 @@ export function addCheckpoint(learning, checkpoint = {}) {
406
696
  return next;
407
697
  }
408
698
 
409
- /** 状态只看证据强度:明确长期要求或两个独立作品才 active;条件不确定就 pending。 */
699
+ const NON_NORM_TYPES = new Set(['execution_miss', 'artifact_accept', 'aspect_accept']);
700
+
701
+ /**
702
+ * 状态只看证据强度:明确长期要求或两个独立作品才 active;条件不确定就 pending。
703
+ * 例外/含糊/验收/执行检查点不形成长期规范;目标或规范变了,旧证据立即失效。
704
+ */
410
705
  export function nextLearningState({ existing, change, evidence, now, specChanged = false } = {}) {
411
706
  const at = new Date(now || Date.now()).toISOString();
412
707
  const learning = existing?.learning ? JSON.parse(JSON.stringify(existing.learning)) : { state: 'pending', supportedEpisodeIds: [], evidence: [] };
413
708
  const priorState = LEARNING_STATES.includes(learning.state) ? learning.state : 'pending';
414
- /** 规范正文或适用条件变了:旧证据不再支持新规范,只保留本次这一条。 */
415
- const episodes = specChanged ? [] : (Array.isArray(learning.supportedEpisodeIds) ? learning.supportedEpisodeIds.slice() : []);
709
+ const feedbackType = change?.feedbackType || '';
710
+ learning.updatedAt = at;
711
+ learning.lastFeedbackType = feedbackType || learning.lastFeedbackType || '';
712
+ /** 这些反馈只补充验收或检查点,不抬升状态,也不产生长期规范。 */
713
+ if (NON_NORM_TYPES.has(feedbackType) || feedbackType === 'task_exception' || feedbackType === 'unclear') {
714
+ if (!existing) learning.state = 'pending';
715
+ return learning;
716
+ }
717
+ const targetChanged = Boolean(change?.id && existing?.id && String(change.id) !== String(existing.id));
718
+ /** 规范正文、适用条件或目标对象变了:旧证据不再支持新规范,只保留本次这一条。 */
719
+ const reset = specChanged === true || targetChanged;
720
+ const episodes = reset ? [] : (Array.isArray(learning.supportedEpisodeIds) ? learning.supportedEpisodeIds.slice() : []);
416
721
  if (evidence?.episodeId && !episodes.includes(evidence.episodeId)) episodes.push(evidence.episodeId);
417
- const anchors = specChanged ? [] : (Array.isArray(learning.evidence) ? learning.evidence.slice() : []);
722
+ const anchors = reset ? [] : (Array.isArray(learning.evidence) ? learning.evidence.slice() : []);
418
723
  if (evidence?.episodeId && !anchors.some((row) => row.episodeId === evidence.episodeId)) anchors.push(evidence);
419
724
  learning.supportedEpisodeIds = episodes.slice(-LIMITS.evidence);
420
725
  learning.evidence = anchors.slice(-LIMITS.evidence).map((row) => ({ ...row }));
421
- learning.updatedAt = at;
422
- learning.lastFeedbackType = change?.feedbackType || learning.lastFeedbackType || '';
726
+ if (reset) {
727
+ learning.evidenceResetAt = at;
728
+ learning.evidenceResetReason = targetChanged ? 'target_changed' : 'spec_changed';
729
+ delete learning.samples;
730
+ }
731
+ const sourceRefs = Array.isArray(evidence?.messageIds) ? evidence.messageIds : [];
732
+ if (evidence?.episodeId || evidence?.sourceExcerpt || sourceRefs.length) {
733
+ const excerpt = bounded(evidence?.sourceExcerpt || evidence?.excerpt || '', 160);
734
+ const messageIds = (Array.isArray(evidence?.messageIds) ? evidence.messageIds : []).slice(-3).map((id) => bounded(id, 80));
735
+ if (excerpt) learning.sourceExcerpt = excerpt;
736
+ learning.sourceRefs = [...(Array.isArray(learning.sourceRefs) ? learning.sourceRefs : []), {
737
+ excerpt,
738
+ messageIds,
739
+ episodeId: String(evidence?.episodeId || ''),
740
+ feedbackType,
741
+ at,
742
+ }].slice(-LIMITS.evidence);
743
+ learning.binding = {
744
+ targetId: String(change?.id || existing?.id || 'new'),
745
+ baseRevision: Number(existing?.revision || 0),
746
+ messageIds,
747
+ at,
748
+ };
749
+ }
423
750
  if (change?.currentText) learning.lastText = change.currentText;
424
- if (priorState === 'paused' && !specChanged && !Boolean(evidence?.explicitLongTerm)) {
751
+ if (priorState === 'paused' && !reset && !Boolean(evidence?.explicitLongTerm)) {
425
752
  learning.state = 'paused';
426
753
  return learning;
427
754
  }
@@ -458,6 +785,58 @@ export function nextLearningState({ existing, change, evidence, now, specChanged
458
785
  return learning;
459
786
  }
460
787
 
788
+ /** 过期 pending 只是待复核失效,不销毁候选内容;可重新唤起。 */
789
+ export function candidateExpired(learning, now) {
790
+ const at = now || Date.now();
791
+ if (!learning || learning.state !== 'pending' || !learning.createdAt) return false;
792
+ return at - Date.parse(learning.createdAt) > LIMITS.trialMs;
793
+ }
794
+
795
+ /** 只数还有效的 pending;过期候选不占名额。 */
796
+ export function pendingCount(entries, now) {
797
+ const at = now || Date.now();
798
+ return (Array.isArray(entries) ? entries : []).filter((entry) => {
799
+ const learning = entry && entry.learning ? entry.learning : entry;
800
+ return learning && learning.state === 'pending' && !candidateExpired(learning, at);
801
+ }).length;
802
+ }
803
+
804
+ /** 重新唤起过期候选:换新的待确认窗口,但不伪造新的证据。 */
805
+ export function reviveCandidate(learning, { now } = {}) {
806
+ if (!learning) return learning;
807
+ const at = now || Date.now();
808
+ const next = JSON.parse(JSON.stringify(learning));
809
+ next.state = 'pending';
810
+ next.createdAt = new Date(at).toISOString();
811
+ next.updatedAt = next.createdAt;
812
+ next.recoveredAt = next.createdAt;
813
+ delete next.trialUntil;
814
+ delete next.blockedReason;
815
+ delete next.pausedAt;
816
+ delete next.pausedBy;
817
+ return next;
818
+ }
819
+
820
+ /**
821
+ * 准入顺序:先算目标状态,再看上限。pending 满了不能挡住会直接变成 trial/active 的更新,
822
+ * 已有规则的修订也永不因配额被挡。
823
+ */
824
+ export function admitChange({ existing, change, evidence, now, explicitLongTerm = false, pending = 0, rules = 0 } = {}) {
825
+ const merged = mergeChange(existing || null, change, { now });
826
+ const learning = nextLearningState({
827
+ existing: existing || { learning: merged.learning },
828
+ change,
829
+ evidence: { ...evidence, explicitLongTerm },
830
+ specChanged: merged.specChanged === true,
831
+ now,
832
+ });
833
+ const next = { merged, learning };
834
+ if (existing) return { ok: true, ...next };
835
+ if (Number(rules) + 1 > LIMITS.rules) return { ok: false, reason: '项目实验规则已经到上限' };
836
+ if (learning.state === 'pending' && Number(pending) >= LIMITS.pending) return { ok: false, reason: '待确认候选已经到上限' };
837
+ return { ok: true, ...next };
838
+ }
839
+
461
840
  export function pauseLearning(learning, { by = 'feedback', reason = 'feedback_pending', now } = {}) {
462
841
  const next = learning ? JSON.parse(JSON.stringify(learning)) : { supportedEpisodeIds: [], evidence: [] };
463
842
  next.state = 'paused';
@@ -480,15 +859,43 @@ export function resumeLearning(learning, { by = 'management', now } = {}) {
480
859
  }
481
860
 
482
861
  /** 频次先在项目锁内扣,失败也计数;超限返回延后原因。 */
483
- export function quota(learning, { episodeId, now } = {}) {
862
+ /**
863
+ * 滚动窗口内的调用次数:以最近一次调用时间判断,不依赖 recent 数组;
864
+ * 关开实验只清在途观察(recent),attempts 里的已用额度不会被开关重置。
865
+ */
866
+ export function attemptWindowOf(learning, { now } = {}) {
484
867
  const at = now || Date.now();
485
868
  const ledger = learning && typeof learning === 'object' ? learning : {};
486
869
  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 };
870
+ const recent = (Array.isArray(ledger.recent) ? ledger.recent : [])
871
+ .filter((value) => Number.isFinite(Date.parse(value)) && at - Date.parse(value) < LIMITS.windowMs);
872
+ let calls = 0;
873
+ let rows = 0;
874
+ for (const row of Object.values(attempts)) {
875
+ if (!row || typeof row !== 'object') continue;
876
+ const lastAt = Date.parse(row.lastAt || '');
877
+ /** 老数据没有时间戳时按仍在窗口内算,宁可少放一次也不放宽额度。 */
878
+ if (Number.isFinite(lastAt) && at - lastAt >= LIMITS.windowMs) continue;
879
+ calls += Number(row.count || 0);
880
+ rows += 1;
881
+ }
882
+ return { at, recent: recent.length, rows, calls, projectCount: Math.max(recent.length, calls) };
883
+ }
884
+
885
+ export function quota(learning, { episodeId, now } = {}) {
886
+ const window = attemptWindowOf(learning, { now });
887
+ const attempts = learning && learning.attempts && typeof learning.attempts === 'object' ? learning.attempts : {};
888
+ /** 项目层候选没有作品身份,store 用 project:<key> 记账:只受项目滚动额度约束。
889
+ * 同一批反馈只算一次的职责在调用方(operationId 重放),不能拿“每期两次”把整个项目卡死。 */
890
+ const projectScoped = !episodeId || String(episodeId).startsWith('project:');
891
+ const episodeCount = projectScoped ? 0 : Number(attempts[episodeId]?.count || 0);
892
+ if (!projectScoped && episodeCount >= LIMITS.attemptsPerEpisode) {
893
+ return { ok: false, reason: 'episode_limit', episodeCount, projectCount: window.projectCount };
894
+ }
895
+ if (window.projectCount >= LIMITS.attemptsPerProject) {
896
+ return { ok: false, reason: 'project_limit', episodeCount, projectCount: window.projectCount };
897
+ }
898
+ return { ok: true, episodeCount, projectCount: window.projectCount };
492
899
  }
493
900
 
494
901
  export function recordAttempt(learning, { episodeId, now } = {}) {
@@ -569,17 +976,18 @@ export function effectSummary(learning) {
569
976
  };
570
977
  }
571
978
 
572
- /** 到期的 trial/paused 按边界清理;active 与用户固定规范不走 TTL。 */
979
+ /** 到期的 trial/paused 按边界清理;过期 pending 只标可恢复,不销毁;active 与用户固定规范不走 TTL。 */
573
980
  export function lifecycle(entries, now) {
574
981
  const at = now || Date.now();
575
982
  const purge = [];
576
983
  const paused = [];
984
+ const recoverable = [];
577
985
  for (const entry of Array.isArray(entries) ? entries : []) {
578
986
  const learning = entry?.learning;
579
987
  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);
988
+ if (learning.state === 'pending' && candidateExpired(learning, at)) recoverable.push(entry.id);
581
989
  else if (learning.state === 'trial' && learning.trialUntil && at - Date.parse(learning.trialUntil) > 0) paused.push(entry.id);
582
990
  else if (learning.state === 'paused' && learning.pausedAt && at - Date.parse(learning.pausedAt) > LIMITS.pausedMs) purge.push(entry.id);
583
991
  }
584
- return { purge, paused };
992
+ return { purge, paused, recoverable };
585
993
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.16.2",
3
+ "version": "0.17.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,7 @@
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/client.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/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",
12
12
  "bench": "node bench/bench.mjs",
13
13
  "bench:evolution": "node bench/evolution.mjs"
14
14
  },
@@ -33,6 +33,9 @@
33
33
  "README.md",
34
34
  "TRIGGERS.md",
35
35
  "docs",
36
+ "!docs/competitive-notes.md",
37
+ "!docs/ha-audit.md",
38
+ "!docs/ROADMAP.md",
36
39
  "LICENSE",
37
40
  "bench",
38
41
  "test"