dsh-lite-memory 0.15.0 → 0.16.2

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/README.md CHANGED
@@ -23,7 +23,7 @@ Lightweight memory for the DeepSeek Harness. No standing system prompt, nothing
23
23
 
24
24
  写入时分两层:默认只在这个项目生效;用户说「以后所有项目都这样」才进全局层(200 条起提示,硬上限 1000 条),检索时一起查。同一主题在不同前提下用 `when` 区分,各存一条。
25
25
 
26
- 写入前先蒸馏:标题是结论,正文只留原则、判断规则和触发条件,不把具体尺寸、字号、层级、颜色或一次性数字记进来;需要这些值时按记忆里的指针查当前代码或文档。单条正文超过 1200 字节会被拒绝,先删细节再存。
26
+ 写入前先蒸馏:标题是结论,正文只留原则、判断规则和触发条件,不把具体尺寸、字号、层级、颜色或一次性数字记进来;需要这些值时按记忆里的指针查当前代码或文档。单条正文超过 4096 字节会被拒绝,先删细节再存;摘要超长自动截到 60 字。
27
27
 
28
28
  触发词:`记住`、`记一下`、`上次`、`规范`、`约定`、`路径`、`凭据怎么取`。
29
29
 
@@ -39,7 +39,7 @@ Those lines belong to the turn that made them: each turn is rebuilt from its own
39
39
 
40
40
  Writes land in one of two layers: the current project by default, or the global layer (warns past 200, hard ceiling 1000) when the user says the rule holds for every project. Both layers are searched together, and a `when` field separates the same subject under different preconditions.
41
41
 
42
- Before writing, distill: the title is the conclusion and the body keeps only the principle, decision rule and trigger condition. Concrete dimensions, font sizes, z-index, colors and one-off numbers stay out; the memory points at where to read the current value instead. A body over 1200 bytes is refused, so drop detail before saving.
42
+ Before writing, distill: the title is the conclusion and the body keeps only the principle, decision rule and trigger condition. Concrete dimensions, font sizes, z-index, colors and one-off numbers stay out; the memory points at where to read the current value instead. A body over 4096 bytes is refused, so drop detail before saving; an over-long summary is truncated to 60 characters.
43
43
 
44
44
  ## 点一下就存 / One click, no turn
45
45
 
@@ -80,13 +80,13 @@ On the Memories tab, the search row also switches between list and graph; either
80
80
  - **0 依赖**,存储层无定时器、无监听、无网络,只有同步单文件写;写入用一把有超时的锁,别的进程正在写就明确报「忙」,不抢别人的锁。
81
81
  - 搜索最多 4 条、400 字;正文只在 `memory_read` 里出现,全文永不进上下文。
82
82
  - 普通检索只列当前有效的记录;已经归档或被更新替换的旧说法不混进来,要查历史得在 `memory_search` 里显式要 `history`。
83
- - 上限:单条正文 1200 字节、整库正文 512 KB、每层 1000 条、标题与摘要各 60 字;每层 200 条只当提示线。90 天未用只留 `id + 标题`。到了硬上限不拒绝新记忆:把同层最久没被读的一条移进归档,保存结果里写明是哪条(正文与完整记录都留着,可人工取回),单条本身超预算才报错;用户明确说「别记」也进归档。文件从不删除。
83
+ - 上限:单条正文 4096 字节、整库正文 1 MB、每层 1000 条、标题与摘要各 60 字;每层 200 条只当提示线。90 天未用只留 `id + 标题`。到了硬上限不拒绝新记忆:把同层最久没被读的一条移进归档,用户固定过的规则不动,保存结果里写明是哪条(正文与完整记录都留着,可人工取回),单条本身超预算才报错;用户明确说「别记」也进归档。文件从不删除。
84
84
  - 一个项目一个目录,项目之间互相看不见。
85
85
 
86
86
  - **Zero dependencies.** The store has no timers, no listeners, no network — just synchronous single-file writes, guarded by a lock that gives up after 500 ms instead of stealing it.
87
87
  - Search returns at most 4 hits and 400 characters. Full text only appears in `memory_read`.
88
88
  - Normal search lists current entries only; archived notes and superseded wording stay out unless `memory_search` asks for `history`.
89
- - Limits: 1200 bytes per body, 512 KB of bodies per store, 1000 entries per layer, 60 characters per title or summary; 200 entries per layer is just a warning line. At the hard ceiling a save is never refused: the least recently read entry in the same layer moves to archive and the result names it (body and full record kept, restorable by hand); only an entry that alone exceeds the budget fails. Saying 「别记」 also archives. Files are never deleted.
89
+ - Limits: 4096 bytes per body, 1 MB of bodies per store, 1000 entries per layer, 60 characters per title or summary; 200 entries per layer is just a warning line. At the hard ceiling a save is never refused: the least recently read entry in the same layer moves to archive (pinned rules are never evicted) and the result names it (body and full record kept, restorable by hand); only an entry that alone exceeds the budget fails. Saying 「别记」 also archives. Files are never deleted.
90
90
  - One directory per project, so projects never see each other's notes.
91
91
 
92
92
  ## 项目自进化〔实验〕 / Project self-evolution (experimental)
