dsh-lite-memory 0.15.0 → 0.16.4

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/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
  }
@@ -1306,7 +1361,7 @@ export function apply(ctx, config) {
1306
1361
 
1307
1362
  ctx.effect(() => tools.register({
1308
1363
  name: 'memory_save',
1309
- description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,不抄原始输出里的具体数值。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1364
+ description: `保存一条记忆。只在会改变以后判断时才存;先搜已有条目,能更新就不要新建。保存前先蒸馏:标题是结论,正文只写原则与触发条件,通常 2–4 句;路径、字段表、完整命令、版本号、尺寸数值不要抄进正文,改成文档或 skill 的指针。用户明确说「记住」时直接调用;用户自己的话已经是长期规则(「以后都这样」「别再 X」)时也直接调用,不要先问。更新同一件事时带 id:身份只看标题、旧标题和前置条件,像不像不授权覆盖。文本相近但标题不同时先不写,返回 needsResolution 与 1–2 条相近条目:确实是同一条就用那个 id 更新,确实是另一条才带 resolution="distinct" 再存。带 id 更新时没给的字段保持原值,正文整段替换,旧正文只留作内部撤回,不展示也不参与检索。条数到硬上限时把同层最久没被读的一条移进归档,结果里写明动了哪条,不拒绝新记忆;单条正文超限只报错、不截断。前置条件不同的同一标题是两条。${NOTES}`,
1310
1365
  parameters: SAVE_PARAMS,
1311
1366
  output: {
1312
1367
  presentationMeta: (_args, value) => ({
@@ -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);
@@ -2149,7 +2250,7 @@ export function apply(ctx, config) {
2149
2250
  if (sawTool) throw new Error('整理返回了工具调用');
2150
2251
  /** 运行期间用户又给了新反馈:旧结果不能覆盖新纠正,直接作废,等排队的下一次整理。 */
2151
2252
  if ((feedbackRevisions.get(scope.key) || 0) !== candidate.feedbackRevision) {
2152
- throw new Error('反馈已更新,这次整理作废');
2253
+ throw new Error('反馈已更新,这轮结果没采用');
2153
2254
  }
2154
2255
  const parsed = learning.parseOutput(text, { finish, refs: input.refs, rules: input.rules, ranges });
2155
2256
  if (!parsed.ok) throw new Error(parsed.reason);
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.4",
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 });