package/anchor.js CHANGED
@@ -16,12 +16,22 @@ const REFERENCE_WORDS = /(这个|这条|这次|上次|之前|刚才|上面|前
16
16
  const TASK_STOP = new Set(['一个','一下','这个','那个','我们','你们','他们','什么','怎么','可以','需要','帮我','帮忙','请问','然后','现在','还是','就是','没有','不是','以及','关于','进行','使用','问题','任务','内容','时候','已经','如果','因为','所以','但是','而且','或者','这些','那些','这样','那样','一下','一点','一些']);
17
17
  /** 判断任务是否换了对象时,这些词只表示动作或指代,不算对象证据。 */
18
18
  const TASK_NOISE = /(继续|再做一期|下一期|再来一期|再开一期|续一集|再拍一期|接着|然后|接下来|现在|帮我|帮忙|这个|这条|这次|上次|之前|刚才|上面|前面|下面|后面|这里|还是|同样|再改|排查|处理|检查|看看|看一下|看下|修复|解决|优化|调整|修改|分析|研究|对比|评估|调研|测试|设计|实现|开发|搭建|部署|定位|复现|查一下|找一下|问题|任务|内容|一下)/g;
19
- const NEW_OBJECT_VERB = /(排查|检查|看看|看一下|看下|分析|研究|对比|评估|调研|测试|设计|实现|开发|搭建|部署|修复|解决|处理|定位|复现|查一下|找一下)/;
20
- const NEW_TASK_MARKER = /(现在|接下来|然后|下面|新任务|另一个|重新|改做|换个|换一个)/;
21
19
  /** 续接词后面还得跟一个动作,才算真正在改旧稿;单说「继续 / 这个 / 之前」不构成复用依据。 */
22
20
  const CONTINUATION_ACTION = /(继续|接着|然后|再|还是|同样)\s*(做|改|调|弄|看|写|处理|优化|调整|检查|完成)/;
21
+ /** 明确指向刚才那一版作品的说法,是沿用旧范围的正面证据;「这版/那版」只是指示,不算旧稿。 */
22
+ const OLD_WORK_REFERENCE = /(刚才那版|之前那版|上一版|原稿|旧稿)/;
23
+ /** 旧稿被放下、否定或只作为话题提起时,不能因为它出现就恢复旧范围。 */
24
+ const OLD_WORK_PAUSE = /(先放着|放着|放一边|先不|先别|别管|不用管|搁置|暂停|不做了|先不做|不用了)/;
25
+ const OLD_WORK_ACTION = /(继续|接着|再改|改成|改一下|调整|优化|改改|开头|结尾|结构|节奏|语气|口吻|文案|措辞|细节|版本)/;
26
+ /** 创作类与工程类任务的默认判断不同;同一主题从写口播切到排查代码,不能沿用旧栏目。 */
27
+ const CONTENT_INTENT = /(口播|拍摄|录制|剪辑|文案|封面|字幕|直播|视频|选题|脚本|一期|栏目)/;
28
+ const ENGINEERING_INTENT = /(代码|排查|故障|部署|调试|重构|接口|数据库|查询|报错|日志|服务|环境|依赖|测试|配置|修复)/;
29
+ /** 这些词描述的是旧稿的某个方面,不是新对象;不该因为它们出现就撤销旧范围。 */
30
+ const ASPECT_NOISE = /(节奏|速度|风格|语气|口吻|结构|长度|时长|篇幅|配色|字号|字体|标题|文案|措辞|逻辑|细节|开头|结尾|过渡|画面|镜头|音乐|音效|字幕|参数|格式|排版)/g;
31
+ /** 程度、指代和虚词组成的二字片段不是对象证据。 */
32
+ const GENERIC_CHARS = /[再更稍很太挺点些快慢多少好坏大小高低长短早晚新旧一二三四五六七八九十百千万个把的得地了着过吗呢吧啊]/;
23
33
  function taskTerms(value) {
24
- const plain = text(value, 200).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(TASK_NOISE, ' ').replace(/\s+/g, ' ').trim();
34
+ const plain = text(value, 200).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(ASPECT_NOISE, ' ').replace(TASK_NOISE, ' ').replace(/\s+/g, ' ').trim();
25
35
  const terms = new Set();
26
36
  for (const word of plain.match(/[a-z][a-z0-9_-]{2,}/g) || []) terms.add(word);
27
37
  const runs = plain.match(/[\u4e00-\u9fff]+/g) || [];
@@ -29,7 +39,8 @@ function taskTerms(value) {
29
39
  if (TASK_STOP.has(run)) continue;
30
40
  for (let i = 0; i + 2 <= run.length; i += 1) {
31
41
  const gram = run.slice(i, i + 2);
32
- if (!TASK_STOP.has(gram)) terms.add(gram);
42
+ if (TASK_STOP.has(gram) || GENERIC_CHARS.test(gram)) continue;
43
+ terms.add(gram);
33
44
  }
34
45
  }
35
46
  return terms;
@@ -37,19 +48,28 @@ function taskTerms(value) {
37
48
  /** 继承旧任务要有正面证据:新要求与本轮任务没有实质对象交集时,先按新任务处理。 */
38
49
  export function looksLikeNewTask(query, previousText) {
39
50
  const current = text(query, 200);
40
- if (!current || isThanks(current) || isRevision(current)) return false;
51
+ if (!current || isThanks(current)) return false;
41
52
  const next = taskTerms(current);
42
53
  const previous = taskTerms(previousText);
43
54
  const cue = isContinuation(current) || REFERENCE_WORDS.test(current);
44
55
  /** 只剩指代或续接词时,必须另有动作才算续接;否则宁可换任务也不沿用旧范围。 */
45
56
  if (!next.size) return cue ? !CONTINUATION_ACTION.test(current) : false;
46
57
  if (!previous.size) return false;
58
+ /** 同一主题换了工作类型(口播 → 排查代码)是新任务,不靠对象词重叠判续作。 */
59
+ const nextKind = CONTENT_INTENT.test(current) ? (ENGINEERING_INTENT.test(current) ? '' : 'content')
60
+ : (ENGINEERING_INTENT.test(current) ? 'engineering' : '');
61
+ const previousKind = CONTENT_INTENT.test(previousText) ? (ENGINEERING_INTENT.test(previousText) ? '' : 'content')
62
+ : (ENGINEERING_INTENT.test(previousText) ? 'engineering' : '');
63
+ if (nextKind && previousKind && nextKind !== previousKind) return true;
47
64
  for (const term of next) if (previous.has(term)) return false;
48
- /** 只有指代或「继续」时,还要有明确的新对象动作、转向词或新英文对象才换任务。 */
49
- if (!cue) return true;
50
- if (NEW_OBJECT_VERB.test(current) || NEW_TASK_MARKER.test(current)) return true;
51
- for (const term of next) if (/^[a-z]/.test(term)) return true;
52
- return false;
65
+ /** 指向旧稿还要有继续改它的动作;「旧稿先放着」「这版数据库配置怎么改」都算新任务。 */
66
+ if (OLD_WORK_REFERENCE.test(current) && !OLD_WORK_PAUSE.test(current) && OLD_WORK_ACTION.test(current)) return false;
67
+ /**
68
+ * 有实质对象词却跟旧任务没有任何交集:这就是新任务,不因为句首带了
69
+ * 「继续」「这个」就把旧范围连同正文一起搬过来。继承只认对象交集、
70
+ * 明确的旧稿指代,或者除动作/指代外没有新对象(上面两条)。
71
+ */
72
+ return true;
53
73
  }
54
74
 
55
75
  export function emptyTasks() {
@@ -76,6 +96,27 @@ export function isRevision(query) {
76
96
  return REVISE_WORDS.test(text(query, 160));
77
97
  }
78
98
 
99
+ const NEGATED_SERIES = /(不要|不用|别|不按|不照|不是|并非|先不|不做|不搞|忽略|跳过|不管|别提)[^,。;!?\n]{0,10}$/;
100
+ const MENTION_SERIES = /(日志|报错|错误|信息|提到|说到|包含|关键词|这个词|源码|代码|插件|文档|规则名|栏目名|分析)[^,。;!?\n]{0,10}$/;
101
+ const PAUSED_SERIES = /^(先放着|先放|放一边|先不做|不做了|不用了|暂停)/;
102
+ const PAUSED_BEFORE = /(先放着|先放|放一边|先不做|不做了|不用了|暂停)[^,。;!?\n]{0,4}$/;
103
+ /** 提到栏目名不等于选中它:否定、引用、看源码或「先放着」都只是话题,不能建绑定。 */
104
+ export function seriesChosenBy(query, series) {
105
+ const value = text(query, 400);
106
+ const name = text(series, 40);
107
+ if (!value || !name) return false;
108
+ let from = 0;
109
+ while (from <= value.length) {
110
+ const at = value.indexOf(name, from);
111
+ if (at < 0) return false;
112
+ const before = value.slice(Math.max(0, at - 14), at);
113
+ const after = value.slice(at + name.length);
114
+ if (!NEGATED_SERIES.test(before) && !MENTION_SERIES.test(before) && !PAUSED_BEFORE.test(before) && !PAUSED_SERIES.test(after)) return true;
115
+ from = at + name.length;
116
+ }
117
+ return false;
118
+ }
119
+
79
120
  export function requestBatchKey(messageIds) {
80
121
  const ids = [...new Set((Array.isArray(messageIds) ? messageIds : []).map((id) => String(id || '')).filter(Boolean))].sort();
81
122
  if (!ids.length) return '';
@@ -271,7 +312,7 @@ export function routeTask(tasksInput, input = {}) {
271
312
  }
272
313
  const registered = (Array.isArray(input.registeredSeries) ? input.registeredSeries : []).map((item) => text(item, 40)).filter(Boolean);
273
314
  /** 用户在这句话里点名的栏目优先于旧栏目和项目默认值。 */
274
- const namedSeries = registered.find((series) => series && query.includes(series)) || '';
315
+ const namedSeries = registered.find((series) => seriesChosenBy(query, series)) || '';
275
316
  const changedSeries = current && namedSeries && namedSeries !== (current.seriesId || '') ? namedSeries : '';
276
317
  const fallbackKind = (currentEpisode && currentEpisode.taskKind)
277
318
  || (input.defaults && input.defaults.taskKind) || '';
@@ -311,9 +352,10 @@ export function routeTask(tasksInput, input = {}) {
311
352
  return { tasks, anchor: current, episode: currentEpisode, decision: { kind: 'none', reason: 'thanks', pointer: true }, changed: false };
312
353
  }
313
354
 
314
- /** 先认新对象,再认续作:带「继续 / 这个」但换了对象,也必须撤销旧范围。 */
355
+ /** 先认新对象,再认续作:带「继续 / 这个」但换了对象,也必须撤销旧范围。
356
+ * 本句点名了已登记栏目(「再做一期口播」)就是明确的对象证据,不算换任务。 */
315
357
  if (current && currentEpisode && currentEpisode.status !== 'unbound'
316
- && !SWITCH_WORDS.test(query) && !changedSeries
358
+ && !SWITCH_WORDS.test(query) && !changedSeries && !namedSeries
317
359
  && looksLikeNewTask(query, currentEpisode.taskText || '')) {
318
360
  const transitionId = transitionIdFor(batchKey, tasks.revision);
319
361
  const existing = Object.values(tasks.episodes).find((episode) => episode.transitionId === transitionId);
package/client.js CHANGED
@@ -852,7 +852,7 @@ window.__ModuleLoader__.load({
852
852
  const unresolved = rule.applicability === 'unresolved'
853
853
  || (rule.applicability === 'conditional' && (!Array.isArray(rule.conditions) || rule.conditions.length === 0));
854
854
  if (unresolved) return '适用范围待确认,暂不参与回答。';
855
- if (rule.state === 'pending') return '尚未参与回答,确认后才会试用。';
855
+ if (rule.state === 'pending') return '还没生效,采纳后才参与回答。';
856
856
  if (rule.state === 'trial') return '在条件匹配的任务中参与参考,到期后暂停。';
857
857
  if (rule.state === 'active') return '在适用任务中作为默认参考,当前明确要求优先。';
858
858
  return '';
@@ -865,7 +865,7 @@ window.__ModuleLoader__.load({
865
865
  if (conditions.length === 0) return '待确认';
866
866
  return conditions.map((item) => `${CONDITION_LABEL[item.key] || item.key} ${item.value}`).join('、');
867
867
  }
868
- return '所有项目';
868
+ return '本项目内';
869
869
  };
870
870
 
871
871
  const evidenceTextOf = (row) => {
@@ -1267,7 +1267,7 @@ window.__ModuleLoader__.load({
1267
1267
  h(Toggle, { ariaLabel: '自进化', on: Boolean(memoryOn && evolution && evolution.enabled === true), busy: busy || !evolution || !canToggle || !memoryOn, onClick: canToggle && memoryOn ? toggleEvolution : undefined }),
1268
1268
  ),
1269
1269
  h('div', { style: PANEL_MUTED }, '从反馈中整理经验,按需使用'), // ui-copy-ignore 用户指定文案
1270
- h('div', { style: PANEL_MUTED }, '实验功能'), // ui-copy-ignore 用户指定文案
1270
+ h('div', { style: PANEL_MUTED }, '实验功能,会增加 token 消耗'), // ui-copy-ignore 用户指定文案
1271
1271
  );
1272
1272
  const switchBar = h('div', { style: { display: 'flex', alignItems: 'stretch', gap: '10px', flexWrap: 'wrap', padding: '12px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' } },
1273
1273
  globalView ? h('div', { style: { ...PANEL_MUTED, padding: '2px 0' } }, '全局记忆只读') : memorySwitch,
@@ -1528,14 +1528,16 @@ window.__ModuleLoader__.load({
1528
1528
  const groups = React.useMemo(() => {
1529
1529
  const list = Array.isArray(refs) ? refs : EMPTY;
1530
1530
  return {
1531
- reference: list.filter((item) => item.exposure === 'reference'),
1531
+ found: list.filter((item) => item.exposure === 'reference' && item.kind === 'memory-search'),
1532
+ provided: list.filter((item) => item.exposure === 'reference' && item.kind === 'memory-read'),
1532
1533
  update: list.filter((item) => item.exposure === 'update'),
1533
1534
  };
1534
1535
  }, [refs]);
1535
1536
  if (!sessionId && refs.length === 0) return null;
1536
- if (groups.reference.length === 0 && groups.update.length === 0) return null;
1537
+ if (groups.found.length === 0 && groups.provided.length === 0 && groups.update.length === 0) return null;
1537
1538
  return h('div', { style: TAIL },
1538
- h(RefSection, { label: '本轮参考', items: groups.reference, sessionId, turnKey: turn }),
1539
+ h(RefSection, { label: '检索发现', items: groups.found, sessionId, turnKey: turn }),
1540
+ h(RefSection, { label: '已提供正文', items: groups.provided, sessionId, turnKey: turn }),
1539
1541
  h(RefSection, { label: '本轮更新', items: groups.update, sessionId, turnKey: turn }),
1540
1542
  );
1541
1543
  };
@@ -0,0 +1,10 @@
1
+ # 0.16.0
2
+
3
+ - 任务切换不再把上一条任务的规则带进新任务:带明确对象的新要求按新任务处理,只有「继续改」这类没有对象的短句才继承。
4
+ - 自进化关掉或换代后,已经准备好的实验正文和 `contextId` 立即失效;重新打开也不复活旧正文。
5
+ - 试用到期的学习结果不再自动注入,检索、读取、自动注入共用同一档可用性判断。
6
+ - 检索先过滤不可用条目再截断,不会被待确认的候选挤掉真正的命中;只有待确认候选时明确返回「适用条件还没确认」。
7
+ - 写入口径放宽:正文上限 4096 字节,整库 1 MB;摘要超长自动截断;不再因为正文提到像素、字号或写成指令句而拒绝保存。
8
+ - 只改标题的学习规则保持 `active` 和已有证据,不再降成试用。
9
+ - 容量腾挪不归档用户固定过的规则;实验规则与待确认候选的额度不再重复计数。
10
+ - 面板:作用范围改成「本项目内」,候选状态与引用分组的文案对齐实际行为,自进化开关标明会增加消耗。
package/index.js CHANGED
@@ -635,8 +635,8 @@ const SAVE_PARAMS = {
635
635
  properties: {
636
636
  id: { type: 'string', description: '要更新的记忆 id(来自搜索结果或相近提示)。同一件事更新时用它,别另建一条' },
637
637
  title: { type: 'string', description: `标题:名词短语,用户一眼能认出是哪件事,不超过 ${LIMITS.titleChars} 字。它就是这条记忆的身份` },
638
- summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语,不超过 ${LIMITS.summaryChars} 字` },
639
- body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写像素、字号、层级、颜色、一次性数字或代码片段,这些查当前代码或文档。` },
638
+ summary: { type: 'string', description: `一句话说清要记的内容本身——用户只看这一行决定留不留,名词短语最好,超长会自动截到这个长度` },
639
+ body: { type: 'string', description: `正文 markdown,整段替换旧正文,不超过 ${LIMITS.entryBytes} 字节。只写原则、判断规则、触发条件和边界;不写一次性数字或代码片段,这些查当前代码或文档。` },
640
640
  when: { type: 'string', description: `前置条件或适用场景(「在哪台机器 / 哪个环境 / 什么情况下」),不超过 ${LIMITS.whenChars} 字。同一标题但前置条件不同,会存成两条,不合并;更新时不给就保留原值` },
641
641
  kind: { type: 'string', enum: ['preference', 'convention', 'procedure', 'pointer', 'pitfall', 'credential_hint', 'note'], description: '条目类型;更新时不给就保留原值' },
642
642
  tags: { type: 'array', items: { type: 'string' }, description: '最多 5 个标签;更新时不给就保留原值' },
@@ -1051,6 +1051,14 @@ export function apply(ctx, config) {
1051
1051
  return text;
1052
1052
  }
1053
1053
 
1054
+ /** 实验正文按条目记有效期:一条到期只撤一条,不把同请求里其他规则一起清掉。 */
1055
+ function experimentTextFrom(parts, now = Date.now()) {
1056
+ if (!parts || !Array.isArray(parts.items)) return '';
1057
+ const items = parts.items.filter((item) => !item.validUntil || now <= Date.parse(String(item.validUntil)));
1058
+ if (!items.length) return '';
1059
+ return `${parts.header}\n${items.map((item) => item.line).join('\n')}${parts.tail || ''}`;
1060
+ }
1061
+
1054
1062
  /** 本轮问题与栏目名的字面相关度:只展示有交集的栏目,交集为空就整段跳过。 */
1055
1063
  function relevantRanges(names, query) {
1056
1064
  const text = String(query || '').toLowerCase();
@@ -1070,8 +1078,19 @@ export function apply(ctx, config) {
1070
1078
  });
1071
1079
  }
1072
1080
 
1081
+ /** 实验正文只在「自进化开着,且还是同一代」时可用;关掉再开不能复活旧正文。 */
1082
+ function experimentStillCurrent(scope, held) {
1083
+ let evolution;
1084
+ try { evolution = evolutionStatus(scope); } catch { return false; }
1085
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false) return false;
1086
+ if (held && held.epoch && String(evolution.epoch || '') !== String(held.epoch)) return false;
1087
+ return true;
1088
+ }
1089
+
1073
1090
  /** 读决策 → 一段短提示:未绑定只给栏目索引,已绑定才给规则正文。 */
1074
1091
  function renderExperiment(scope, decision, context, held, win) {
1092
+ if (held && typeof held === 'object') { held.validUntil = ''; held.experimentParts = null; held.injectedIds = []; }
1093
+ if (!experimentStillCurrent(scope, held)) return '';
1075
1094
  if (!decision || decision.kind === 'none') return '';
1076
1095
  if (decision.kind === 'conflict') {
1077
1096
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
@@ -1095,6 +1114,8 @@ export function apply(ctx, config) {
1095
1114
  if (record.reviewState === 'legacy_unreviewed') continue;
1096
1115
  const learning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1097
1116
  if (learning.state !== 'active' && learning.state !== 'trial') continue;
1117
+ /** 试用到期、暂停或身份失效:自动注入与检索、读取用同一档可用性,不能各判各的。 */
1118
+ if (!view.experimentalUsable(record, Date.now())) continue;
1098
1119
  const status = view.statusOf(record, context);
1099
1120
  if (status === 'needs_context') { unknown.push(record); continue; }
1100
1121
  if (status !== 'ok') continue;
@@ -1106,8 +1127,7 @@ export function apply(ctx, config) {
1106
1127
  if (conflict) {
1107
1128
  return chargeExperiment(win, '这些实验规则互相冲突,先按用户这次说的做,别叠加对立的规则。');
1108
1129
  }
1109
- const lines = [];
1110
- const shown = [];
1130
+ const parts = { header: EXPERIMENT_RULES_HEADER, items: [], tail: held && held.contextId ? `\ncontextId=${held.contextId}` : '' };
1111
1131
  let used = Buffer.byteLength(EXPERIMENT_RULES_HEADER) + 1;
1112
1132
  for (const record of matched.slice(0, 3)) {
1113
1133
  const body = String(record.body || '');
@@ -1123,17 +1143,24 @@ export function apply(ctx, config) {
1123
1143
  const size = Buffer.byteLength(`\n${line}`);
1124
1144
  if (used + size > EXPERIMENT_BYTES) break;
1125
1145
  used += size;
1126
- lines.push(line);
1127
- shown.push(record.id);
1146
+ const trialLearning = record.learning && typeof record.learning === 'object' ? record.learning : {};
1147
+ const trialUntil = trialLearning.state === 'trial' && Number.isFinite(Date.parse(String(trialLearning.trialUntil || '')))
1148
+ ? String(trialLearning.trialUntil) : '';
1149
+ parts.items.push({ id: record.id, line, validUntil: trialUntil });
1150
+ }
1151
+ if (held) held.injectedIds = parts.items.map((item) => item.id);
1152
+ if (!parts.items.length && unknown.length) {
1153
+ /** 条件没确认的条目只报数量,绝不在模型侧暴露标题。 */
1154
+ return chargeExperiment(win, '有些实验规则的适用条件还没确认,先按用户这次说的做,别把它们当本轮依据。');
1128
1155
  }
1129
- if (held) held.injectedIds = shown;
1130
- if (!lines.length && unknown.length) {
1131
- const nameLine = unknown.slice(0, 3).map((record) => `- 「${record.title}」 · ${record.id}`).join('\n');
1132
- return chargeExperiment(win, `有些实验规则的适用条件还没确认,先别当成本轮依据:\n${nameLine}`);
1156
+ if (!parts.items.length) return '';
1157
+ if (held) held.experimentParts = parts;
1158
+ /** 兼容旧读取点:整体最早到期只取本轮真正注入的条目,不再被无关试验拖下水。 */
1159
+ if (held) {
1160
+ const deadlines = parts.items.map((item) => Date.parse(String(item.validUntil))).filter(Number.isFinite);
1161
+ if (deadlines.length) held.validUntil = new Date(Math.min(...deadlines)).toISOString();
1133
1162
  }
1134
- if (!lines.length) return '';
1135
- const tail = held && held.contextId ? `\ncontextId=${held.contextId}` : '';
1136
- return chargeExperiment(win, `${EXPERIMENT_RULES_HEADER}\n${lines.join('\n')}${tail}`);
1163
+ return chargeExperiment(win, experimentTextFrom(parts));
1137
1164
  }
1138
1165
 
1139
1166
  /** 一次真人请求只路由一次;同一批次重试复用同一 transitionId。 */
@@ -1240,7 +1267,7 @@ export function apply(ctx, config) {
1240
1267
  const contextId = anchor.contextIdFor(scope.key, batchKey, crypto.randomUUID().slice(0, 8));
1241
1268
  const created = {
1242
1269
  contextId, projectKey: scope.key, sessionId: win.sessionId, batchKey,
1243
- messageIds: win.messageIds.slice(), context: view.trustedContext(scope, {}),
1270
+ messageIds: win.messageIds.slice(), query: trustedQueryOf(win), context: view.trustedContext(scope, {}),
1244
1271
  anchor: null, ranges: rangesOf(scope), decision: { kind: 'none' }, epoch: status.epoch, at: Date.now(),
1245
1272
  };
1246
1273
  win.experiment = created;
@@ -1248,13 +1275,27 @@ export function apply(ctx, config) {
1248
1275
  return contextId;
1249
1276
  }
1250
1277
 
1278
+ /** 本轮真人消息是唯一可信的任务依据;模型自己写的 query 只用于检索词,不能拿来建绑定。 */
1279
+ function trustedQueryOf(win) {
1280
+ return win && typeof win.query === 'string' ? win.query : '';
1281
+ }
1282
+
1251
1283
  /** 前台绑定只升到 proposed:真正执行链还没发生,不冒充 active。 */
1252
- function bindSelection(execution, resolved, query) {
1284
+ function bindSelection(execution, resolved) {
1253
1285
  const held = resolved.held;
1286
+ if (!held || typeof held !== 'object') return { ok: false, status: 'needs_context', reason: '本轮还没有可核对的用户消息,先重新问一次当前任务' };
1287
+ const query = held.query || trustedQueryOf(resolved.win);
1288
+ if (!query) return { ok: false, status: 'needs_context', reason: '本轮还没有可核对的用户消息,先重新问一次当前任务' };
1254
1289
  const ranges = held.ranges || { series: [], taskKinds: [] };
1290
+ /** 自动路由已经选了范围就沿用同一个结果,工具绑定不重新猜一遍,避免两处给出不同任务。 */
1291
+ const routedAnchor = held.anchor && typeof held.anchor === 'object' ? held.anchor : null;
1255
1292
  const hits = [];
1256
- for (const value of ranges.series || []) if (query.includes(value)) hits.push({ seriesId: value });
1257
- for (const value of ranges.taskKinds || []) if (query.includes(value)) hits.push({ taskKind: value });
1293
+ if (routedAnchor && routedAnchor.seriesId) hits.push({ seriesId: routedAnchor.seriesId });
1294
+ else if (routedAnchor && routedAnchor.taskKind) hits.push({ taskKind: routedAnchor.taskKind });
1295
+ else {
1296
+ for (const value of ranges.series || []) if (anchor.seriesChosenBy(query, value)) hits.push({ seriesId: value });
1297
+ for (const value of ranges.taskKinds || []) if (anchor.seriesChosenBy(query, value)) hits.push({ taskKind: value });
1298
+ }
1258
1299
  if (hits.length !== 1) {
1259
1300
  const names = [...(ranges.series || []), ...(ranges.taskKinds || [])].slice(0, 3);
1260
1301
  return { ok: false, status: 'needs_context', reason: names.length ? '本轮没有匹配的记忆范围,按当前任务继续' : '本轮没有适用的记忆范围,按当前任务继续' };
@@ -1276,7 +1317,11 @@ export function apply(ctx, config) {
1276
1317
  resolved.context = held.context;
1277
1318
  const text = renderExperiment(resolved.scope, held.decision, held.context, held, resolved.win);
1278
1319
  const preparedState = prepared.get(execution && execution.agent);
1279
- if (preparedState) preparedState.experimentText = text;
1320
+ if (preparedState) {
1321
+ preparedState.experimentText = text;
1322
+ preparedState.experimentParts = held.experimentParts || null;
1323
+ preparedState.validUntil = held.validUntil || '';
1324
+ }
1280
1325
  return { ok: true };
1281
1326
  }
1282
1327
 
@@ -1297,7 +1342,17 @@ export function apply(ctx, config) {
1297
1342
  if (!memoryEnabled(scope)) return '';
1298
1343
  const epoch = controlStatus(scope).epoch || '';
1299
1344
  if (state.controlEpoch && epoch && state.controlEpoch !== epoch) return '';
1300
- return [state.experimentText, state.text].filter(Boolean).join('\n\n');
1345
+ /** 自进化关掉或换过代:关掉前的实验正文不再输出,重开也不复活。 */
1346
+ let evolutionText = state.experimentParts ? experimentTextFrom(state.experimentParts) : state.experimentText;
1347
+ if (evolutionText) {
1348
+ let evolution;
1349
+ try { evolution = evolutionStatus(scope); } catch { evolution = null; }
1350
+ if (!evolution || evolution.enabled !== true || evolution.compatible === false
1351
+ || (state.evolutionEpoch && String(evolution.epoch || '') !== state.evolutionEpoch)) evolutionText = '';
1352
+ }
1353
+ /** 没有逐条快照时才退回整段到期判断;有快照就只撤到期的那一条。 */
1354
+ if (evolutionText && !state.experimentParts && state.validUntil && Date.now() > Date.parse(String(state.validUntil))) evolutionText = '';
1355
+ return [evolutionText, state.text].filter(Boolean).join('\n\n');
1301
1356
  } catch {
1302
1357
  return '';
1303
1358
  }
@@ -1328,6 +1383,8 @@ export function apply(ctx, config) {
1328
1383
  title: { type: 'string' },
1329
1384
  summary: { type: 'string' },
1330
1385
  when: { type: 'string' },
1386
+ summaryShortened: { type: 'boolean' },
1387
+ fullTextInBody: { type: 'boolean' },
1331
1388
  revision: { type: 'integer' },
1332
1389
  merged: { type: 'boolean' },
1333
1390
  changed: { type: 'boolean' },
@@ -1361,7 +1418,7 @@ export function apply(ctx, config) {
1361
1418
  ? `memory · 需要确认:「${value.title}」与已有条目相近${value.crossLayer ? '(在另一层)' : ''}${value.sameTopic ? '(同一主题槽位)' : ''}\n${value.similar}\n要更新它,用 id 重存;确实是另一条,加 resolution="distinct"`
1362
1419
  : value.changed === false
1363
1420
  ? `memory · unchanged ${withTag(value, `"${value.title}"`)} · ${value.id}`
1364
- : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}`,
1421
+ : `memory · ${value.merged ? 'merged' : 'saved'} ${withTag(value, `"${value.title}"`)}${value.when ? `(${value.when})` : ''} · ${value.id}${value.similar ? ` · 相近:${value.similar}` : ''}${value.archived ? ` · 已归档「${value.archived}」腾位` : ''}${value.fullTextInBody ? ' · 摘要超过 60 字,完整原句已存进正文' : ''}`,
1365
1422
  }],
1366
1423
  },
1367
1424
  execute: async (args, execution) => {
@@ -1439,6 +1496,8 @@ export function apply(ctx, config) {
1439
1496
  title: saved.record.title,
1440
1497
  summary: saved.record.summary,
1441
1498
  when: saved.record.when || '',
1499
+ summaryShortened: Boolean(saved.draft?.summaryShortened),
1500
+ fullTextInBody: Boolean(saved.draft?.fullTextInBody),
1442
1501
  revision: saved.record.revision || 1,
1443
1502
  merged: saved.merged,
1444
1503
  changed: !saved.noop,
@@ -1507,6 +1566,7 @@ export function apply(ctx, config) {
1507
1566
  chars: { type: 'integer' },
1508
1567
  text: { type: 'string' },
1509
1568
  status: { type: 'string', enum: ['ok', 'budget_exhausted', 'memory_disabled', 'needs_context'] },
1569
+ reasonCode: { type: 'string' },
1510
1570
  refs: {
1511
1571
  type: 'array',
1512
1572
  items: {
@@ -1553,19 +1613,20 @@ export function apply(ctx, config) {
1553
1613
  const resolved = resolveRequestContext(execution, contextId);
1554
1614
  if (!resolved.ok) return { count: 0, chars: 0, text: resolved.reason, status: resolved.status || 'needs_context', refs: [] };
1555
1615
  if (wantsBind) {
1556
- const bound = bindSelection(execution, resolved, String(args.query || ''));
1616
+ const bound = bindSelection(execution, resolved);
1557
1617
  if (!bound.ok) return { count: 0, chars: 0, text: bound.reason, status: bound.status || 'needs_context', refs: [] };
1558
1618
  }
1559
1619
  const scope = resolved.scope;
1560
1620
  const context = resolved.context;
1561
- /** 检索只列当前任务真能用的条目;条件未知或不适用就只留状态,不给索引行。 */
1562
- const hits = search(scope, args.query, (args.limit ?? 3) + 3, {
1621
+ /** 检索只列当前任务真能用的条目;先取全量候选,过滤后再截断,避免不可用条目挤掉后面的命中。 */
1622
+ const limit = args.limit ?? 3;
1623
+ const rawHits = search(scope, args.query, 50, {
1563
1624
  context,
1564
1625
  history: args.history === true,
1565
1626
  allowExperimental: resolved.experimental === true,
1566
- }).filter((item) => (item.archived || item.status === 'ok')
1567
- && (!item.ownerProjectId || item.ownerProjectId === scope.key))
1568
- .slice(0, args.limit ?? 3);
1627
+ }).filter((item) => !item.ownerProjectId || item.ownerProjectId === scope.key);
1628
+ const hits = rawHits.filter((item) => item.archived || item.status === 'ok').slice(0, limit);
1629
+ const withheld = rawHits.filter((item) => !item.archived && item.status !== 'ok');
1569
1630
  const scopeName = scope.key === 'global' ? '' : projectNameOf(scope);
1570
1631
  const shown = hits.map((item) => ({
1571
1632
  ...view.toModelView(item, 'search', { status: item.status || (item.archived ? 'archived' : 'ok') }),
@@ -1575,6 +1636,27 @@ export function apply(ctx, config) {
1575
1636
  const rendered = renderIndex(shown.slice(0, kept));
1576
1637
  return { count: kept, chars: rendered.chars, text: rendered.text, status: 'ok', refs: refsOf(hits.slice(0, kept)) };
1577
1638
  };
1639
+ /** 有匹配但当前用不上:只说状态,不把标题、编号或旧规则递给模型,避免它被这些字面带偏。 */
1640
+ if (!hits.length && withheld.length) {
1641
+ const unresolved = withheld.some((item) => item.status === 'needs_context');
1642
+ const value = {
1643
+ count: 0,
1644
+ chars: 0,
1645
+ text: unresolved
1646
+ ? '有相关的项目记忆,但适用条件还没确认。先说明当前任务,或确认这条记忆的适用范围。'
1647
+ : '有相关的项目记忆,但当前任务明确不适用。',
1648
+ status: unresolved ? 'needs_context' : 'ok',
1649
+ reasonCode: unresolved ? 'applicability_unresolved' : 'not_applicable',
1650
+ refs: [],
1651
+ };
1652
+ if (win) {
1653
+ const bytes = jsonBytes(value);
1654
+ if (!affordable(win, bytes)) return { ...value, status: 'budget_exhausted', text: '' };
1655
+ win.bytes += bytes;
1656
+ win.reads += 1;
1657
+ }
1658
+ return value;
1659
+ }
1578
1660
  /** 没命中是正常结果,不是额度问题;空响应同样记一次本请求开销。 */
1579
1661
  if (!hits.length) {
1580
1662
  const empty = { count: 0, chars: 0, text: '', status: 'ok', refs: [] };
@@ -1632,10 +1714,20 @@ export function apply(ctx, config) {
1632
1714
  : value.status === 'memory_disabled'
1633
1715
  ? 'memory · 记忆检索已关闭'
1634
1716
  : value.repeat
1635
- ? `memory · 本请求已读过「${value.title}」 · ${value.id}`
1717
+ ? 'memory · 本请求已读过这条记忆'
1636
1718
  : value.body
1637
1719
  ? `memory · ${withTag(value, `「${value.title}」`)}${value.when ? `(${value.when})` : ''} · ${value.id}${statusLabel(value.status)}\n${value.body}`
1638
- : `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`,
1720
+ : value.links !== undefined && value.status === 'ok'
1721
+ ? `memory · 「${value.title}」的相关记忆\n${value.links || '(无)'}`
1722
+ : value.status === 'needs_context'
1723
+ ? 'memory · 这条记忆的适用条件还没确认'
1724
+ : value.status === 'not_applicable'
1725
+ ? 'memory · 这条记忆不适用于当前任务'
1726
+ : value.status === 'archived'
1727
+ ? 'memory · 这条记忆已归档'
1728
+ : value.status === 'gone'
1729
+ ? 'memory · 这条记忆已不存在'
1730
+ : 'memory · 当前不能读这条记忆',
1639
1731
  }],
1640
1732
  presentationMeta: (_args, value) => ({
1641
1733
  kind: 'memory-read',
@@ -1661,7 +1753,7 @@ export function apply(ctx, config) {
1661
1753
  const found = readLinks(scope, args.id, { context, allowExperimental });
1662
1754
  if (!found) throw new Error(`没有 ${args.id} 这条记忆`);
1663
1755
  if (found.status !== 'ok') {
1664
- const withheld = { id: found.id, scope: found.scope, status: found.status, links: '' };
1756
+ const withheld = { id: '', scope: '', status: found.status, links: '' };
1665
1757
  if (win) {
1666
1758
  const bytes = jsonBytes(withheld);
1667
1759
  if (!affordable(win, bytes)) return { id: '', scope: '', status: 'budget_exhausted' };
@@ -1687,22 +1779,22 @@ export function apply(ctx, config) {
1687
1779
  const record = readEntry(scope, args.id, { context, allowExperimental });
1688
1780
  if (!record) throw new Error(`没有 ${args.id} 这条记忆`);
1689
1781
  if (record.origin === 'project_evolution' && record.ownerProjectId && record.ownerProjectId !== scope.key) {
1690
- return { id: args.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'not_applicable', body: '' };
1782
+ return { id: '', scope: record.scope, status: 'not_applicable', body: '' };
1691
1783
  }
1692
1784
  if (record.origin === 'project_evolution' && !allowExperimental) {
1693
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1785
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1694
1786
  }
1695
- /** 暂停、过期或条件不匹配的实验条目只给状态,不给正文。 */
1787
+ /** 暂停、过期或条件不匹配的实验条目只给状态,不给标题和正文。 */
1696
1788
  if (record.origin === 'project_evolution' && !view.experimentalUsable(record)) {
1697
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1789
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1698
1790
  }
1699
1791
  const learningState = record.learning && typeof record.learning === 'object' ? record.learning : {};
1700
1792
  if (record.origin === 'project_evolution' && (learningState.state === 'paused' || learningState.blockedReason)) {
1701
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: 'needs_context', body: '' };
1793
+ return { id: '', scope: record.scope, status: 'needs_context', body: '' };
1702
1794
  }
1703
- /** 条件未知或不适用的条目只报状态:正文绝不能绕过自动召回的门槛。 */
1795
+ /** 条件未知或不适用的条目只报状态:正文和标题都不能绕过自动召回的门槛。 */
1704
1796
  if (record.status !== 'ok') {
1705
- return { id: record.id, title: record.title, when: record.when || '', revision: record.revision || 1, scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1797
+ return { id: '', scope: record.scope, status: record.status === 'needs_context' ? 'needs_context' : 'not_applicable', body: '' };
1706
1798
  }
1707
1799
  const revision = record.revision || 1;
1708
1800
  const body = String(record.body ?? '');
@@ -1936,6 +2028,8 @@ export function apply(ctx, config) {
1936
2028
  previous.text = snapshot ? snapshot.text : '';
1937
2029
  previous.cards = snapshot ? snapshot.cards : [];
1938
2030
  previous.experimentText = experimentText;
2031
+ previous.experimentParts = win && win.experiment ? (win.experiment.experimentParts || null) : null;
2032
+ previous.validUntil = win && win.experiment ? (win.experiment.validUntil || '') : '';
1939
2033
  previous.fingerprint = fingerprint;
1940
2034
  } else {
1941
2035
  clearPrepared(agent);
@@ -1948,13 +2042,15 @@ export function apply(ctx, config) {
1948
2042
  /** 工具模式也要开额度窗口:只是不自动注入,工具调用同样受每请求预算约束。 */
1949
2043
  const ids = fresh.map((message) => String(message?.id ?? '')).filter(Boolean).sort();
1950
2044
  const win = openWindow(agent, { sessionId, turn, messageIds: ids });
2045
+ /** 工具模式也要记下本轮真人原话:只用于核对绑定,不自动注入。 */
2046
+ const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
2047
+ if (win && query) win.query = query;
1951
2048
 
1952
2049
  if (recallMode() !== 'adaptive') {
1953
2050
  clearPrepared(agent);
1954
2051
  return decision;
1955
2052
  }
1956
2053
 
1957
- const query = view.newestUserQuery(fresh).slice(0, RECALL_QUERY_CHARS);
1958
2054
  if (!query) {
1959
2055
  clearPrepared(agent);
1960
2056
  return decision;
@@ -1995,9 +2091,13 @@ export function apply(ctx, config) {
1995
2091
  clearPrepared(agent);
1996
2092
  return decision;
1997
2093
  }
2094
+ let evolutionEpoch = '';
2095
+ try { evolutionEpoch = String(evolutionStatus(scope).epoch || ''); } catch { evolutionEpoch = ''; }
1998
2096
  const state = {
1999
2097
  text: snapshot ? snapshot.text : '',
2000
2098
  experimentText: experiment ? experiment.text : '',
2099
+ experimentParts: experiment && experiment.held ? (experiment.held.experimentParts || null) : null,
2100
+ validUntil: experiment && experiment.held ? (experiment.held.validUntil || '') : '',
2001
2101
  cards: snapshot ? snapshot.cards : [],
2002
2102
  query,
2003
2103
  sessionId,
@@ -2005,6 +2105,7 @@ export function apply(ctx, config) {
2005
2105
  signal,
2006
2106
  fingerprint,
2007
2107
  controlEpoch,
2108
+ evolutionEpoch,
2008
2109
  at: Date.now(),
2009
2110
  };
2010
2111
  prepared.set(agent, state);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lite-memory",
3
- "version": "0.15.0",
3
+ "version": "0.16.2",
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",
package/state.js CHANGED
@@ -182,9 +182,9 @@ export const LIMITS = {
182
182
  entries: 200,
183
183
  globalEntries: 200,
184
184
  hardEntries: 1000,
185
- entryBytes: 1200,
185
+ entryBytes: 4096,
186
186
  recordBytes: 10 * 1024,
187
- totalBytes: 512 * 1024,
187
+ totalBytes: 1024 * 1024,
188
188
  historyBytes: 512 * 1024,
189
189
  stateBytes: 2 * 1024 * 1024,
190
190
  titleChars: 60,
@@ -747,10 +747,12 @@ export function enforceBudget(state, scope) {
747
747
  const active = state.entries.filter((entry) => entry.status === 'active');
748
748
  const bytes = active.reduce((sum, entry) => sum + entryBytesOf(entry), 0);
749
749
  if (active.length <= LIMITS.hardEntries && bytes <= LIMITS.totalBytes) break;
750
- const idle = active.filter((entry) => now - entryLastTouch(entry) >= IDLE_MS);
751
- const pool = idle.length ? idle : active;
750
+ /** 用户固定过的规则不参与容量腾挪;只从没固定的条目里选最久没读的。 */
751
+ const movable = active.filter((entry) => entry.maintenance?.learning?.pinned !== true);
752
+ if (!movable.length) throw new StateError('budget_exhausted', '记忆库已满,固定住的规则不参与腾挪:先取消固定或精简正文');
753
+ const idle = movable.filter((entry) => now - entryLastTouch(entry) >= IDLE_MS);
754
+ const pool = idle.length ? idle : movable;
752
755
  const victim = pool.reduce((oldest, entry) => (entryLastTouch(entry) < entryLastTouch(oldest) ? entry : oldest));
753
- if (!victim) throw new StateError('budget_exhausted', `记忆库已满:这一条自己就超过 ${LIMITS.totalBytes} 字节正文预算,先精简正文`);
754
756
  victim.status = 'archived';
755
757
  victim.updatedAt = new Date().toISOString();
756
758
  evicted.push({ id: victim.id, title: victim.current.title });